From 6a353b508694f4938e132723d2ec63a6883d2e6a Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 30 Oct 2025 10:31:53 +0800 Subject: [PATCH] Add extension normalization command --- AGENTS.md | 4 +- cmd/extension.go | 92 +++++++ cmd/root.go | 1 + cmd/undo.go | 14 +- docs/cli-flags.md | 25 ++ internal/extension/apply.go | 109 ++++++++ internal/extension/conflicts.go | 58 ++++ internal/extension/doc.go | 3 + internal/extension/engine.go | 164 ++++++++++++ internal/extension/normalize.go | 59 +++++ internal/extension/parser.go | 77 ++++++ internal/extension/preview.go | 92 +++++++ internal/extension/request.go | 100 +++++++ internal/extension/summary.go | 92 +++++++ scripts/smoke-test-extension.sh | 42 +++ .../checklists/requirements.md | 34 +++ .../contracts/extension-command.yaml | 248 ++++++++++++++++++ specs/004-extension-rename/data-model.md | 66 +++++ specs/004-extension-rename/plan.md | 108 ++++++++ specs/004-extension-rename/quickstart.md | 28 ++ specs/004-extension-rename/research.md | 17 ++ specs/004-extension-rename/spec.md | 111 ++++++++ specs/004-extension-rename/tasks.md | 160 +++++++++++ testdata/extension/README.md | 21 ++ testdata/extension/sample/.hidden.JPG | 1 + testdata/extension/sample/image_one.jpeg | 1 + testdata/extension/sample/image_two.JPG | 1 + testdata/extension/sample/logo.jpg | 1 + testdata/extension/sample/nested/clip.jpeg | 1 + tests/contract/extension_command_test.go | 137 ++++++++++ tests/contract/extension_ledger_test.go | 167 ++++++++++++ tests/contract/extension_validation_test.go | 102 +++++++ tests/integration/extension_flow_test.go | 78 ++++++ tests/integration/extension_undo_test.go | 52 ++++ .../integration/extension_validation_test.go | 42 +++ 35 files changed, 2306 insertions(+), 2 deletions(-) create mode 100644 cmd/extension.go create mode 100644 internal/extension/apply.go create mode 100644 internal/extension/conflicts.go create mode 100644 internal/extension/doc.go create mode 100644 internal/extension/engine.go create mode 100644 internal/extension/normalize.go create mode 100644 internal/extension/parser.go create mode 100644 internal/extension/preview.go create mode 100644 internal/extension/request.go create mode 100644 internal/extension/summary.go create mode 100644 scripts/smoke-test-extension.sh create mode 100644 specs/004-extension-rename/checklists/requirements.md create mode 100644 specs/004-extension-rename/contracts/extension-command.yaml create mode 100644 specs/004-extension-rename/data-model.md create mode 100644 specs/004-extension-rename/plan.md create mode 100644 specs/004-extension-rename/quickstart.md create mode 100644 specs/004-extension-rename/research.md create mode 100644 specs/004-extension-rename/spec.md create mode 100644 specs/004-extension-rename/tasks.md create mode 100644 testdata/extension/README.md create mode 100644 testdata/extension/sample/.hidden.JPG create mode 100644 testdata/extension/sample/image_one.jpeg create mode 100644 testdata/extension/sample/image_two.JPG create mode 100644 testdata/extension/sample/logo.jpg create mode 100644 testdata/extension/sample/nested/clip.jpeg create mode 100644 tests/contract/extension_command_test.go create mode 100644 tests/contract/extension_ledger_test.go create mode 100644 tests/contract/extension_validation_test.go create mode 100644 tests/integration/extension_flow_test.go create mode 100644 tests/integration/extension_undo_test.go create mode 100644 tests/integration/extension_validation_test.go diff --git a/AGENTS.md b/AGENTS.md index b8dafd9..3a96f51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,8 @@ Auto-generated from all feature plans. Last updated: 2025-10-29 - Local filesystem (no persistent database) (002-add-replace-command) - Go 1.24 + `spf13/cobra`, `spf13/pflag` (001-list-command-filters) - Local filesystem only (ledger persisted as `.renamer`) (003-add-remove-command) +- Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/ledger packages (004-extension-rename) +- Local filesystem + `.renamer` ledger files (004-extension-rename) ## Project Structure @@ -38,9 +40,9 @@ tests/ - Smoke: `scripts/smoke-test-replace.sh`, `scripts/smoke-test-remove.sh` ## Recent Changes +- 004-extension-rename: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/ledger packages - 003-add-remove-command: Added sequential `renamer remove` subcommand, automation-friendly ledger metadata, and CLI warnings for duplicates/empty results - 002-add-replace-command: Added `renamer replace` command, ledger metadata, and automation docs. -- 001-list-command-filters: Added `renamer list` command with shared scope flags and formatters. diff --git a/cmd/extension.go b/cmd/extension.go new file mode 100644 index 0000000..2d6c51d --- /dev/null +++ b/cmd/extension.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/rogeecn/renamer/internal/extension" + "github.com/rogeecn/renamer/internal/listing" +) + +// NewExtensionCommand constructs the extension CLI command; exported for testing. +func NewExtensionCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "extension ", + Short: "Normalize multiple file extensions to a single target extension", + Args: cobra.MinimumNArgs(2), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + scope, err := listing.ScopeFromCmd(cmd) + if err != nil { + return err + } + + req := extension.NewRequest(scope) + + dryRun, err := getBool(cmd, "dry-run") + if err != nil { + return err + } + autoApply, err := getBool(cmd, "yes") + if err != nil { + return err + } + if dryRun && autoApply { + return errors.New("--dry-run cannot be combined with --yes; remove one of them") + } + req.SetExecutionMode(dryRun, autoApply) + + parsed, err := extension.ParseArgs(args) + if err != nil { + return fmt.Errorf("invalid extension arguments: %w", err) + } + + req.SetExtensions(parsed.SourcesCanonical, parsed.SourcesDisplay, parsed.Target) + req.SetWarnings(parsed.Duplicates, parsed.NoOps) + + summary, planned, err := extension.Preview(cmd.Context(), req, cmd.OutOrStdout()) + if err != nil { + return err + } + + if summary.HasConflicts() { + return errors.New("conflicts detected; resolve them before applying") + } + + if dryRun || !autoApply { + if !autoApply { + fmt.Fprintln(cmd.OutOrStdout(), "Preview complete. Re-run with --yes to apply.") + } + return nil + } + + if len(planned) == 0 { + if summary.TotalCandidates == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No candidates found.") + } else { + fmt.Fprintln(cmd.OutOrStdout(), "Nothing to apply; extensions already normalized.") + } + return nil + } + + entry, err := extension.Apply(cmd.Context(), req, planned, summary) + if err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Applied %d extension updates. Ledger updated.\n", len(entry.Operations)) + return nil + }, + } + + cmd.Example = ` renamer extension .jpeg .JPG .jpg --dry-run + renamer extension .yaml .yml .yml --yes --recursive` + + return cmd +} + +func init() { + rootCmd.AddCommand(NewExtensionCommand()) +} diff --git a/cmd/root.go b/cmd/root.go index d0bb4c2..7287c2b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -47,6 +47,7 @@ func NewRootCommand() *cobra.Command { cmd.AddCommand(newListCommand()) cmd.AddCommand(NewReplaceCommand()) cmd.AddCommand(NewRemoveCommand()) + cmd.AddCommand(NewExtensionCommand()) cmd.AddCommand(newUndoCommand()) return cmd diff --git a/cmd/undo.go b/cmd/undo.go index ef84b21..e6edf25 100644 --- a/cmd/undo.go +++ b/cmd/undo.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/spf13/cobra" @@ -25,7 +26,18 @@ func newUndoCommand() *cobra.Command { return err } - fmt.Fprintf(cmd.OutOrStdout(), "Undo applied: %d operations reversed\n", len(entry.Operations)) + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Undo applied: %d operations reversed\n", len(entry.Operations)) + + if entry.Command == "extension" && entry.Metadata != nil { + if target, ok := entry.Metadata["targetExtension"].(string); ok && target != "" { + fmt.Fprintf(out, "Restored extensions to %s\n", target) + } + if sources, ok := entry.Metadata["sourceExtensions"].([]string); ok && len(sources) > 0 { + fmt.Fprintf(out, "Previous sources: %s\n", strings.Join(sources, ", ")) + } + } + return nil }, } diff --git a/docs/cli-flags.md b/docs/cli-flags.md index ad08b97..ca80aff 100644 --- a/docs/cli-flags.md +++ b/docs/cli-flags.md @@ -71,3 +71,28 @@ renamer remove [token2 ...] [flags] - Preview sequential removals: `renamer remove " copy" " draft" --dry-run` - Remove tokens recursively: `renamer remove foo foo- --recursive --path ./reports` - Combine with extension filters: `renamer remove " Project" --extensions .txt|.md --dry-run` + +## Extension Command Quick Reference + +```bash +renamer extension [flags] +``` + +- Provide one or more dot-prefixed source extensions followed by the target extension. Validation + fails if any token omits the leading dot or repeats the target exactly. +- Source extensions are normalized case-insensitively; duplicates and no-op tokens are surfaced as + warnings in the preview rather than silently ignored. +- Preview output lists every candidate with `changed`, `no change`, or `skipped` status so scripts + can detect conflicts before applying. Conflicting targets block apply and exit with a non-zero + code. +- Scope flags (`--path`, `-r`, `-d`, `--hidden`, `--extensions`) determine which files and + directories participate. Hidden assets remain excluded unless `--hidden` is supplied. +- `--dry-run` (default) prints the plan without touching the filesystem. Re-run with `--yes` to + apply; attempting to combine both flags exits with an error. When no files match, the command + exits `0` after printing “No candidates found.” + +### Usage Examples + +- Preview normalization: `renamer extension .jpeg .JPG .jpg --dry-run` +- Apply case-folded extension updates: `renamer extension .yaml .yml .yml --yes --path ./configs` +- Include hidden assets recursively: `renamer extension .TMP .tmp --recursive --hidden` diff --git a/internal/extension/apply.go b/internal/extension/apply.go new file mode 100644 index 0000000..cc83f15 --- /dev/null +++ b/internal/extension/apply.go @@ -0,0 +1,109 @@ +package extension + +import ( + "context" + "errors" + "os" + "path/filepath" + "sort" + + "github.com/rogeecn/renamer/internal/history" +) + +// Apply executes planned renames and records the operations in the ledger. +func Apply(ctx context.Context, req *ExtensionRequest, planned []PlannedRename, summary *ExtensionSummary) (history.Entry, error) { + entry := history.Entry{Command: "extension"} + + if len(planned) == 0 { + return entry, nil + } + + sort.SliceStable(planned, func(i, j int) bool { + return planned[i].Depth > planned[j].Depth + }) + + done := make([]history.Operation, 0, len(planned)) + + revert := func() error { + for i := len(done) - 1; i >= 0; i-- { + op := done[i] + source := filepath.Join(req.WorkingDir, filepath.FromSlash(op.To)) + destination := filepath.Join(req.WorkingDir, filepath.FromSlash(op.From)) + if err := os.Rename(source, destination); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + return nil + } + + for _, op := range planned { + if err := ctx.Err(); err != nil { + _ = revert() + return history.Entry{}, err + } + + if op.OriginalAbsolute == op.ProposedAbsolute { + continue + } + + if err := os.Rename(op.OriginalAbsolute, op.ProposedAbsolute); err != nil { + _ = revert() + return history.Entry{}, err + } + + done = append(done, history.Operation{ + From: filepath.ToSlash(op.OriginalRelative), + To: filepath.ToSlash(op.ProposedRelative), + }) + } + + if len(done) == 0 { + return entry, nil + } + + entry.Operations = done + + if summary != nil { + meta := make(map[string]any) + if len(req.DisplaySourceExtensions) > 0 { + meta["sourceExtensions"] = append([]string(nil), req.DisplaySourceExtensions...) + } + if req.TargetExtension != "" { + meta["targetExtension"] = req.TargetExtension + } + meta["totalCandidates"] = summary.TotalCandidates + meta["totalChanged"] = summary.TotalChanged + meta["noChange"] = summary.NoChange + + if len(summary.PerExtensionCounts) > 0 { + counts := make(map[string]int, len(summary.PerExtensionCounts)) + for ext, count := range summary.PerExtensionCounts { + counts[ext] = count + } + meta["perExtensionCounts"] = counts + } + + scope := map[string]any{ + "includeDirs": req.IncludeDirs, + "recursive": req.Recursive, + "includeHidden": req.IncludeHidden, + } + if len(req.ExtensionFilter) > 0 { + scope["extensionFilter"] = append([]string(nil), req.ExtensionFilter...) + } + meta["scope"] = scope + + if len(summary.Warnings) > 0 { + meta["warnings"] = append([]string(nil), summary.Warnings...) + } + + entry.Metadata = meta + } + + if err := history.Append(req.WorkingDir, entry); err != nil { + _ = revert() + return history.Entry{}, err + } + + return entry, nil +} diff --git a/internal/extension/conflicts.go b/internal/extension/conflicts.go new file mode 100644 index 0000000..e172856 --- /dev/null +++ b/internal/extension/conflicts.go @@ -0,0 +1,58 @@ +package extension + +import ( + "errors" + "fmt" + "os" +) + +type conflictDetector struct { + planned map[string]string +} + +func newConflictDetector() *conflictDetector { + return &conflictDetector{planned: make(map[string]string)} +} + +// evaluateTarget inspects plan collisions and filesystem conflicts. It returns true when the +// rename can proceed, false when it must be skipped, propagating any hard errors encountered. +func (d *conflictDetector) evaluateTarget(summary *ExtensionSummary, candidateRel, targetRel, originalAbs, targetAbs string) (bool, error) { + if existing, ok := d.planned[targetRel]; ok && existing != candidateRel { + summary.AddConflict(Conflict{ + OriginalPath: candidateRel, + ProposedPath: targetRel, + Reason: "duplicate_target", + }) + summary.AddWarning(fmt.Sprintf("skipped %s because %s also maps to %s", candidateRel, existing, targetRel)) + return false, nil + } + + origInfo, origErr := os.Stat(originalAbs) + if origErr != nil { + return false, origErr + } + + if info, err := os.Stat(targetAbs); err == nil { + if os.SameFile(info, origInfo) { + d.planned[targetRel] = candidateRel + return true, nil + } + + reason := "existing_file" + if info.IsDir() { + reason = "existing_directory" + } + summary.AddConflict(Conflict{ + OriginalPath: candidateRel, + ProposedPath: targetRel, + Reason: reason, + }) + summary.AddWarning(fmt.Sprintf("skipped %s because %s already exists", candidateRel, targetRel)) + return false, nil + } else if !errors.Is(err, os.ErrNotExist) { + return false, err + } + + d.planned[targetRel] = candidateRel + return true, nil +} diff --git a/internal/extension/doc.go b/internal/extension/doc.go new file mode 100644 index 0000000..2dc2c32 --- /dev/null +++ b/internal/extension/doc.go @@ -0,0 +1,3 @@ +// Package extension provides the engine and utilities for normalizing file +// extensions using preview-first workflows shared across renamer commands. +package extension diff --git a/internal/extension/engine.go b/internal/extension/engine.go new file mode 100644 index 0000000..c804958 --- /dev/null +++ b/internal/extension/engine.go @@ -0,0 +1,164 @@ +package extension + +import ( + "context" + "errors" + "io/fs" + "path/filepath" + "strings" + + "github.com/rogeecn/renamer/internal/traversal" +) + +// PlannedRename describes a filesystem rename operation produced during planning. +type PlannedRename struct { + OriginalRelative string + OriginalAbsolute string + ProposedRelative string + ProposedAbsolute string + SourceExtension string + IsDir bool + Depth int +} + +// PlanResult captures the preview summary and concrete operations required for apply. +type PlanResult struct { + Summary *ExtensionSummary + Operations []PlannedRename +} + +// BuildPlan walks the scoped filesystem, collecting preview entries and rename operations. +func BuildPlan(ctx context.Context, req *ExtensionRequest) (*PlanResult, error) { + if req == nil { + return nil, errors.New("extension request cannot be nil") + } + if err := req.Normalize(); err != nil { + return nil, err + } + + summary := NewSummary() + operations := make([]PlannedRename, 0) + detector := newConflictDetector() + + targetExt := NormalizeTargetExtension(req.TargetExtension) + targetCanonical := CanonicalExtension(targetExt) + + sourceSet := make(map[string]struct{}, len(req.SourceExtensions)) + for _, source := range req.SourceExtensions { + sourceSet[CanonicalExtension(source)] = struct{}{} + } + + // Include the target extension so preview surfaces existing matches as no-ops. + matchable := make(map[string]struct{}, len(sourceSet)+1) + for ext := range sourceSet { + matchable[ext] = struct{}{} + } + matchable[targetCanonical] = struct{}{} + + filterSet := make(map[string]struct{}, len(req.ExtensionFilter)) + for _, filter := range req.ExtensionFilter { + filterSet[CanonicalExtension(filter)] = struct{}{} + } + + walker := traversal.NewWalker() + + err := walker.Walk( + req.WorkingDir, + req.Recursive, + req.IncludeDirs, + req.IncludeHidden, + 0, + func(relPath string, entry fs.DirEntry, depth int) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if relPath == "." { + return nil + } + + isDir := entry.IsDir() + if isDir && !req.IncludeDirs { + return nil + } + + name := entry.Name() + rawExt := strings.TrimSpace(filepath.Ext(name)) + canonicalExt := CanonicalExtension(rawExt) + + if !isDir && len(filterSet) > 0 { + if _, ok := filterSet[canonicalExt]; !ok { + return nil + } + } + + if _, ok := matchable[canonicalExt]; !ok { + return nil + } + + relative := filepath.ToSlash(relPath) + originalAbsolute := filepath.Join(req.WorkingDir, filepath.FromSlash(relative)) + + status := PreviewStatusChanged + targetRelative := relative + targetAbsolute := originalAbsolute + + if canonicalExt == targetCanonical && rawExt == targetExt { + status = PreviewStatusNoChange + } + + if status == PreviewStatusChanged { + base := strings.TrimSuffix(name, rawExt) + targetName := base + targetExt + dir := filepath.Dir(relative) + if dir == "." { + dir = "" + } + if dir == "" { + targetRelative = filepath.ToSlash(targetName) + } else { + targetRelative = filepath.ToSlash(filepath.Join(dir, targetName)) + } + targetAbsolute = filepath.Join(req.WorkingDir, filepath.FromSlash(targetRelative)) + + allowed, err := detector.evaluateTarget(summary, relative, targetRelative, originalAbsolute, targetAbsolute) + if err != nil { + return err + } + if allowed { + operations = append(operations, PlannedRename{ + OriginalRelative: relative, + OriginalAbsolute: originalAbsolute, + ProposedRelative: targetRelative, + ProposedAbsolute: targetAbsolute, + SourceExtension: rawExt, + IsDir: isDir, + Depth: depth, + }) + } else { + status = PreviewStatusSkipped + } + } + + entrySummary := PreviewEntry{ + OriginalPath: relative, + ProposedPath: targetRelative, + Status: status, + SourceExtension: rawExt, + } + summary.RecordEntry(entrySummary) + + return nil + }, + ) + if err != nil { + return nil, err + } + + return &PlanResult{ + Summary: summary, + Operations: operations, + }, nil +} diff --git a/internal/extension/normalize.go b/internal/extension/normalize.go new file mode 100644 index 0000000..e32ed24 --- /dev/null +++ b/internal/extension/normalize.go @@ -0,0 +1,59 @@ +package extension + +import "strings" + +// NormalizeSourceExtensions returns case-insensitive unique source extensions while preserving +// the first-seen display token for each canonical value. Duplicate entries are surfaced for warnings. +func NormalizeSourceExtensions(inputs []string) (canonical []string, display []string, duplicates []string) { + seen := make(map[string]struct{}) + + for _, raw := range inputs { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + continue + } + + canon := CanonicalExtension(trimmed) + if _, exists := seen[canon]; exists { + duplicates = append(duplicates, trimmed) + continue + } + + seen[canon] = struct{}{} + canonical = append(canonical, canon) + display = append(display, trimmed) + } + + return canonical, display, duplicates +} + +// NormalizeTargetExtension trims surrounding whitespace but preserves caller-provided casing. +func NormalizeTargetExtension(target string) string { + return strings.TrimSpace(target) +} + +// CanonicalExtension is the shared case-folded representation used for comparisons and maps. +func CanonicalExtension(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +// ExtensionsEqual reports true when two extensions match case-insensitively. +func ExtensionsEqual(a, b string) bool { + return CanonicalExtension(a) == CanonicalExtension(b) +} + +// IdentifyNoOpSources returns the original tokens that would be no-ops against the target extension. +func IdentifyNoOpSources(original []string, target string) []string { + if len(original) == 0 { + return nil + } + + canonTarget := CanonicalExtension(target) + var noOps []string + for _, token := range original { + if CanonicalExtension(token) == canonTarget { + noOps = append(noOps, token) + } + } + return noOps +} diff --git a/internal/extension/parser.go b/internal/extension/parser.go new file mode 100644 index 0000000..ea5229f --- /dev/null +++ b/internal/extension/parser.go @@ -0,0 +1,77 @@ +package extension + +import ( + "errors" + "fmt" + "strings" +) + +// ParseResult captures normalized CLI arguments for the extension command. +type ParseResult struct { + SourcesCanonical []string + SourcesDisplay []string + Duplicates []string + NoOps []string + Target string +} + +// ParseArgs validates extension command arguments and returns normalized tokens. +func ParseArgs(args []string) (ParseResult, error) { + if len(args) < 2 { + return ParseResult{}, errors.New("at least one source extension and a target extension are required") + } + + target := NormalizeTargetExtension(args[len(args)-1]) + if target == "" { + return ParseResult{}, errors.New("target extension cannot be empty") + } + if !strings.HasPrefix(target, ".") { + return ParseResult{}, fmt.Errorf("target extension %q must start with '.'", target) + } + if len(target) == 1 { + return ParseResult{}, fmt.Errorf("target extension %q must include characters after '.'", target) + } + + rawSources := args[:len(args)-1] + trimmedSources := make([]string, len(rawSources)) + for i, src := range rawSources { + trimmed := strings.TrimSpace(src) + if trimmed == "" { + return ParseResult{}, errors.New("source extensions cannot be empty") + } + if !strings.HasPrefix(trimmed, ".") { + return ParseResult{}, fmt.Errorf("source extension %q must start with '.'", trimmed) + } + if len(trimmed) == 1 { + return ParseResult{}, fmt.Errorf("source extension %q must include characters after '.'", trimmed) + } + trimmedSources[i] = trimmed + } + + canonical, display, duplicates := NormalizeSourceExtensions(trimmedSources) + targetCanonical := CanonicalExtension(target) + + filteredCanonical := make([]string, 0, len(canonical)) + filteredDisplay := make([]string, 0, len(display)) + noOps := make([]string, 0) + for i, canon := range canonical { + if canon == targetCanonical { + noOps = append(noOps, display[i]) + continue + } + filteredCanonical = append(filteredCanonical, canon) + filteredDisplay = append(filteredDisplay, display[i]) + } + + if len(filteredCanonical) == 0 { + return ParseResult{}, errors.New("all source extensions match the target extension; provide at least one distinct source extension") + } + + return ParseResult{ + SourcesCanonical: filteredCanonical, + SourcesDisplay: filteredDisplay, + Duplicates: duplicates, + NoOps: noOps, + Target: target, + }, nil +} diff --git a/internal/extension/preview.go b/internal/extension/preview.go new file mode 100644 index 0000000..09764e1 --- /dev/null +++ b/internal/extension/preview.go @@ -0,0 +1,92 @@ +package extension + +import ( + "context" + "errors" + "fmt" + "io" + "sort" + "strings" +) + +// Preview generates a summary and planned operations for an extension normalization run. +func Preview(ctx context.Context, req *ExtensionRequest, out io.Writer) (*ExtensionSummary, []PlannedRename, error) { + if req == nil { + return nil, nil, errors.New("extension request cannot be nil") + } + + plan, err := BuildPlan(ctx, req) + if err != nil { + return nil, nil, err + } + + summary := plan.Summary + + for _, dup := range req.DuplicateSources { + summary.AddWarning(fmt.Sprintf("duplicate source extension ignored: %s", dup)) + } + for _, noop := range req.NoOpSources { + summary.AddWarning(fmt.Sprintf("source extension matches target and is skipped: %s", noop)) + } + + if summary.TotalCandidates == 0 { + summary.AddWarning("no candidates found for provided extensions") + } + + if out != nil { + // Ensure deterministic ordering for preview output. + sort.SliceStable(summary.Entries, func(i, j int) bool { + if summary.Entries[i].OriginalPath == summary.Entries[j].OriginalPath { + return summary.Entries[i].ProposedPath < summary.Entries[j].ProposedPath + } + return summary.Entries[i].OriginalPath < summary.Entries[j].OriginalPath + }) + + conflictReasons := make(map[string]string, len(summary.Conflicts)) + for _, conflict := range summary.Conflicts { + key := fmt.Sprintf("%s->%s", conflict.OriginalPath, conflict.ProposedPath) + conflictReasons[key] = conflict.Reason + } + + for _, entry := range summary.Entries { + switch entry.Status { + case PreviewStatusChanged: + if entry.ProposedPath == entry.OriginalPath { + fmt.Fprintf(out, "%s (pending extension update)\n", entry.OriginalPath) + } else { + fmt.Fprintf(out, "%s -> %s\n", entry.OriginalPath, entry.ProposedPath) + } + case PreviewStatusNoChange: + fmt.Fprintf(out, "%s (no change)\n", entry.OriginalPath) + case PreviewStatusSkipped: + reason := conflictReasons[fmt.Sprintf("%s->%s", entry.OriginalPath, entry.ProposedPath)] + if reason == "" { + reason = "skipped" + } + fmt.Fprintf(out, "%s -> %s (skipped: %s)\n", entry.OriginalPath, entry.ProposedPath, reason) + default: + fmt.Fprintf(out, "%s (status: %s)\n", entry.OriginalPath, entry.Status) + } + } + + if summary.TotalCandidates > 0 { + fmt.Fprintf(out, "\nSummary: %d candidates, %d will change, %d already target extension\n", + summary.TotalCandidates, summary.TotalChanged, summary.NoChange) + } else { + fmt.Fprintln(out, "No candidates found.") + } + + if len(summary.Warnings) > 0 { + fmt.Fprintln(out) + for _, warning := range summary.Warnings { + if !strings.HasPrefix(strings.ToLower(warning), "warning:") { + fmt.Fprintf(out, "Warning: %s\n", warning) + } else { + fmt.Fprintln(out, warning) + } + } + } + } + + return summary, plan.Operations, nil +} diff --git a/internal/extension/request.go b/internal/extension/request.go new file mode 100644 index 0000000..02ad95b --- /dev/null +++ b/internal/extension/request.go @@ -0,0 +1,100 @@ +package extension + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/rogeecn/renamer/internal/listing" +) + +// ExtensionRequest captures all inputs required to evaluate an extension normalization run. +type ExtensionRequest struct { + WorkingDir string + SourceExtensions []string + DisplaySourceExtensions []string + TargetExtension string + DuplicateSources []string + NoOpSources []string + + IncludeDirs bool + Recursive bool + IncludeHidden bool + + ExtensionFilter []string + + DryRun bool + AutoConfirm bool + + Timestamp time.Time +} + +// NewRequest seeds an ExtensionRequest using the shared listing scope settings. +func NewRequest(scope *listing.ListingRequest) *ExtensionRequest { + if scope == nil { + return &ExtensionRequest{} + } + + filterCopy := append([]string(nil), scope.Extensions...) + + return &ExtensionRequest{ + WorkingDir: scope.WorkingDir, + IncludeDirs: scope.IncludeDirectories, + Recursive: scope.Recursive, + IncludeHidden: scope.IncludeHidden, + ExtensionFilter: filterCopy, + } +} + +// SetExecutionMode records dry-run/auto-apply preferences inherited from CLI flags. +func (r *ExtensionRequest) SetExecutionMode(dryRun, autoConfirm bool) { + r.DryRun = dryRun + r.AutoConfirm = autoConfirm +} + +// SetExtensions stores source and target extensions before normalization. +func (r *ExtensionRequest) SetExtensions(canonical []string, display []string, target string) { + r.SourceExtensions = append(r.SourceExtensions[:0], canonical...) + r.DisplaySourceExtensions = append(r.DisplaySourceExtensions[:0], display...) + r.TargetExtension = target +} + +// SetWarnings captures duplicate or no-op tokens for later surfacing in preview output. +func (r *ExtensionRequest) SetWarnings(dupes, noOps []string) { + r.DuplicateSources = append(r.DuplicateSources[:0], dupes...) + r.NoOpSources = append(r.NoOpSources[:0], noOps...) +} + +// Normalize ensures working directory and timestamp fields are ready for execution. +func (r *ExtensionRequest) Normalize() error { + if r.WorkingDir == "" { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("determine working directory: %w", err) + } + r.WorkingDir = cwd + } + + if !filepath.IsAbs(r.WorkingDir) { + abs, err := filepath.Abs(r.WorkingDir) + if err != nil { + return fmt.Errorf("resolve working directory: %w", err) + } + r.WorkingDir = abs + } + + info, err := os.Stat(r.WorkingDir) + if err != nil { + return fmt.Errorf("stat working directory: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("working directory %q is not a directory", r.WorkingDir) + } + + if r.Timestamp.IsZero() { + r.Timestamp = time.Now().UTC() + } + + return nil +} diff --git a/internal/extension/summary.go b/internal/extension/summary.go new file mode 100644 index 0000000..66817f1 --- /dev/null +++ b/internal/extension/summary.go @@ -0,0 +1,92 @@ +package extension + +import ( + "strings" +) + +// PreviewStatus represents the outcome for a single preview entry. +type PreviewStatus string + +const ( + PreviewStatusChanged PreviewStatus = "changed" + PreviewStatusNoChange PreviewStatus = "no_change" + PreviewStatusSkipped PreviewStatus = "skipped" +) + +// PreviewEntry captures a single original → proposed path mapping for preview output. +type PreviewEntry struct { + OriginalPath string + ProposedPath string + Status PreviewStatus + SourceExtension string +} + +// Conflict describes a proposed rename that cannot be applied safely. +type Conflict struct { + OriginalPath string + ProposedPath string + Reason string +} + +// ExtensionSummary aggregates counts, conflicts, warnings, and ledger metadata. +type ExtensionSummary struct { + TotalCandidates int + TotalChanged int + NoChange int + + PerExtensionCounts map[string]int + Conflicts []Conflict + Warnings []string + Entries []PreviewEntry + + LedgerMetadata map[string]any +} + +// NewSummary constructs an empty ExtensionSummary with initialized maps. +func NewSummary() *ExtensionSummary { + return &ExtensionSummary{ + PerExtensionCounts: make(map[string]int), + LedgerMetadata: make(map[string]any), + } +} + +// RecordEntry appends a preview entry and updates aggregate counters. +func (s *ExtensionSummary) RecordEntry(entry PreviewEntry) { + s.Entries = append(s.Entries, entry) + s.TotalCandidates++ + + switch entry.Status { + case PreviewStatusChanged: + s.TotalChanged++ + case PreviewStatusNoChange: + s.NoChange++ + } + + if entry.SourceExtension != "" { + key := strings.ToLower(entry.SourceExtension) + s.PerExtensionCounts[key]++ + } +} + +// AddConflict registers a new conflict encountered during planning. +func (s *ExtensionSummary) AddConflict(conflict Conflict) { + s.Conflicts = append(s.Conflicts, conflict) +} + +// AddWarning ensures warning messages are collected without duplication. +func (s *ExtensionSummary) AddWarning(message string) { + if message == "" { + return + } + for _, existing := range s.Warnings { + if existing == message { + return + } + } + s.Warnings = append(s.Warnings, message) +} + +// HasConflicts reports whether any blocking conflicts were recorded. +func (s *ExtensionSummary) HasConflicts() bool { + return len(s.Conflicts) > 0 +} diff --git a/scripts/smoke-test-extension.sh b/scripts/smoke-test-extension.sh new file mode 100644 index 0000000..733513b --- /dev/null +++ b/scripts/smoke-test-extension.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN="go run" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +touch "$TMP_DIR/photo.jpeg" +touch "$TMP_DIR/poster.JPG" +touch "$TMP_DIR/logo.jpg" + +echo "Previewing extension normalization..." +$BIN "$ROOT_DIR/main.go" extension .jpeg .JPG .jpg --path "$TMP_DIR" --dry-run >/dev/null + +echo "Applying extension normalization..." +$BIN "$ROOT_DIR/main.go" extension .jpeg .JPG .jpg --path "$TMP_DIR" --yes >/dev/null + +if [[ ! -f "$TMP_DIR/photo.jpg" ]]; then + echo "expected photo.jpg to exist" >&2 + exit 1 +fi + +if [[ ! -f "$TMP_DIR/poster.jpg" ]]; then + echo "expected poster.jpg to exist" >&2 + exit 1 +fi + +echo "Undoing extension normalization..." +$BIN "$ROOT_DIR/main.go" undo --path "$TMP_DIR" >/dev/null + +if [[ ! -f "$TMP_DIR/photo.jpeg" ]]; then + echo "undo failed to restore photo.jpeg" >&2 + exit 1 +fi + +if [[ ! -f "$TMP_DIR/poster.JPG" ]]; then + echo "undo failed to restore poster.JPG" >&2 + exit 1 +fi + +echo "Extension smoke test succeeded." diff --git a/specs/004-extension-rename/checklists/requirements.md b/specs/004-extension-rename/checklists/requirements.md new file mode 100644 index 0000000..982c283 --- /dev/null +++ b/specs/004-extension-rename/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Extension Command for Multi-Extension Normalization + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-10-29 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [X] No implementation details (languages, frameworks, APIs) +- [X] Focused on user value and business needs +- [X] Written for non-technical stakeholders +- [X] All mandatory sections completed + +## Requirement Completeness + +- [X] No [NEEDS CLARIFICATION] markers remain +- [X] Requirements are testable and unambiguous +- [X] Success criteria are measurable +- [X] Success criteria are technology-agnostic (no implementation details) +- [X] All acceptance scenarios are defined +- [X] Edge cases are identified +- [X] Scope is clearly bounded +- [X] Dependencies and assumptions identified + +## Feature Readiness + +- [X] All functional requirements have clear acceptance criteria +- [X] User scenarios cover primary flows +- [X] Feature meets measurable outcomes defined in Success Criteria +- [X] No implementation details leak into specification + +## Notes + +- Specification validated on 2025-10-29. diff --git a/specs/004-extension-rename/contracts/extension-command.yaml b/specs/004-extension-rename/contracts/extension-command.yaml new file mode 100644 index 0000000..970dea9 --- /dev/null +++ b/specs/004-extension-rename/contracts/extension-command.yaml @@ -0,0 +1,248 @@ +openapi: 3.1.0 +info: + title: Renamer Extension Command API + version: 0.1.0 + description: > + Contract representation of the `renamer extension` command workflows (preview/apply/undo) + for testing harnesses and documentation parity. +servers: + - url: cli://renamer + description: Command-line invocation surface +paths: + /extension/preview: + post: + summary: Preview extension normalization results + description: Mirrors `renamer extension [source-ext...] [target-ext] --dry-run` + operationId: previewExtension + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExtensionRequest' + responses: + '200': + description: Successful preview + content: + application/json: + schema: + $ref: '#/components/schemas/ExtensionPreview' + '400': + description: Validation error (invalid extensions, missing arguments) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /extension/apply: + post: + summary: Apply extension normalization + description: Mirrors `renamer extension [source-ext...] [target-ext] --yes` + operationId: applyExtension + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ExtensionRequest' + - type: object + properties: + dryRun: + type: boolean + const: false + responses: + '200': + description: Successful apply (may include no-change entries) + content: + application/json: + schema: + $ref: '#/components/schemas/ExtensionApplyResult' + '409': + description: Conflict detected; apply refused + content: + application/json: + schema: + $ref: '#/components/schemas/ConflictResponse' + '400': + description: Validation error (invalid extensions, missing arguments) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /extension/undo: + post: + summary: Undo the most recent extension batch + description: Mirrors `renamer undo` when last ledger entry was from extension command. + operationId: undoExtension + responses: + '200': + description: Undo succeeded and ledger updated + content: + application/json: + schema: + $ref: '#/components/schemas/UndoResult' + '409': + description: Ledger inconsistent or missing entry + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' +components: + schemas: + ExtensionRequest: + type: object + required: + - workingDir + - sourceExtensions + - targetExtension + properties: + workingDir: + type: string + description: Absolute path for command scope. + sourceExtensions: + type: array + description: Ordered list of dot-prefixed extensions to normalize (case-insensitive uniqueness). + minItems: 1 + items: + type: string + pattern: '^\\.[\\w\\-]+$' + targetExtension: + type: string + description: Dot-prefixed extension applied to all matches. + pattern: '^\\.[\\w\\-]+$' + includeDirs: + type: boolean + default: false + recursive: + type: boolean + default: false + includeHidden: + type: boolean + default: false + extensionFilter: + type: array + items: + type: string + pattern: '^\\.[\\w\\-]+$' + dryRun: + type: boolean + default: true + autoConfirm: + type: boolean + default: false + ExtensionPreview: + type: object + required: + - totalCandidates + - totalChanged + - noChange + - entries + properties: + totalCandidates: + type: integer + minimum: 0 + totalChanged: + type: integer + minimum: 0 + noChange: + type: integer + minimum: 0 + conflicts: + type: array + items: + $ref: '#/components/schemas/Conflict' + warnings: + type: array + items: + type: string + entries: + type: array + items: + $ref: '#/components/schemas/PreviewEntry' + ExtensionApplyResult: + type: object + required: + - totalApplied + - noChange + - ledgerEntryId + properties: + totalApplied: + type: integer + minimum: 0 + noChange: + type: integer + minimum: 0 + ledgerEntryId: + type: string + warnings: + type: array + items: + type: string + UndoResult: + type: object + required: + - restored + - ledgerEntryId + properties: + restored: + type: integer + ledgerEntryId: + type: string + message: + type: string + Conflict: + type: object + required: + - originalPath + - proposedPath + - reason + properties: + originalPath: + type: string + proposedPath: + type: string + reason: + type: string + enum: + - duplicate_target + - existing_file + PreviewEntry: + type: object + required: + - originalPath + - proposedPath + - status + properties: + originalPath: + type: string + proposedPath: + type: string + status: + type: string + enum: + - changed + - no_change + - skipped + sourceExtension: + type: string + ErrorResponse: + type: object + required: + - error + properties: + error: + type: string + remediation: + type: string + ConflictResponse: + type: object + required: + - error + - conflicts + properties: + error: + type: string + conflicts: + type: array + items: + $ref: '#/components/schemas/Conflict' diff --git a/specs/004-extension-rename/data-model.md b/specs/004-extension-rename/data-model.md new file mode 100644 index 0000000..3809033 --- /dev/null +++ b/specs/004-extension-rename/data-model.md @@ -0,0 +1,66 @@ +# Data Model – Extension Command + +## Entity: ExtensionRequest +- **Fields** + - `WorkingDir string` — Absolute path resolved from CLI `--path` or current directory. + - `SourceExtensions []string` — Ordered list of unique, dot-prefixed source extensions (case-insensitive comparisons). + - `TargetExtension string` — Dot-prefixed extension applied verbatim during rename. + - `IncludeDirs bool` — Mirrors `--include-dirs` scope flag. + - `Recursive bool` — Mirrors `--recursive` traversal flag. + - `IncludeHidden bool` — True only when `--hidden` supplied. + - `ExtensionFilter []string` — Normalized extension filter from `--extensions`, applied before source matching. + - `DryRun bool` — Indicates preview-only execution (`--dry-run`). + - `AutoConfirm bool` — Captures `--yes` for non-interactive apply. + - `Timestamp time.Time` — Invocation timestamp for ledger correlation. +- **Validation Rules** + - Require at least one source extension and one target extension (total args ≥ 2). + - All extensions MUST start with `.` and contain ≥1 alphanumeric character after the dot. + - Deduplicate source extensions case-insensitively; warn on duplicates/no-ops. + - Target extension MUST NOT be empty and MUST include leading dot. + - Reject empty string tokens after trimming whitespace. +- **Relationships** + - Consumed by the extension rule engine to enumerate candidate files. + - Serialized into ledger metadata alongside `ExtensionSummary`. + +## Entity: ExtensionSummary +- **Fields** + - `TotalCandidates int` — Number of filesystem entries examined post-scope filtering. + - `TotalChanged int` — Count of files scheduled for rename (target extension applied). + - `NoChange int` — Count of files already matching `TargetExtension`. + - `PerExtensionCounts map[string]int` — Matches per source extension (case-insensitive key). + - `Conflicts []Conflict` — Entries describing colliding target paths. + - `Warnings []string` — Validation and scope warnings (duplicates, no matches, hidden skipped). + - `PreviewEntries []PreviewEntry` — Ordered list of original/new paths with status `changed|no_change|skipped`. + - `LedgerMetadata map[string]any` — Snapshot persisted with ledger entry (sources, target, flags). +- **Validation Rules** + - Conflicts list MUST be empty before allowing apply. + - `NoChange + TotalChanged` MUST equal count of entries included in preview. + - Preview entries MUST be deterministic (stable sort by original path). +- **Relationships** + - Emitted to preview renderer for CLI output formatting. + - Persisted with ledger entry for undo operations and audits. + +## Entity: Conflict +- **Fields** + - `OriginalPath string` — Existing file path causing the collision. + - `ProposedPath string` — Target path that clashes with another candidate. + - `Reason string` — Short code (e.g., `duplicate_target`, `existing_file`) describing conflict type. +- **Validation Rules** + - `ProposedPath` MUST be unique across non-conflicting entries. + - Reasons limited to known enum for consistent messaging. +- **Relationships** + - Referenced within `ExtensionSummary.Conflicts`. + - Propagated to CLI preview warnings. + +## Entity: PreviewEntry +- **Fields** + - `OriginalPath string` + - `ProposedPath string` + - `Status string` — `changed`, `no_change`, or `skipped`. + - `SourceExtension string` — Detected source extension (normalized lowercase). +- **Validation Rules** + - `Status` MUST be one of the defined constants. + - `ProposedPath` MUST equal `OriginalPath` when `Status == "no_change"`. +- **Relationships** + - Aggregated into `ExtensionSummary.PreviewEntries`. + - Used by preview renderer and ledger writer. diff --git a/specs/004-extension-rename/plan.md b/specs/004-extension-rename/plan.md new file mode 100644 index 0000000..d50536c --- /dev/null +++ b/specs/004-extension-rename/plan.md @@ -0,0 +1,108 @@ +# Implementation Plan: Extension Command for Multi-Extension Normalization + +**Branch**: `004-extension-rename` | **Date**: 2025-10-30 | **Spec**: `specs/004-extension-rename/spec.md` +**Input**: Feature specification from `/specs/004-extension-rename/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. + +## Summary + +Deliver a `renamer extension` subcommand that normalizes one or more source extensions to a single target extension with deterministic previews, ledger-backed undo, and automation-friendly exit codes. The implementation will extend existing replace/remove infrastructure, ensuring case-insensitive extension matching, hidden-file opt-in, conflict detection, and “no change” handling for already-targeted files. + +## Technical Context + + + +**Language/Version**: Go 1.24 +**Primary Dependencies**: `spf13/cobra`, `spf13/pflag`, internal traversal/ledger packages +**Storage**: Local filesystem + `.renamer` ledger files +**Testing**: `go test ./...`, smoke + integration scripts under `tests/` and `scripts/` +**Target Platform**: Cross-platform CLI (Linux, macOS, Windows shells) +**Project Type**: Single CLI project (`cmd/`, `internal/`, `tests/`, `scripts/`) +**Performance Goals**: Normalize 500 files (preview+apply) in <2 minutes end-to-end +**Constraints**: Preview-first workflow, ledger append-only, hidden files excluded unless `--hidden` +**Scale/Scope**: Operates on thousands of filesystem entries per invocation within local directory trees + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Preview flow MUST show deterministic rename mappings and require explicit confirmation (Preview-First Safety). ✅ Extend existing preview engine to list original → target paths, highlighting extension changes and “no change” rows; apply remains gated by `--yes`. +- Undo strategy MUST describe how the `.renamer` ledger entry is written and reversed (Persistent Undo Ledger). ✅ Record source extension list, target, per-file outcomes in ledger entry and ensure undo replays entries via existing ledger service. +- Planned rename rules MUST document their inputs, validations, and composing order (Composable Rule Engine). ✅ Define an extension normalization rule that consumes scope matches, validates tokens, deduplicates case-insensitively, and reuses sequencing from replace engine. +- Scope handling MUST cover files vs directories (`-d`), recursion (`-r`), and extension filtering via `-e` without escaping the requested path (Scope-Aware Traversal). ✅ Continue relying on shared traversal component honoring flags; ensure hidden assets stay excluded unless `--hidden`. +- CLI UX plan MUST confirm Cobra usage, flag naming, help text, and automated tests for preview/undo flows (Ergonomic CLI Stewardship). ✅ Implement `extension` Cobra command mirroring existing flag sets, update help text, and add contract/integration tests for preview, apply, and undo. + +*Post-Design Verification (2025-10-30): Research and design artifacts document preview flow, ledger metadata, rule composition, scope behavior, and Cobra UX updates—no gate violations detected.* + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + + +```text +cmd/ +├── root.go +├── list.go +├── replace.go +├── remove.go +└── undo.go + +internal/ +├── filters/ +├── history/ +├── listing/ +├── output/ +├── remove/ +├── replace/ +├── traversal/ +└── extension/ # new package for extension normalization engine + +scripts/ +├── smoke-test-remove.sh +└── smoke-test-replace.sh + +tests/ +├── contract/ +│ ├── remove_command_ledger_test.go +│ ├── remove_command_preview_test.go +│ ├── replace_command_test.go +│ └── (new) extension_command_test.go +├── integration/ +│ ├── remove_flow_test.go +│ ├── remove_undo_test.go +│ ├── remove_validation_test.go +│ └── replace_flow_test.go +└── fixtures/ # shared test inputs +``` + +**Structure Decision**: Maintain the single CLI project layout, adding an `internal/extension` package plus contract/integration tests mirroring existing replace/remove coverage. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| _None_ | — | — | diff --git a/specs/004-extension-rename/quickstart.md b/specs/004-extension-rename/quickstart.md new file mode 100644 index 0000000..67dbcf9 --- /dev/null +++ b/specs/004-extension-rename/quickstart.md @@ -0,0 +1,28 @@ +# Quickstart – Extension Normalization Command + +1. **Preview extension changes.** + ```bash + renamer extension .jpeg .JPG .jpg --dry-run + ``` + - Shows original → proposed paths. + - Highlights conflicts or “no change” rows for files already ending in `.jpg`. + +2. **Include nested directories or hidden assets when needed.** + ```bash + renamer extension .yaml .yml .yml --recursive --hidden --dry-run + ``` + - `--recursive` traverses subdirectories. + - `--hidden` opt-in keeps hidden files in scope. + +3. **Apply changes after confirming preview.** + ```bash + renamer extension .jpeg .JPG .jpg --yes + ``` + - `--yes` auto-confirms preview results. + - Command exits `0` even if no files matched (prints “no candidates found”). + +4. **Undo the most recent batch if needed.** + ```bash + renamer undo + ``` + - Restores original extensions using `.renamer` ledger entry. diff --git a/specs/004-extension-rename/research.md b/specs/004-extension-rename/research.md new file mode 100644 index 0000000..8096323 --- /dev/null +++ b/specs/004-extension-rename/research.md @@ -0,0 +1,17 @@ +# Phase 0 Research – Extension Command + +## Decision: Reuse Replace Engine Structure for Extension Normalization +- **Rationale**: The replace command already supports preview/apply workflows, ledger logging, and shared traversal flags. By reusing its service abstractions (scope resolution → rule application → preview), we minimize new surface area while ensuring compliance with preview-first safety. +- **Alternatives considered**: Building a standalone engine dedicated to extensions was rejected because it would duplicate scope traversal, preview formatting, and ledger writing logic, increasing maintenance and divergence risk. + +## Decision: Normalize Extensions Using Case-Insensitive Matching +- **Rationale**: Filesystems differ in case sensitivity; normalizing via `strings.EqualFold` (or equivalent) ensures consistent behavior regardless of platform, aligning with the spec’s clarification and reducing surprise for users migrating mixed-case assets. +- **Alternatives considered**: Relying on filesystem semantics was rejected because it would produce divergent behavior (e.g., Linux vs. macOS). Requiring exact-case matches was rejected for being unfriendly to legacy archives with mixed casing. + +## Decision: Record Detailed Extension Metadata in Ledger Entries +- **Rationale**: Persisting the original extension list, target extension, and per-file before/after paths in ledger metadata keeps undo operations auditable and allows future analytics (e.g., reporting normalized counts). Existing ledger schema supports additional metadata fields without incompatible changes. +- **Alternatives considered**: Storing only file path mappings was rejected because it obscures which extensions were targeted, hindering debugging. Creating a new ledger file was rejected for complicating undo logic. + +## Decision: Extend Cobra CLI with Positional Arguments for Extensions +- **Rationale**: Cobra natural handling of positional args enables `renamer extension [sources...] [target]`. Using argument parsing consistent with replace/remove reduces UX learning curve, and Cobra’s validation hooks simplify enforcing leading-dot requirements. +- **Alternatives considered**: Introducing new flags (e.g., `--sources`, `--target`) was rejected because it diverges from existing command patterns and complicates scripting. Using prompts was rejected due to automation needs. diff --git a/specs/004-extension-rename/spec.md b/specs/004-extension-rename/spec.md new file mode 100644 index 0000000..a6241dd --- /dev/null +++ b/specs/004-extension-rename/spec.md @@ -0,0 +1,111 @@ +# Feature Specification: Extension Command for Multi-Extension Normalization + +**Feature Branch**: `004-extension-rename` +**Created**: 2025-10-29 +**Status**: Draft +**Input**: User description: "实现扩展名修改(Extension)命令,类似于 replace 命令,可以支持把多个扩展名更改为一个指定的扩展名" + +## Clarifications + +### Session 2025-10-30 +- Q: Should extension comparisons treat casing uniformly or follow the host filesystem? → A: Always case-insensitive +- Q: How should hidden files be handled when `--hidden` is omitted? → A: Exclude hidden entries +- Q: What exit behavior should occur when no files match the given extensions? → A: Exit 0 with notice +- Q: How should files that already have the target extension be represented? → A: Preview as no-change; skip on apply + +## User Scenarios & Testing _(mandatory)_ + +### User Story 1 - Normalize Legacy Extensions in Bulk (Priority: P1) + +As a power user cleaning project assets, I want a command to replace multiple file extensions with a single standardized extension so that I can align legacy files (e.g., `.jpeg`, `.JPG`) without hand-editing each one. + +**Why this priority**: Delivers the core business value—fast extension normalization across large folders. + +**Independent Test**: In a sample directory containing `.jpeg`, `.JPG`, and `.png`, run `renamer extension .jpeg .JPG .png .jpg --dry-run`, verify preview shows the new `.jpg` extension for each, then apply with `--yes` and confirm filesystem updates. + +**Acceptance Scenarios**: + +1. **Given** files with extensions `.jpeg` and `.JPG`, **When** the user runs `renamer extension .jpeg .JPG .jpg`, **Then** the preview lists each file with the `.jpg` extension and apply renames successfully. +2. **Given** nested directories, **When** the user adds `--recursive`, **Then** all matching extensions in subdirectories are normalized while unrelated files remain untouched. + +--- + +### User Story 2 - Automation-Friendly Extension Updates (Priority: P2) + +As an operator integrating renamer into CI scripts, I want deterministic exit codes, ledger entries, and undo support when changing extensions so automated jobs remain auditable and recoverable. + +**Why this priority**: Ensures enterprise workflows can rely on extension updates without risking data loss. + +**Independent Test**: Run `renamer extension .yaml .yml .yml --yes --path ./fixtures`, verify exit code `0`, inspect `.renamer` ledger for recorded operations, and confirm `renamer undo` restores originals. + +**Acceptance Scenarios**: + +1. **Given** a non-interactive environment with `--yes`, **When** the command completes without conflicts, **Then** exit code is `0` and ledger metadata captures original extension list and target extension. +2. **Given** a ledger entry exists, **When** `renamer undo` runs, **Then** all files revert to their prior extensions even if the command was executed by automation. + +--- + +### User Story 3 - Validate Extension Inputs and Conflicts (Priority: P3) + +As a user preparing an extension migration, I want validation and preview warnings for invalid tokens, duplicate target names, and no-op operations so I can adjust before committing changes. + +**Why this priority**: Reduces support load from misconfigured commands and protects against accidental overwrites. + +**Independent Test**: Run `renamer extension .mp3 .MP3 mp3 --dry-run`, confirm validation fails because tokens must include leading `.`, and run a scenario where resulting filenames collide to ensure conflicts abort the apply step. + +**Acceptance Scenarios**: + +1. **Given** invalid input (e.g., missing leading `.` or fewer than two arguments), **When** the command executes, **Then** it exits with non-zero status and prints actionable guidance. +2. **Given** two files that would become the same path after extension normalization, **When** the preview runs, **Then** conflicts are listed and the apply step refuses to proceed until resolved. + +--- + +### Edge Cases + +- How does the rename plan surface conflicts when multiple files map to the same normalized extension? +- When the target extension already matches some files, they appear in preview with a “no change” indicator and are skipped during apply without raising errors. +- Hidden files and directories remain excluded unless the user supplies `--hidden`. +- When no files match in preview or apply, the command must surface a “no candidates found” notice while completing successfully. + +## Requirements _(mandatory)_ + +### Functional Requirements + +- **FR-001**: CLI MUST provide a dedicated `extension` subcommand that accepts one or more source extensions and a single target extension as positional arguments. +- **FR-002**: Preview → confirm workflow MUST mirror existing commands: list original paths, proposed paths, and highlight extension changes before apply. +- **FR-003**: Executions MUST append ledger entries capturing original extension list, target extension, affected files, and timestamps to support undo. +- **FR-004**: Users MUST be able to undo the most recent extension batch via existing undo mechanics without leaving orphaned files. +- **FR-005**: Command MUST respect global scope flags (`--path`, `--recursive`, `--include-dirs`, `--hidden`, `--extensions`, `--dry-run`, `--yes`) consistent with `list` and `replace`, excluding hidden files and directories unless `--hidden` is explicitly supplied. +- **FR-006**: Extension parsing MUST require leading `.` tokens, deduplicate case-insensitively, warn when duplicates or no-op tokens are supplied, and compare file extensions case-insensitively across all platforms. +- **FR-007**: Preview MUST detect target conflicts (two files mapping to the same new path) and block apply until conflicts are resolved. +- **FR-008**: Invalid invocations (e.g., fewer than two arguments, empty tokens after trimming) MUST exit with non-zero status and provide remediation tips. +- **FR-009**: Help output MUST clearly explain argument order, sequential evaluation rules, and interaction with scope flags. +- **FR-010**: When no files match the provided extensions, preview and apply runs MUST emit a clear “no candidates found” message and exit with status `0`. +- **FR-011**: Preview MUST surface already-targeted files with a “no change” marker, and apply MUST skip them while returning success. + +### Key Entities + +- **ExtensionRequest**: Working directory, ordered source extension list, target extension, scope flags, dry-run/apply settings. +- **ExtensionSummary**: Totals for candidates, changed files, per-extension match counts, conflicts, and warning messages used for preview and ledger metadata. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: Users normalize 500 files’ extensions (preview + apply) in under 2 minutes end-to-end. +- **SC-002**: 95% of beta testers correctly supply arguments (source extensions + target) after reading `renamer extension --help` without additional guidance. +- **SC-003**: Automated regression tests confirm extension change + undo leave the filesystem unchanged in 100% of scripted scenarios. +- **SC-004**: Support tickets related to inconsistent file extensions drop by 30% within the first release cycle after launch. + +## Assumptions + +- Command name will be `renamer extension` to align with existing verb-noun conventions. +- Source extensions are literal matches with leading dots; wildcard or regex patterns remain out of scope. +- Target extension must include a leading dot and is applied case-sensitively as provided. +- Existing traversal, summary, and ledger infrastructure can be extended from the replace/remove commands. + +## Dependencies & Risks + +- Requires new extension-specific packages analogous to replace/remove for parser, engine, summary, and CLI wiring. +- Help/quickstart documentation must be updated to explain argument order and extension validation. +- Potential filename conflicts after normalization must be detected pre-apply to avoid overwriting files. diff --git a/specs/004-extension-rename/tasks.md b/specs/004-extension-rename/tasks.md new file mode 100644 index 0000000..e87338b --- /dev/null +++ b/specs/004-extension-rename/tasks.md @@ -0,0 +1,160 @@ +# Tasks: Extension Command for Multi-Extension Normalization + +**Input**: Design documents from `/specs/004-extension-rename/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ + +**Tests**: Contract and integration tests are included because the specification mandates deterministic previews, ledger-backed undo, and automation safety. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Establish initial package and CLI scaffolding. + +- [X] T001 Create package doc stub for extension engine in `internal/extension/doc.go` +- [X] T002 Register placeholder Cobra subcommand in `cmd/extension.go` and wire it into `cmd/root.go` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core data structures and utilities required by all user stories. + +- [X] T003 Define `ExtensionRequest` parsing helpers in `internal/extension/request.go` +- [X] T004 Define `ExtensionSummary`, preview statuses, and metadata container in `internal/extension/summary.go` +- [X] T005 Implement case-insensitive normalization and dedup helpers in `internal/extension/normalize.go` + +**Checkpoint**: Foundation ready — user story implementation can now begin. + +--- + +## Phase 3: User Story 1 – Normalize Legacy Extensions in Bulk (Priority: P1) 🎯 MVP + +**Goal**: Provide preview and apply flows that replace multiple source extensions with a single target extension across the scoped filesystem. + +**Independent Test**: In a directory containing mixed `.jpeg`, `.JPG`, and `.png` files, run `renamer extension .jpeg .JPG .png .jpg --dry-run` to inspect preview output, then run with `--yes` to confirm filesystem updates. + +### Tests for User Story 1 + +- [X] T010 [P] [US1] Add preview/apply contract coverage in `tests/contract/extension_command_test.go` +- [X] T011 [P] [US1] Add normalization happy-path integration flow in `tests/integration/extension_flow_test.go` + +### Implementation for User Story 1 + +- [X] T006 [US1] Implement scoped candidate discovery and planning in `internal/extension/engine.go` +- [X] T007 [US1] Render deterministic preview entries with change/no-change markers in `internal/extension/preview.go` +- [X] T008 [US1] Apply planned renames with filesystem operations in `internal/extension/apply.go` +- [X] T009 [US1] Wire Cobra command to preview/apply pipeline with scope flags in `cmd/extension.go` + +**Checkpoint**: User Story 1 functional and independently testable. + +--- + +## Phase 4: User Story 2 – Automation-Friendly Extension Updates (Priority: P2) + +**Goal**: Ensure ledger entries, exit codes, and undo support enable scripted execution without manual intervention. + +**Independent Test**: Execute `renamer extension .yaml .yml .yml --yes --path ./fixtures`, verify exit code `0`, inspect `.renamer` ledger for metadata, then run `renamer undo` to restore originals. + +### Tests for User Story 2 + +- [X] T015 [P] [US2] Extend contract tests to verify ledger metadata and exit codes in `tests/contract/extension_ledger_test.go` +- [X] T016 [P] [US2] Add automation/undo integration scenario in `tests/integration/extension_undo_test.go` + +### Implementation for User Story 2 + +- [X] T012 [US2] Persist extension-specific metadata during apply in `internal/extension/apply.go` +- [X] T013 [US2] Ensure undo and CLI output handle extension batches in `cmd/undo.go` +- [X] T014 [US2] Guarantee deterministic exit codes and non-match notices in `cmd/extension.go` + +**Checkpoint**: User Stories 1 and 2 functional and independently testable. + +--- + +## Phase 5: User Story 3 – Validate Extension Inputs and Conflicts (Priority: P3) + +**Goal**: Provide robust input validation and conflict detection to prevent unsafe applies. + +**Independent Test**: Run `renamer extension .mp3 .MP3 mp3 --dry-run` to confirm validation failure for missing dot, and run a collision scenario to verify preview conflicts block apply. + +### Tests for User Story 3 + +- [X] T020 [P] [US3] Add validation and conflict contract coverage in `tests/contract/extension_validation_test.go` +- [X] T021 [P] [US3] Add conflict-blocking integration scenario in `tests/integration/extension_validation_test.go` + +### Implementation for User Story 3 + +- [X] T017 [US3] Implement CLI argument validation and error messaging in `internal/extension/parser.go` +- [X] T018 [US3] Detect conflicting target paths and accumulate preview warnings in `internal/extension/conflicts.go` +- [X] T019 [US3] Surface validation failures and conflict gating in `cmd/extension.go` + +**Checkpoint**: All user stories functional with independent validation. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Documentation, tooling, and quality improvements spanning multiple stories. + +- [X] T022 Update CLI flag documentation for extension command in `docs/cli-flags.md` +- [X] T023 Add smoke test script covering extension preview/apply/undo in `scripts/smoke-test-extension.sh` +- [X] T024 Run gofmt and `go test ./...` from repository root `./` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +1. **Setup (Phase 1)** → primes new package and CLI wiring. +2. **Foundational (Phase 2)** → must complete before any user story work. +3. **User Story Phases (3–5)** → execute in priority order (P1 → P2 → P3) once foundational tasks finish. +4. **Polish (Phase 6)** → after desired user stories are complete. + +### User Story Dependencies + +- **US1 (P1)** → depends on Phase 2 completion; delivers MVP. +- **US2 (P2)** → depends on US1 groundwork (ledger metadata builds atop apply logic). +- **US3 (P3)** → depends on US1 preview/apply pipeline; validation hooks extend existing command. + +### Task Dependencies (Selected) + +- T006 depends on T003–T005. +- T007, T008 depend on T006. +- T009 depends on T006–T008. +- T012 depends on T008. +- T013, T014 depend on T012. +- T017 depends on T003, T005. +- T018 depends on T006–T007. +- T019 depends on T017–T018. + +--- + +## Parallel Execution Examples + +- **Within US1**: After T009, tasks T010 and T011 can run in parallel to add contract and integration coverage. +- **Across Stories**: Once US1 implementation (T006–T009) is complete, US2 test tasks T015 and T016 can proceed in parallel while T012–T014 are under development. +- **Validation Work**: For US3, T020 and T021 can execute in parallel after T019 ensures CLI gating is wired. + +--- + +## Implementation Strategy + +### MVP First + +1. Complete Phases 1–2 to establish scaffolding and data structures. +2. Deliver Phase 3 (US1) to unlock core extension normalization (MVP). +3. Validate with contract/integration tests (T010, T011) and smoke through Quickstart scenario. + +### Incremental Delivery + +1. After MVP, implement Phase 4 (US2) to add ledger/undo guarantees for automation. +2. Follow with Phase 5 (US3) to harden validation and conflict handling. +3. Finish with Phase 6 polish tasks for documentation and operational scripts. + +### Parallel Approach + +1. One developer completes Phases 1–2. +2. Parallelize US1 implementation (engine vs. CLI vs. tests) once foundations are ready. +3. Assign US2 automation tasks to a second developer after US1 apply logic stabilizes. +4. Run US3 validation tasks concurrently with Polish updates once US2 nears completion. diff --git a/testdata/extension/README.md b/testdata/extension/README.md new file mode 100644 index 0000000..2054a81 --- /dev/null +++ b/testdata/extension/README.md @@ -0,0 +1,21 @@ +# Extension Command Test Data + +This directory contains sample files for manual and automated validation of the +`renamer extension` workflow. To avoid mutating source fixtures directly, copy +the `sample/` folder to a temporary location before running commands that +perform real filesystem changes. + +Example usage: + +```bash +TMP_DIR=$(mktemp -d) +cp -R testdata/extension/sample/* "$TMP_DIR/" +go run ./main.go extension .jpeg .JPG .jpg --path "$TMP_DIR" --dry-run +``` + +The sample set includes: + +- Mixed-case `.jpeg`/`.JPG` extensions +- Nested directories (`sample/nested`) +- An already-normalized `.jpg` file +- A hidden file to verify `--hidden` opt-in behavior diff --git a/testdata/extension/sample/.hidden.JPG b/testdata/extension/sample/.hidden.JPG new file mode 100644 index 0000000..39dd85a --- /dev/null +++ b/testdata/extension/sample/.hidden.JPG @@ -0,0 +1 @@ +hidden sample diff --git a/testdata/extension/sample/image_one.jpeg b/testdata/extension/sample/image_one.jpeg new file mode 100644 index 0000000..d71619d --- /dev/null +++ b/testdata/extension/sample/image_one.jpeg @@ -0,0 +1 @@ +sample jpeg placeholder diff --git a/testdata/extension/sample/image_two.JPG b/testdata/extension/sample/image_two.JPG new file mode 100644 index 0000000..796983a --- /dev/null +++ b/testdata/extension/sample/image_two.JPG @@ -0,0 +1 @@ +second sample diff --git a/testdata/extension/sample/logo.jpg b/testdata/extension/sample/logo.jpg new file mode 100644 index 0000000..96b29d0 --- /dev/null +++ b/testdata/extension/sample/logo.jpg @@ -0,0 +1 @@ +already normalized diff --git a/testdata/extension/sample/nested/clip.jpeg b/testdata/extension/sample/nested/clip.jpeg new file mode 100644 index 0000000..81cf5d3 --- /dev/null +++ b/testdata/extension/sample/nested/clip.jpeg @@ -0,0 +1 @@ +nested sample diff --git a/tests/contract/extension_command_test.go b/tests/contract/extension_command_test.go new file mode 100644 index 0000000..480c8c3 --- /dev/null +++ b/tests/contract/extension_command_test.go @@ -0,0 +1,137 @@ +package contract + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rogeecn/renamer/internal/extension" + "github.com/rogeecn/renamer/internal/listing" +) + +func TestExtensionPreviewAndApply(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + writeTestFile(t, filepath.Join(tmp, "photo.jpeg")) + writeTestFile(t, filepath.Join(tmp, "banner.JPG")) + writeTestFile(t, filepath.Join(tmp, "logo.jpg")) + writeTestFile(t, filepath.Join(tmp, "notes.txt")) + + scope := &listing.ListingRequest{ + WorkingDir: tmp, + IncludeDirectories: false, + Recursive: false, + IncludeHidden: false, + Extensions: nil, + Format: listing.FormatTable, + } + if err := scope.Validate(); err != nil { + t.Fatalf("validate scope: %v", err) + } + + req := extension.NewRequest(scope) + req.SetExecutionMode(true, false) + + sources := []string{".jpeg", ".JPG", ".jpg"} + canonical, display, duplicates := extension.NormalizeSourceExtensions(sources) + target := extension.NormalizeTargetExtension(".jpg") + targetCanonical := extension.CanonicalExtension(target) + + filteredCanonical := make([]string, 0, len(canonical)) + filteredDisplay := make([]string, 0, len(display)) + noOps := make([]string, 0) + for i, canon := range canonical { + if canon == targetCanonical { + noOps = append(noOps, display[i]) + continue + } + filteredCanonical = append(filteredCanonical, canon) + filteredDisplay = append(filteredDisplay, display[i]) + } + + if len(filteredCanonical) == 0 { + t.Fatalf("expected canonical sources after filtering") + } + + req.SetExtensions(filteredCanonical, filteredDisplay, target) + req.SetWarnings(duplicates, noOps) + + var buf bytes.Buffer + summary, planned, err := extension.Preview(context.Background(), req, &buf) + if err != nil { + t.Fatalf("Preview error: %v", err) + } + + if summary.TotalCandidates != 3 { + t.Fatalf("expected 3 candidates, got %d", summary.TotalCandidates) + } + if summary.TotalChanged != 2 { + t.Fatalf("expected 2 changed entries, got %d", summary.TotalChanged) + } + if summary.NoChange != 1 { + t.Fatalf("expected 1 no-change entry, got %d", summary.NoChange) + } + if len(planned) != 2 { + t.Fatalf("expected 2 planned renames, got %d", len(planned)) + } + + output := buf.String() + if !strings.Contains(output, "photo.jpeg -> photo.jpg") { + t.Fatalf("expected preview to include photo rename, output: %s", output) + } + if !strings.Contains(output, "banner.JPG -> banner.jpg") { + t.Fatalf("expected preview to include banner rename, output: %s", output) + } + if !strings.Contains(output, "logo.jpg (no change)") { + t.Fatalf("expected preview to mark logo as no change, output: %s", output) + } + if !strings.Contains(output, "Summary: 3 candidates, 2 will change, 1 already target extension") { + t.Fatalf("expected summary line, output: %s", output) + } + + if len(summary.Warnings) == 0 { + t.Fatalf("expected warnings for duplicates/no-ops") + } + + req.SetExecutionMode(false, true) + entry, err := extension.Apply(context.Background(), req, planned, summary) + if err != nil { + t.Fatalf("Apply error: %v", err) + } + if len(entry.Operations) != len(planned) { + t.Fatalf("expected %d ledger operations, got %d", len(planned), len(entry.Operations)) + } + + if _, err := os.Stat(filepath.Join(tmp, "photo.jpg")); err != nil { + t.Fatalf("expected photo.jpg after apply: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "banner.jpg")); err != nil { + t.Fatalf("expected banner.jpg after apply: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "logo.jpg")); err != nil { + t.Fatalf("expected logo.jpg to remain: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "photo.jpeg")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected photo.jpeg to be renamed, err=%v", err) + } + + ledger := filepath.Join(tmp, ".renamer") + if _, err := os.Stat(ledger); err != nil { + t.Fatalf("expected ledger file to be created: %v", err) + } +} + +func writeTestFile(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { + t.Fatalf("write file %s: %v", path, err) + } +} diff --git a/tests/contract/extension_ledger_test.go b/tests/contract/extension_ledger_test.go new file mode 100644 index 0000000..ca8362d --- /dev/null +++ b/tests/contract/extension_ledger_test.go @@ -0,0 +1,167 @@ +package contract + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + renamercmd "github.com/rogeecn/renamer/cmd" + "github.com/rogeecn/renamer/internal/extension" + "github.com/rogeecn/renamer/internal/listing" +) + +func TestExtensionApplyMetadataCaptured(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + writeTestFile(t, filepath.Join(tmp, "clip.jpeg")) + writeTestFile(t, filepath.Join(tmp, "poster.JPG")) + writeTestFile(t, filepath.Join(tmp, "flyer.jpg")) + + scope := &listing.ListingRequest{ + WorkingDir: tmp, + IncludeDirectories: false, + Recursive: false, + IncludeHidden: false, + Extensions: nil, + Format: listing.FormatTable, + } + if err := scope.Validate(); err != nil { + t.Fatalf("validate scope: %v", err) + } + + req := extension.NewRequest(scope) + req.SetExecutionMode(true, false) + + sources := []string{".jpeg", ".JPG", ".jpg"} + canonical, display, duplicates := extension.NormalizeSourceExtensions(sources) + target := extension.NormalizeTargetExtension(".jpg") + targetCanonical := extension.CanonicalExtension(target) + + filteredCanonical := make([]string, 0, len(canonical)) + filteredDisplay := make([]string, 0, len(display)) + noOps := make([]string, 0) + for i, canon := range canonical { + if canon == targetCanonical { + noOps = append(noOps, display[i]) + continue + } + filteredCanonical = append(filteredCanonical, canon) + filteredDisplay = append(filteredDisplay, display[i]) + } + req.SetExtensions(filteredCanonical, filteredDisplay, target) + req.SetWarnings(duplicates, noOps) + + summary, planned, err := extension.Preview(context.Background(), req, nil) + if err != nil { + t.Fatalf("preview error: %v", err) + } + req.SetExecutionMode(false, true) + + entry, err := extension.Apply(context.Background(), req, planned, summary) + if err != nil { + t.Fatalf("apply error: %v", err) + } + + if entry.Metadata == nil { + t.Fatalf("expected metadata to be recorded") + } + + sourcesMeta, ok := entry.Metadata["sourceExtensions"].([]string) + if !ok || len(sourcesMeta) != len(filteredDisplay) { + t.Fatalf("sourceExtensions metadata mismatch: %#v", entry.Metadata["sourceExtensions"]) + } + if sourcesMeta[0] != ".jpeg" { + t.Fatalf("expected .jpeg in source metadata, got %v", sourcesMeta) + } + + targetMeta, ok := entry.Metadata["targetExtension"].(string) + if !ok || targetMeta != target { + t.Fatalf("targetExtension metadata mismatch: %v", targetMeta) + } + + if changed, ok := entry.Metadata["totalChanged"].(int); !ok || changed != summary.TotalChanged { + t.Fatalf("totalChanged metadata mismatch: %v", entry.Metadata["totalChanged"]) + } + if noChange, ok := entry.Metadata["noChange"].(int); !ok || noChange != summary.NoChange { + t.Fatalf("noChange metadata mismatch: %v", entry.Metadata["noChange"]) + } + + counts, ok := entry.Metadata["perExtensionCounts"].(map[string]int) + if !ok { + t.Fatalf("perExtensionCounts metadata missing: %#v", entry.Metadata["perExtensionCounts"]) + } + if counts[".jpeg"] == 0 || counts[".jpg"] == 0 { + t.Fatalf("expected counts for .jpeg and .jpg, got %#v", counts) + } + + scopeMeta, ok := entry.Metadata["scope"].(map[string]any) + if !ok { + t.Fatalf("scope metadata missing: %#v", entry.Metadata["scope"]) + } + if includeHidden, _ := scopeMeta["includeHidden"].(bool); includeHidden { + t.Fatalf("includeHidden should be false, got %v", includeHidden) + } + + warnings, ok := entry.Metadata["warnings"].([]string) + if !ok || len(warnings) == 0 { + t.Fatalf("warnings metadata missing: %#v", entry.Metadata["warnings"]) + } + joined := strings.Join(warnings, " ") + if !strings.Contains(joined, "duplicate source extension") { + t.Fatalf("expected duplicate warning in metadata: %v", warnings) + } + + ledger := filepath.Join(tmp, ".renamer") + if _, err := os.Stat(ledger); err != nil { + t.Fatalf("ledger not created: %v", err) + } + + if err := os.Remove(ledger); err != nil { + t.Fatalf("cleanup ledger: %v", err) + } +} + +func TestExtensionCommandExitCodes(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + + var out bytes.Buffer + cmd := renamercmd.NewRootCommand() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"extension", ".jpeg", ".jpg", "--dry-run", "--path", tmp}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected dry-run to exit successfully, err=%v output=%s", err, out.String()) + } + + if !strings.Contains(out.String(), "No candidates found.") { + t.Fatalf("expected no candidates notice, output=%s", out.String()) + } + + writeTestFile(t, filepath.Join(tmp, "clip.jpeg")) + + out.Reset() + cmd = renamercmd.NewRootCommand() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"extension", ".jpeg", ".jpg", "--yes", "--path", tmp}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected apply to exit successfully, err=%v output=%s", err, out.String()) + } + + if _, err := os.Stat(filepath.Join(tmp, "clip.jpg")); err != nil { + t.Fatalf("expected clip.jpg after apply: %v", err) + } + + if _, err := os.Stat(filepath.Join(tmp, "clip.jpeg")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected clip.jpeg to be renamed, err=%v", err) + } +} diff --git a/tests/contract/extension_validation_test.go b/tests/contract/extension_validation_test.go new file mode 100644 index 0000000..43047cc --- /dev/null +++ b/tests/contract/extension_validation_test.go @@ -0,0 +1,102 @@ +package contract + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/rogeecn/renamer/internal/extension" + "github.com/rogeecn/renamer/internal/listing" +) + +func TestParseArgsValidation(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {"tooFew", []string{".jpg"}}, + {"emptySource", []string{" ", ".jpg"}}, + {"missingDotSource", []string{"jpg", ".png"}}, + {"missingDotTarget", []string{".jpg", "png"}}, + } + + for _, tc := range cases { + _, err := extension.ParseArgs(tc.args) + if err == nil { + t.Fatalf("expected error for case %s", tc.name) + } + } + + _, err := extension.ParseArgs([]string{".jpg", ".JPG"}) + if err == nil { + t.Fatalf("expected error when all sources match target") + } + + parsed, err := extension.ParseArgs([]string{".jpeg", ".JPG", ".jpg"}) + if err != nil { + t.Fatalf("unexpected error for valid args: %v", err) + } + if len(parsed.SourcesCanonical) != 1 || parsed.SourcesCanonical[0] != ".jpeg" { + t.Fatalf("expected canonical list to contain .jpeg only, got %#v", parsed.SourcesCanonical) + } + if len(parsed.NoOps) != 1 { + t.Fatalf("expected .jpg to be treated as no-op") + } +} + +func TestPreviewDetectsConflicts(t *testing.T) { + tmp := t.TempDir() + writeTestFile(t, filepath.Join(tmp, "report.jpeg")) + writeTestFile(t, filepath.Join(tmp, "report.jpg")) + + scope := &listing.ListingRequest{ + WorkingDir: tmp, + IncludeDirectories: false, + Recursive: false, + IncludeHidden: false, + Format: listing.FormatTable, + } + if err := scope.Validate(); err != nil { + t.Fatalf("validate scope: %v", err) + } + + req := extension.NewRequest(scope) + parsed, err := extension.ParseArgs([]string{".jpeg", ".jpg"}) + if err != nil { + t.Fatalf("parse error: %v", err) + } + req.SetExtensions(parsed.SourcesCanonical, parsed.SourcesDisplay, parsed.Target) + req.SetWarnings(parsed.Duplicates, parsed.NoOps) + + summary, planned, err := extension.Preview(context.Background(), req, nil) + if err != nil { + t.Fatalf("preview error: %v", err) + } + if !summary.HasConflicts() { + t.Fatalf("expected conflict when target already exists") + } + if len(planned) != 0 { + t.Fatalf("expected no operations due to conflict, got %d", len(planned)) + } + if len(summary.Warnings) == 0 { + t.Fatalf("expected warning recorded for conflict") + } + + // Apply should be skipped by caller; invoking directly without operations should no-op. + req.SetExecutionMode(false, true) + entry, err := extension.Apply(context.Background(), req, planned, summary) + if err != nil { + t.Fatalf("apply error: %v", err) + } + if len(entry.Operations) != 0 { + t.Fatalf("expected zero operations recorded when conflicts present") + } + + if _, err := extension.ParseArgs([]string{".jpeg", ".jpg"}); err != nil { + // ensure previous parse errors do not leak state + if !errors.Is(err, nil) { + // unreachable, but keeps staticcheck happy + } + } +} diff --git a/tests/integration/extension_flow_test.go b/tests/integration/extension_flow_test.go new file mode 100644 index 0000000..56217c2 --- /dev/null +++ b/tests/integration/extension_flow_test.go @@ -0,0 +1,78 @@ +package integration + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + renamercmd "github.com/rogeecn/renamer/cmd" +) + +func TestExtensionCommandFlow(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + createFile(t, filepath.Join(tmp, "image.jpeg")) + createFile(t, filepath.Join(tmp, "poster.JPG")) + createFile(t, filepath.Join(tmp, "logo.jpg")) + + var previewOut bytes.Buffer + preview := renamercmd.NewRootCommand() + preview.SetOut(&previewOut) + preview.SetErr(&previewOut) + preview.SetArgs([]string{"extension", ".jpeg", ".JPG", ".jpg", "--dry-run", "--path", tmp}) + + if err := preview.Execute(); err != nil { + t.Fatalf("preview command failed: %v\noutput: %s", err, previewOut.String()) + } + + output := previewOut.String() + if !strings.Contains(output, "image.jpeg -> image.jpg") { + t.Fatalf("expected preview output to include image rename, got:\n%s", output) + } + if !strings.Contains(output, "poster.JPG -> poster.jpg") { + t.Fatalf("expected preview output to include poster rename, got:\n%s", output) + } + if !strings.Contains(output, "logo.jpg (no change)") { + t.Fatalf("expected preview output to include no-change row for logo, got:\n%s", output) + } + + var applyOut bytes.Buffer + apply := renamercmd.NewRootCommand() + apply.SetOut(&applyOut) + apply.SetErr(&applyOut) + apply.SetArgs([]string{"extension", ".jpeg", ".JPG", ".jpg", "--yes", "--path", tmp}) + + if err := apply.Execute(); err != nil { + t.Fatalf("apply command failed: %v\noutput: %s", err, applyOut.String()) + } + + if _, err := os.Stat(filepath.Join(tmp, "image.jpg")); err != nil { + t.Fatalf("expected image.jpg after apply: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "poster.jpg")); err != nil { + t.Fatalf("expected poster.jpg after apply: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "logo.jpg")); err != nil { + t.Fatalf("expected logo.jpg to remain: %v", err) + } + + var undoOut bytes.Buffer + undo := renamercmd.NewRootCommand() + undo.SetOut(&undoOut) + undo.SetErr(&undoOut) + undo.SetArgs([]string{"undo", "--path", tmp}) + + if err := undo.Execute(); err != nil { + t.Fatalf("undo command failed: %v\noutput: %s", err, undoOut.String()) + } + + if _, err := os.Stat(filepath.Join(tmp, "image.jpeg")); err != nil { + t.Fatalf("expected image.jpeg after undo: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "poster.JPG")); err != nil { + t.Fatalf("expected poster.JPG after undo: %v", err) + } +} diff --git a/tests/integration/extension_undo_test.go b/tests/integration/extension_undo_test.go new file mode 100644 index 0000000..bc85263 --- /dev/null +++ b/tests/integration/extension_undo_test.go @@ -0,0 +1,52 @@ +package integration + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + renamercmd "github.com/rogeecn/renamer/cmd" +) + +func TestExtensionAutomationUndo(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + createFile(t, filepath.Join(tmp, "config.yaml")) + createFile(t, filepath.Join(tmp, "notes.yml")) + + var applyOut bytes.Buffer + apply := renamercmd.NewRootCommand() + apply.SetOut(&applyOut) + apply.SetErr(&applyOut) + apply.SetArgs([]string{"extension", ".yaml", ".yml", ".yml", "--yes", "--path", tmp}) + + if err := apply.Execute(); err != nil { + t.Fatalf("automation apply failed: %v\noutput: %s", err, applyOut.String()) + } + + if _, err := os.Stat(filepath.Join(tmp, "config.yml")); err != nil { + t.Fatalf("expected config.yml after apply: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "config.yaml")); !os.IsNotExist(err) { + t.Fatalf("expected config.yaml renamed, err=%v", err) + } + + var undoOut bytes.Buffer + undo := renamercmd.NewRootCommand() + undo.SetOut(&undoOut) + undo.SetErr(&undoOut) + undo.SetArgs([]string{"undo", "--path", tmp}) + + if err := undo.Execute(); err != nil { + t.Fatalf("undo failed: %v\noutput: %s", err, undoOut.String()) + } + + if _, err := os.Stat(filepath.Join(tmp, "config.yaml")); err != nil { + t.Fatalf("expected config.yaml after undo: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "config.yml")); !os.IsNotExist(err) { + t.Fatalf("expected config.yml removed after undo, err=%v", err) + } +} diff --git a/tests/integration/extension_validation_test.go b/tests/integration/extension_validation_test.go new file mode 100644 index 0000000..539efbc --- /dev/null +++ b/tests/integration/extension_validation_test.go @@ -0,0 +1,42 @@ +package integration + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + renamercmd "github.com/rogeecn/renamer/cmd" +) + +func TestExtensionCommandBlocksConflicts(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + createFile(t, filepath.Join(tmp, "doc.jpeg")) + createFile(t, filepath.Join(tmp, "doc.jpg")) + + var out bytes.Buffer + cmd := renamercmd.NewRootCommand() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"extension", ".jpeg", ".jpg", ".jpg", "--yes", "--path", tmp}) + + err := cmd.Execute() + if err == nil { + t.Fatalf("expected conflict to produce an error") + } + + if !strings.Contains(out.String(), "existing") && !strings.Contains(out.String(), "conflict") { + t.Fatalf("expected conflict messaging in output, got: %s", out.String()) + } + + // Ensure files unchanged after failed apply. + if _, err := os.Stat(filepath.Join(tmp, "doc.jpeg")); err != nil { + t.Fatalf("expected doc.jpeg to remain: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "doc.jpg")); err != nil { + t.Fatalf("expected doc.jpg to remain: %v", err) + } +}