From ceea09f7bede57cd452ab6a8e5c5a9b759e9be15 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 29 Oct 2025 17:46:54 +0800 Subject: [PATCH] feat: add replace subcommand with multi-pattern support --- AGENTS.md | 26 ++- cmd/replace.go | 119 ++++++++++++ cmd/root.go | 19 ++ cmd/undo.go | 52 ++++++ docs/CHANGELOG.md | 2 + docs/cli-flags.md | 25 ++- internal/history/history.go | 122 +++++++++++++ internal/listing/options.go | 14 +- internal/replace/README.md | 13 ++ internal/replace/apply.go | 86 +++++++++ internal/replace/engine.go | 38 ++++ internal/replace/parser.go | 52 ++++++ internal/replace/preview.go | 111 ++++++++++++ internal/replace/request.go | 61 +++++++ internal/replace/summary.go | 80 +++++++++ internal/replace/traversal.go | 73 ++++++++ scripts/smoke-test-replace.sh | 42 +++++ .../checklists/release.md | 29 +++ .../checklists/requirements.md | 34 ++++ .../contracts/replace-command.md | 70 ++++++++ specs/002-add-replace-command/data-model.md | 45 +++++ specs/002-add-replace-command/plan.md | 90 ++++++++++ specs/002-add-replace-command/quickstart.md | 63 +++++++ specs/002-add-replace-command/research.md | 32 ++++ specs/002-add-replace-command/spec.md | 115 ++++++++++++ specs/002-add-replace-command/tasks.md | 169 ++++++++++++++++++ testdata/replace/README.md | 27 +++ testdata/replace/case-sensitivity/Draft.txt | 0 testdata/replace/case-sensitivity/README.md | 3 + testdata/replace/case-sensitivity/draft.txt | 0 testdata/replace/case-sensitivity/sample.txt | 0 testdata/replace/hidden-files/.draft.tmp | 0 testdata/replace/hidden-files/README.md | 3 + testdata/replace/hidden-files/notes.txt | 0 testdata/replace/multi-pattern/README.md | 4 + testdata/replace/multi-pattern/alpha-beta.txt | 0 testdata/replace/multi-pattern/beta-gamma.log | 0 .../multi-pattern/nested/gamma-delta.md | 0 tests/contract/replace_command_test.go | 102 +++++++++++ tests/fixtures/replace-samples/README.md | 9 + tests/integration/replace_flow_test.go | 101 +++++++++++ tests/unit/replace_parser_test.go | 31 ++++ 42 files changed, 1848 insertions(+), 14 deletions(-) create mode 100644 cmd/replace.go create mode 100644 cmd/undo.go create mode 100644 internal/history/history.go create mode 100644 internal/replace/README.md create mode 100644 internal/replace/apply.go create mode 100644 internal/replace/engine.go create mode 100644 internal/replace/parser.go create mode 100644 internal/replace/preview.go create mode 100644 internal/replace/request.go create mode 100644 internal/replace/summary.go create mode 100644 internal/replace/traversal.go create mode 100755 scripts/smoke-test-replace.sh create mode 100644 specs/002-add-replace-command/checklists/release.md create mode 100644 specs/002-add-replace-command/checklists/requirements.md create mode 100644 specs/002-add-replace-command/contracts/replace-command.md create mode 100644 specs/002-add-replace-command/data-model.md create mode 100644 specs/002-add-replace-command/plan.md create mode 100644 specs/002-add-replace-command/quickstart.md create mode 100644 specs/002-add-replace-command/research.md create mode 100644 specs/002-add-replace-command/spec.md create mode 100644 specs/002-add-replace-command/tasks.md create mode 100644 testdata/replace/README.md create mode 100644 testdata/replace/case-sensitivity/Draft.txt create mode 100644 testdata/replace/case-sensitivity/README.md create mode 100644 testdata/replace/case-sensitivity/draft.txt create mode 100644 testdata/replace/case-sensitivity/sample.txt create mode 100644 testdata/replace/hidden-files/.draft.tmp create mode 100644 testdata/replace/hidden-files/README.md create mode 100644 testdata/replace/hidden-files/notes.txt create mode 100644 testdata/replace/multi-pattern/README.md create mode 100644 testdata/replace/multi-pattern/alpha-beta.txt create mode 100644 testdata/replace/multi-pattern/beta-gamma.log create mode 100644 testdata/replace/multi-pattern/nested/gamma-delta.md create mode 100644 tests/contract/replace_command_test.go create mode 100644 tests/fixtures/replace-samples/README.md create mode 100644 tests/integration/replace_flow_test.go create mode 100644 tests/unit/replace_parser_test.go diff --git a/AGENTS.md b/AGENTS.md index 77bb3e0..e3849e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,29 +3,41 @@ Auto-generated from all feature plans. Last updated: 2025-10-29 ## Active Technologies - +- Local filesystem (no persistent database) (002-add-replace-command) - Go 1.24 + `spf13/cobra`, `spf13/pflag` (001-list-command-filters) ## Project Structure ```text -src/ +cmd/ +internal/ +scripts/ tests/ ``` ## Commands - `renamer list` — preview rename scope with shared flags before executing changes. -- Persistent scope flags: `--path`, `-r/--recursive`, `-d/--include-dirs`, `--hidden`, `--extensions`. +- `renamer replace` — consolidate multiple literal patterns into a single replacement (supports `--dry-run` + `--yes`). +- `renamer undo` — revert the most recent rename/replace batch using ledger entries. +- Persistent scope flags: `--path`, `-r/--recursive`, `-d/--include-dirs`, `--hidden`, `--extensions`, `--yes`, `--dry-run`. ## Code Style -Go 1.24: Follow standard conventions +- Go 1.24: follow gofmt (already checked in CI) +- Prefer composable packages under `internal/` for reusable logic +- Keep CLI wiring thin; place business logic in services + +## Testing + +- `go test ./...` +- Contract tests: `tests/contract/replace_command_test.go` +- Integration tests: `tests/integration/replace_flow_test.go` +- Smoke: `scripts/smoke-test-replace.sh` ## Recent Changes - -- 001-list-command-filters: Added Go 1.24 + `spf13/cobra`, `spf13/pflag` -- 001-list-command-filters: Introduced `renamer list` command with shared scope flags and formatters +- 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/replace.go b/cmd/replace.go new file mode 100644 index 0000000..f9ed2c6 --- /dev/null +++ b/cmd/replace.go @@ -0,0 +1,119 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/rogeecn/renamer/internal/listing" + "github.com/rogeecn/renamer/internal/replace" +) + +// NewReplaceCommand constructs the replace CLI command; exported for testing. +func NewReplaceCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "replace [pattern2 ...] ", + Short: "Replace multiple literals in file and directory names", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + parseResult, err := replace.ParseArgs(args) + if err != nil { + return err + } + + scope, err := listing.ScopeFromCmd(cmd) + if err != nil { + return err + } + + req := &replace.ReplaceRequest{ + WorkingDir: scope.WorkingDir, + Patterns: parseResult.Patterns, + Replacement: parseResult.Replacement, + IncludeDirectories: scope.IncludeDirectories, + Recursive: scope.Recursive, + IncludeHidden: scope.IncludeHidden, + Extensions: scope.Extensions, + } + + 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") + } + + out := cmd.OutOrStdout() + summary, planned, err := replace.Preview(cmd.Context(), req, parseResult, out) + if err != nil { + return err + } + + for _, dup := range summary.SortedDuplicates() { + fmt.Fprintf(out, "Warning: pattern %q provided multiple times\n", dup) + } + + if len(summary.Conflicts) > 0 { + for _, conflict := range summary.Conflicts { + fmt.Fprintf(out, "CONFLICT: %s -> %s (%s)\n", conflict.OriginalPath, conflict.ProposedPath, conflict.Reason) + } + return errors.New("conflicts detected; aborting") + } + + if summary.ChangedCount == 0 { + fmt.Fprintln(out, "No replacements required") + return nil + } + + fmt.Fprintf(out, "Planned replacements: %d entries updated across %d candidates\n", summary.ChangedCount, summary.TotalCandidates) + for pattern, count := range summary.PatternMatches { + fmt.Fprintf(out, " %s -> %d occurrences\n", pattern, count) + } + + if dryRun || !autoApply { + fmt.Fprintln(out, "Preview complete. Re-run with --yes to apply.") + return nil + } + + entry, err := replace.Apply(context.Background(), req, planned, summary) + if err != nil { + return err + } + + if len(entry.Operations) == 0 { + fmt.Fprintln(out, "Nothing to apply; preview already up to date.") + return nil + } + + fmt.Fprintf(out, "Applied %d replacements. Ledger updated.\n", len(entry.Operations)) + return nil + }, + } + + cmd.Example = ` renamer replace draft Draft final --dry-run + renamer replace "Project X" "Project-X" ProjectX --yes --path ./docs` + + return cmd +} + +func getBool(cmd *cobra.Command, name string) (bool, error) { + if flag := cmd.Flags().Lookup(name); flag != nil { + return cmd.Flags().GetBool(name) + } + if flag := cmd.InheritedFlags().Lookup(name); flag != nil { + return cmd.InheritedFlags().GetBool(name) + } + return false, fmt.Errorf("flag %s not defined", name) +} + +func init() { + rootCmd.AddCommand(NewReplaceCommand()) +} diff --git a/cmd/root.go b/cmd/root.go index de8778c..3e5ae2d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -29,5 +29,24 @@ func Execute() { } func init() { + // Register persistent flags shared by all subcommands (`list`, `replace`, etc.). + // These scope flags remain centralized so new commands automatically inherit + // traversal behavior without duplicating flag definitions. listing.RegisterScopeFlags(rootCmd.PersistentFlags()) } + +// NewRootCommand creates a fresh root command with all subcommands and flags registered. +func NewRootCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "renamer", + Short: "Safe, scriptable batch renaming utility", + Long: rootCmd.Long, + } + + listing.RegisterScopeFlags(cmd.PersistentFlags()) + cmd.AddCommand(newListCommand()) + cmd.AddCommand(NewReplaceCommand()) + cmd.AddCommand(newUndoCommand()) + + return cmd +} diff --git a/cmd/undo.go b/cmd/undo.go new file mode 100644 index 0000000..ef84b21 --- /dev/null +++ b/cmd/undo.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/rogeecn/renamer/internal/history" +) + +func newUndoCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "undo", + Short: "Undo the most recent rename or replace batch", + RunE: func(cmd *cobra.Command, args []string) error { + workingDir, err := resolveWorkingDir(cmd) + if err != nil { + return err + } + + entry, err := history.Undo(workingDir) + if err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Undo applied: %d operations reversed\n", len(entry.Operations)) + return nil + }, + } + + return cmd +} + +func resolveWorkingDir(cmd *cobra.Command) (string, error) { + if flag := cmd.Flags().Lookup("path"); flag != nil { + if value := flag.Value.String(); value != "" { + return filepath.Abs(value) + } + } + if flag := cmd.InheritedFlags().Lookup("path"); flag != nil { + if value := flag.Value.String(); value != "" { + return filepath.Abs(value) + } + } + return os.Getwd() +} + +func init() { + rootCmd.AddCommand(newUndoCommand()) +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index de54ce2..fcdaafc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,5 +2,7 @@ ## Unreleased +- Add `renamer replace` subcommand supporting multi-pattern replacements, preview/apply/undo, and scope flags. +- Document quoting guidance, `--dry-run` / `--yes` behavior, and automation scenarios for replace command. - Add `renamer list` subcommand with shared scope flags and plain/table output formats. - Document global scope flags and hidden-file behavior. diff --git a/docs/cli-flags.md b/docs/cli-flags.md index 84f1f83..8cf41b3 100644 --- a/docs/cli-flags.md +++ b/docs/cli-flags.md @@ -1,15 +1,18 @@ # CLI Scope Flags Renamer shares a consistent set of scope flags across every command that inspects or mutates the -filesystem. Use these options at the root command level so they apply to `list`, `preview`, and -`rename` alike. +filesystem. Use these options at the root command level so they apply to all subcommands (`list`, +`replace`, future `preview`/`rename`, etc.). | Flag | Default | Description | |------|---------|-------------| +| `--path` | `.` | Working directory root for traversal. | | `-r`, `--recursive` | `false` | Traverse subdirectories depth-first. Symlinked directories are not followed. | | `-d`, `--include-dirs` | `false` | Limit results to directories only (files and symlinks are suppressed). Directory traversal still occurs even when the flag is absent. | | `-e`, `--extensions` | *(none)* | Pipe-separated list of file extensions (e.g. `.jpg|.mov`). Tokens must start with a dot, are lowercased internally, and duplicates are ignored. | | `--hidden` | `false` | Include dot-prefixed files and directories. By default they are excluded from listings and rename previews. | +| `--yes` | `false` | Apply changes without an interactive confirmation prompt (mutating commands only). | +| `--dry-run` | `false` | Force preview-only behavior even when `--yes` is supplied. | | `--format` | `table` | Command-specific output formatting option. For `list`, use `table` or `plain`. | ## Validation Rules @@ -22,8 +25,26 @@ filesystem. Use these options at the root command level so they apply to `list`, Keep this document updated whenever a new command is introduced or the global scope behavior changes. +## Replace Command Quick Reference + +```bash +renamer replace [pattern2 ...] [flags] +``` + +- The **final positional argument** is the replacement value; all preceding arguments are treated as + literal patterns (quotes required when a pattern contains spaces). +- Patterns are applied sequentially and replaced with the same value. Duplicate patterns are + deduplicated automatically and surfaced in the preview summary. +- Empty replacement strings are allowed (effectively deleting each pattern) but the preview warns + before confirmation. +- Combine with scope flags (`--path`, `-r`, `--include-dirs`, etc.) to target the desired set of + files/directories. +- Use `--dry-run` to preview in scripts, then `--yes` to apply once satisfied; combining both flags + exits with an error to prevent accidental automation mistakes. + ### Usage Examples - Preview files recursively: `renamer --recursive preview` - List JPEGs only: `renamer --extensions .jpg list` +- Replace multiple patterns: `renamer replace draft Draft final --dry-run` - Include dotfiles: `renamer --hidden --extensions .env list` diff --git a/internal/history/history.go b/internal/history/history.go new file mode 100644 index 0000000..74544ef --- /dev/null +++ b/internal/history/history.go @@ -0,0 +1,122 @@ +package history + +import ( + "bufio" + "encoding/json" + "errors" + "os" + "path/filepath" + "time" +) + +const ledgerFileName = ".renamer" + +// Operation records a single rename from source to target. +type Operation struct { + From string `json:"from"` + To string `json:"to"` +} + +// Entry represents a batch of operations appended to the ledger. +type Entry struct { + Timestamp time.Time `json:"timestamp"` + Command string `json:"command"` + WorkingDir string `json:"workingDir"` + Operations []Operation `json:"operations"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// Append writes a new entry to the ledger in newline-delimited JSON format. +func Append(workingDir string, entry Entry) error { + entry.Timestamp = time.Now().UTC() + entry.WorkingDir = workingDir + + path := ledgerPath(workingDir) + + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + defer file.Close() + + enc := json.NewEncoder(file) + return enc.Encode(entry) +} + +// Undo reverts the most recent ledger entry and removes it from the ledger file. +func Undo(workingDir string) (Entry, error) { + path := ledgerPath(workingDir) + + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return Entry{}, errors.New("no ledger entries available") + } else if err != nil { + return Entry{}, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + entries := make([]Entry, 0) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var e Entry + if err := json.Unmarshal(append([]byte(nil), line...), &e); err != nil { + return Entry{}, err + } + entries = append(entries, e) + } + if err := scanner.Err(); err != nil { + return Entry{}, err + } + if len(entries) == 0 { + return Entry{}, errors.New("no ledger entries available") + } + + last := entries[len(entries)-1] + + // Revert operations in reverse order. + for i := len(last.Operations) - 1; i >= 0; i-- { + op := last.Operations[i] + source := filepath.Join(workingDir, op.To) + destination := filepath.Join(workingDir, op.From) + if err := os.Rename(source, destination); err != nil { + return Entry{}, err + } + } + + // Rewrite ledger without the last entry. + if len(entries) == 1 { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return Entry{}, err + } + } else { + tmp := path + ".tmp" + output, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return Entry{}, err + } + enc := json.NewEncoder(output) + for _, e := range entries[:len(entries)-1] { + if err := enc.Encode(e); err != nil { + output.Close() + return Entry{}, err + } + } + if err := output.Close(); err != nil { + return Entry{}, err + } + if err := os.Rename(tmp, path); err != nil { + return Entry{}, err + } + } + + return last, nil +} + +// ledgerPath returns the absolute path to the ledger file under workingDir. +func ledgerPath(workingDir string) string { + return filepath.Join(workingDir, ledgerFileName) +} diff --git a/internal/listing/options.go b/internal/listing/options.go index 7e02035..cbc9bf7 100644 --- a/internal/listing/options.go +++ b/internal/listing/options.go @@ -15,15 +15,19 @@ const ( flagIncludeDirs = "include-dirs" flagHidden = "hidden" flagExtensions = "extensions" + flagYes = "yes" + flagDryRun = "dry-run" ) // RegisterScopeFlags defines persistent flags that scope listing, preview, and rename operations. func RegisterScopeFlags(flags *pflag.FlagSet) { - flags.String(flagPath, "", "Directory to inspect (defaults to current working directory)") - flags.BoolP(flagRecursive, "r", false, "Traverse subdirectories") - flags.BoolP(flagIncludeDirs, "d", false, "Include directories in results") - flags.Bool(flagHidden, false, "Include hidden files and directories") - flags.StringP(flagExtensions, "e", "", "Pipe-delimited list of extensions to include (e.g. .jpg|.png)") + flags.String(flagPath, "", "Directory to inspect (defaults to current working directory)") + flags.BoolP(flagRecursive, "r", false, "Traverse subdirectories") + flags.BoolP(flagIncludeDirs, "d", false, "Include directories in results") + flags.Bool(flagHidden, false, "Include hidden files and directories") + flags.StringP(flagExtensions, "e", "", "Pipe-delimited list of extensions to include (e.g. .jpg|.png)") + flags.Bool(flagYes, false, "Apply changes without interactive confirmation (mutating commands)") + flags.Bool(flagDryRun, false, "Force preview-only output without applying changes") } // ScopeFromCmd builds a ListingRequest populated from scope flags on the provided command. diff --git a/internal/replace/README.md b/internal/replace/README.md new file mode 100644 index 0000000..7784503 --- /dev/null +++ b/internal/replace/README.md @@ -0,0 +1,13 @@ +# internal/replace + +This package hosts the core building blocks for the `renamer replace` command. The modules are +organized as follows: + +- `request.go` — CLI input parsing, validation, and normalization of pattern/replacement data. +- `parser.go` — Helpers for token handling (quoting, deduplication, reporting). +- `traversal.go` — Bridges shared traversal utilities with replace-specific filtering logic. +- `engine.go` — Applies pattern replacements to candidate names and detects conflicts. +- `preview.go` / `apply.go` — Orchestrate preview output and apply/ledger integration (added later). +- `summary.go` — Aggregates match counts and conflict details for previews and ledger entries. + +Tests will live alongside the package (unit) and in `tests/contract` + `tests/integration`. diff --git a/internal/replace/apply.go b/internal/replace/apply.go new file mode 100644 index 0000000..68f30ff --- /dev/null +++ b/internal/replace/apply.go @@ -0,0 +1,86 @@ +package replace + +import ( + "context" + "errors" + "os" + "path/filepath" + "sort" + + "github.com/rogeecn/renamer/internal/history" +) + +// Apply executes the planned operations and records them in the ledger. +func Apply(ctx context.Context, req *ReplaceRequest, planned []PlannedOperation, summary Summary) (history.Entry, error) { + entry := history.Entry{Command: "replace"} + + if len(planned) == 0 { + return entry, nil + } + + sort.SliceStable(planned, func(i, j int) bool { + return planned[i].Result.Candidate.Depth > planned[j].Result.Candidate.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, op.To) + destination := filepath.Join(req.WorkingDir, 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 + } + + from := op.Result.Candidate.OriginalPath + to := op.TargetAbsolute + + if from == to { + continue + } + + if err := os.Rename(from, to); err != nil { + _ = revert() + return history.Entry{}, err + } + + done = append(done, history.Operation{ + From: filepath.ToSlash(op.Result.Candidate.RelativePath), + To: filepath.ToSlash(op.TargetRelative), + }) + } + + if len(done) == 0 { + return entry, nil + } + + entry.Operations = done + + metadataPatterns := make(map[string]int, len(summary.PatternMatches)) + for pattern, count := range summary.PatternMatches { + metadataPatterns[pattern] = count + } + entry.Metadata = map[string]any{ + "patterns": metadataPatterns, + "changed": summary.ChangedCount, + "totalCandidates": summary.TotalCandidates, + } + + if err := history.Append(req.WorkingDir, entry); err != nil { + // Attempt to undo renames if ledger append fails. + _ = revert() + return history.Entry{}, err + } + + return entry, nil +} diff --git a/internal/replace/engine.go b/internal/replace/engine.go new file mode 100644 index 0000000..234bae8 --- /dev/null +++ b/internal/replace/engine.go @@ -0,0 +1,38 @@ +package replace + +import "strings" + +// Result captures the outcome of applying patterns to a candidate name. +type Result struct { + Candidate Candidate + ProposedName string + Matches map[string]int + Changed bool +} + +// ApplyPatterns replaces every occurrence of the provided patterns within the candidate's base name. +func ApplyPatterns(candidate Candidate, patterns []string, replacement string) Result { + current := candidate.BaseName + matches := make(map[string]int, len(patterns)) + + for _, pattern := range patterns { + if pattern == "" { + continue + } + count := strings.Count(current, pattern) + if count == 0 { + continue + } + current = strings.ReplaceAll(current, pattern, replacement) + matches[pattern] += count + } + + changed := current != candidate.BaseName + + return Result{ + Candidate: candidate, + ProposedName: current, + Matches: matches, + Changed: changed, + } +} diff --git a/internal/replace/parser.go b/internal/replace/parser.go new file mode 100644 index 0000000..ac2b25b --- /dev/null +++ b/internal/replace/parser.go @@ -0,0 +1,52 @@ +package replace + +import ( + "errors" + "strings" +) + +// ParseArgs splits CLI arguments into patterns and replacement while deduplicating patterns. +type ParseArgsResult struct { + Patterns []string + Replacement string + Duplicates []string +} + +// ParseArgs interprets positional arguments for the replace command. +// The final token is treated as the replacement; all preceding tokens are literal patterns. +func ParseArgs(args []string) (ParseArgsResult, error) { + if len(args) < 2 { + return ParseArgsResult{}, errors.New("provide at least one pattern and a replacement value") + } + + replacement := args[len(args)-1] + patternTokens := args[:len(args)-1] + + seen := make(map[string]struct{}, len(patternTokens)) + patterns := make([]string, 0, len(patternTokens)) + duplicates := make([]string, 0) + + for _, token := range patternTokens { + trimmed := strings.TrimSpace(token) + if trimmed == "" { + continue + } + key := trimmed + if _, ok := seen[key]; ok { + duplicates = append(duplicates, trimmed) + continue + } + seen[key] = struct{}{} + patterns = append(patterns, trimmed) + } + + if len(patterns) == 0 { + return ParseArgsResult{}, errors.New("at least one non-empty pattern is required before the replacement") + } + + return ParseArgsResult{ + Patterns: patterns, + Replacement: replacement, + Duplicates: duplicates, + }, nil +} diff --git a/internal/replace/preview.go b/internal/replace/preview.go new file mode 100644 index 0000000..92aa63d --- /dev/null +++ b/internal/replace/preview.go @@ -0,0 +1,111 @@ +package replace + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" +) + +// PlannedOperation represents a rename that will be executed during apply. +type PlannedOperation struct { + Result Result + TargetRelative string + TargetAbsolute string +} + +// Preview computes replacements and writes a human-readable summary to out. +func Preview(ctx context.Context, req *ReplaceRequest, parseResult ParseArgsResult, out io.Writer) (Summary, []PlannedOperation, error) { + summary := NewSummary() + for _, dup := range parseResult.Duplicates { + summary.AddDuplicate(dup) + } + + planned := make([]PlannedOperation, 0) + plannedTargets := make(map[string]string) // target rel -> source rel to detect duplicates + + err := TraverseCandidates(ctx, req, func(candidate Candidate) error { + res := ApplyPatterns(candidate, parseResult.Patterns, parseResult.Replacement) + summary.RecordCandidate(res) + + if !res.Changed { + return nil + } + + dir := filepath.Dir(candidate.RelativePath) + if dir == "." { + dir = "" + } + + targetRelative := res.ProposedName + if dir != "" { + targetRelative = filepath.ToSlash(filepath.Join(dir, res.ProposedName)) + } else { + targetRelative = filepath.ToSlash(res.ProposedName) + } + + if targetRelative == candidate.RelativePath { + return nil + } + + if existing, ok := plannedTargets[targetRelative]; ok && existing != candidate.RelativePath { + summary.AddConflict(ConflictDetail{ + OriginalPath: candidate.RelativePath, + ProposedPath: targetRelative, + Reason: "duplicate target generated in preview", + }) + return nil + } + + targetAbsolute := filepath.Join(req.WorkingDir, filepath.FromSlash(targetRelative)) + if info, err := os.Stat(targetAbsolute); err == nil { + if candidate.OriginalPath != targetAbsolute { + // Case-only renames are allowed on case-insensitive filesystems. Compare file identity. + if origInfo, origErr := os.Stat(candidate.OriginalPath); origErr == nil { + if os.SameFile(info, origInfo) { + // Same file—case-only update permitted. + goto recordOperation + } + } + + reason := "target already exists" + if info.IsDir() { + reason = "target directory already exists" + } + summary.AddConflict(ConflictDetail{ + OriginalPath: candidate.RelativePath, + ProposedPath: targetRelative, + Reason: reason, + }) + return nil + } + } + + plannedTargets[targetRelative] = candidate.RelativePath + + if out != nil { + fmt.Fprintf(out, "%s -> %s\n", candidate.RelativePath, targetRelative) + } + + recordOperation: + planned = append(planned, PlannedOperation{ + Result: res, + TargetRelative: targetRelative, + TargetAbsolute: targetAbsolute, + }) + + return nil + }) + if err != nil { + return Summary{}, nil, err + } + + if summary.ReplacementWasEmpty(parseResult.Replacement) { + if out != nil { + fmt.Fprintln(out, "Warning: replacement string is empty; matched patterns will be removed.") + } + } + + return summary, planned, nil +} diff --git a/internal/replace/request.go b/internal/replace/request.go new file mode 100644 index 0000000..da991cc --- /dev/null +++ b/internal/replace/request.go @@ -0,0 +1,61 @@ +package replace + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// ReplaceRequest captures all inputs needed to evaluate a replace operation. +type ReplaceRequest struct { + WorkingDir string + Patterns []string + Replacement string + IncludeDirectories bool + Recursive bool + IncludeHidden bool + Extensions []string +} + +// Validate ensures the request is well-formed before preview/apply. +func (r *ReplaceRequest) Validate() error { + if r == nil { + return errors.New("replace request cannot be nil") + } + + if len(r.Patterns) == 0 { + return errors.New("at least one pattern is required") + } + + if r.Replacement == "" { + // Allow empty replacement but make sure caller has surfaced warnings elsewhere. + // No error returned; preview will message accordingly. + } + + 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) + } + + return nil +} diff --git a/internal/replace/summary.go b/internal/replace/summary.go new file mode 100644 index 0000000..3da2e99 --- /dev/null +++ b/internal/replace/summary.go @@ -0,0 +1,80 @@ +package replace + +import "sort" + +// ConflictDetail describes a rename that could not be applied. +type ConflictDetail struct { + OriginalPath string + ProposedPath string + Reason string +} + +// Summary aggregates metrics for previews, applies, and ledger entries. +type Summary struct { + TotalCandidates int + ChangedCount int + PatternMatches map[string]int + Conflicts []ConflictDetail + Duplicates []string + EmptyReplacement bool +} + +// NewSummary constructs an initialized summary. +func NewSummary() Summary { + return Summary{ + PatternMatches: make(map[string]int), + } +} + +// AddDuplicate records a duplicate pattern supplied by the user. +func (s *Summary) AddDuplicate(pattern string) { + if pattern == "" { + return + } + s.Duplicates = append(s.Duplicates, pattern) +} + +// AddResult incorporates an individual candidate replacement result. +// RecordCandidate updates aggregate counts for a processed candidate and any matches. +func (s *Summary) RecordCandidate(res Result) { + s.TotalCandidates++ + if !res.Changed { + return + } + s.ChangedCount++ + for pattern, count := range res.Matches { + s.PatternMatches[pattern] += count + } +} + +// AddConflict appends a conflict detail to the summary. +func (s *Summary) AddConflict(conflict ConflictDetail) { + s.Conflicts = append(s.Conflicts, conflict) +} + +// SortedDuplicates returns de-duplicated duplicates list for reporting. +func (s *Summary) SortedDuplicates() []string { + if len(s.Duplicates) == 0 { + return nil + } + copyList := make([]string, 0, len(s.Duplicates)) + seen := make(map[string]struct{}, len(s.Duplicates)) + for _, dup := range s.Duplicates { + if _, ok := seen[dup]; ok { + continue + } + seen[dup] = struct{}{} + copyList = append(copyList, dup) + } + sort.Strings(copyList) + return copyList +} + +// ReplacementWasEmpty records whether the replacement string is empty and returns true. +func (s *Summary) ReplacementWasEmpty(replacement string) bool { + if replacement == "" { + s.EmptyReplacement = true + return true + } + return false +} diff --git a/internal/replace/traversal.go b/internal/replace/traversal.go new file mode 100644 index 0000000..645a147 --- /dev/null +++ b/internal/replace/traversal.go @@ -0,0 +1,73 @@ +package replace + +import ( + "context" + "io/fs" + "path/filepath" + "strings" + + "github.com/rogeecn/renamer/internal/traversal" +) + +// Candidate represents a file or directory that may be renamed. +type Candidate struct { + RelativePath string + OriginalPath string + BaseName string + IsDir bool + Depth int +} + +// TraverseCandidates walks the working directory according to the request scope and invokes fn for +// every eligible candidate (files by default, directories when IncludeDirectories is true). +func TraverseCandidates(ctx context.Context, req *ReplaceRequest, fn func(Candidate) error) error { + if err := req.Validate(); err != nil { + return err + } + + extensions := make(map[string]struct{}, len(req.Extensions)) + for _, ext := range req.Extensions { + lower := strings.ToLower(ext) + extensions[lower] = struct{}{} + } + + walker := traversal.NewWalker() + + return walker.Walk( + req.WorkingDir, + req.Recursive, + req.IncludeDirectories, + req.IncludeHidden, + 0, + func(relPath string, entry fs.DirEntry, depth int) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + isDir := entry.IsDir() + ext := strings.ToLower(filepath.Ext(entry.Name())) + + if !isDir && len(extensions) > 0 { + if _, ok := extensions[ext]; !ok { + return nil + } + } + + candidate := Candidate{ + RelativePath: filepath.ToSlash(relPath), + OriginalPath: filepath.Join(req.WorkingDir, relPath), + BaseName: entry.Name(), + IsDir: isDir, + Depth: depth, + } + + if candidate.RelativePath == "." { + return nil + } + + return fn(candidate) + }, + ) +} diff --git a/scripts/smoke-test-replace.sh b/scripts/smoke-test-replace.sh new file mode 100755 index 0000000..d70e09e --- /dev/null +++ b/scripts/smoke-test-replace.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 + +mkdir -p "$TMP_DIR/nested" +touch "$TMP_DIR/foo_draft.txt" +touch "$TMP_DIR/nested/bar_draft.txt" + +echo "Previewing replacements..." +$BIN "$ROOT_DIR/main.go" replace draft Draft final --path "$TMP_DIR" --recursive --dry-run >/dev/null + +echo "Applying replacements..." +$BIN "$ROOT_DIR/main.go" replace draft Draft final --path "$TMP_DIR" --recursive --yes >/dev/null + +if [[ ! -f "$TMP_DIR/foo_final.txt" ]]; then + echo "expected foo_final.txt to exist" >&2 + exit 1 +fi + +if [[ ! -f "$TMP_DIR/nested/bar_final.txt" ]]; then + echo "expected bar_final.txt to exist" >&2 + exit 1 +fi + +echo "Undoing replacements..." +$BIN "$ROOT_DIR/main.go" undo --path "$TMP_DIR" >/dev/null + +if [[ ! -f "$TMP_DIR/foo_draft.txt" ]]; then + echo "undo failed to restore foo_draft.txt" >&2 + exit 1 +fi + +if [[ ! -f "$TMP_DIR/nested/bar_draft.txt" ]]; then + echo "undo failed to restore nested/bar_draft.txt" >&2 + exit 1 +fi + +echo "Smoke test succeeded." diff --git a/specs/002-add-replace-command/checklists/release.md b/specs/002-add-replace-command/checklists/release.md new file mode 100644 index 0000000..f47bf67 --- /dev/null +++ b/specs/002-add-replace-command/checklists/release.md @@ -0,0 +1,29 @@ +# Release Readiness Checklist: Replace Command with Multi-Pattern Support + +**Purpose**: Verify readiness for release / polish phase +**Created**: 2025-10-29 +**Feature**: [spec.md](../spec.md) + +## Documentation + +- [x] Quickstart reflects latest syntax and automation workflow +- [x] CLI reference (`docs/cli-flags.md`) includes replace command usage and warnings +- [x] AGENTS.md updated with replace command summary +- [x] CHANGELOG entry drafted for replace command + +## Quality Gates + +- [x] `go test ./...` passing locally +- [x] Smoke test script for replace + undo exists and runs +- [x] Ledger metadata includes pattern counts and is asserted in tests +- [x] Empty replacement path warns users in preview + +## Operational Readiness + +- [x] `--dry-run` and `--yes` are mutually exclusive and error when combined +- [x] Undo command reverses replace operations via ledger entry +- [x] Scope flags behave identically across list/replace commands + +## Notes + +- Resolve outstanding checklist items prior to tagging release. diff --git a/specs/002-add-replace-command/checklists/requirements.md b/specs/002-add-replace-command/checklists/requirements.md new file mode 100644 index 0000000..a90afd0 --- /dev/null +++ b/specs/002-add-replace-command/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Replace Command with Multi-Pattern Support + +**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 + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` diff --git a/specs/002-add-replace-command/contracts/replace-command.md b/specs/002-add-replace-command/contracts/replace-command.md new file mode 100644 index 0000000..5ab7c53 --- /dev/null +++ b/specs/002-add-replace-command/contracts/replace-command.md @@ -0,0 +1,70 @@ +# CLI Contract: `renamer replace` + +## Command Synopsis + +```bash +renamer replace [pattern2 ...] [flags] +``` + +### Global Flags (inherited from root command) +- `--path ` (defaults to current working directory) +- `-r`, `--recursive` +- `-d`, `--include-dirs` +- `--hidden` +- `-e`, `--extensions <.ext|.ext2>` +- `--yes` — apply without interactive confirmation (used by all mutating commands) +- `--dry-run` — force preview-only run even if `--yes` is supplied + +## Description +Batch-replace literal substrings across filenames and directories using shared traversal and preview +infrastructure. All arguments except the final token are treated as patterns; the last argument is +the replacement value. + +## Arguments & Flags + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | Yes (≥2) | Literal substrings to be replaced. Quotes required when containing spaces. | +| `` | Yes | Final positional argument; literal replacement applied to each pattern. | + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--path` | string | `.` | Working directory for traversal (global flag). | +| `-r`, `--recursive` | bool | `false` | Traverse subdirectories depth-first (global flag). | +| `-d`, `--include-dirs` | bool | `false` | Include directory names in replacement scope (global flag). | +| `--hidden` | bool | `false` | Include hidden files/directories (global flag). | +| `-e`, `--extensions` | string | (none) | Restrict replacements to files matching `|`-delimited extensions (global flag). | +| `--yes` | bool | `false` | Skip confirmation prompt and apply immediately after successful preview (global flag). | +| `--dry-run` | bool | `false` | Force preview-only run even if `--yes` is provided (global flag). | + +## Exit Codes + +| Code | Meaning | Example Trigger | +|------|---------|-----------------| +| `0` | Success | Preview or apply completed without conflicts. | +| `2` | Validation error | Fewer than two arguments supplied or unreadable directory. | +| `3` | Conflict detected | Target filename already exists; user must resolve before retry. | + +## Preview Output +- Lists each impacted file with columns: `PATH`, `MATCHED PATTERN`, `NEW PATH`. +- Summary line: `Total: (patterns: pattern1=#, pattern2=#, conflicts=#)`. + +## Apply Behavior +- Re-validates preview results before writing changes. +- Writes ledger entry capturing old/new names, patterns, replacement string, timestamp. +- On conflict, aborts without partial renames. + +## Validation Rules +- Minimum two unique patterns required (at least one pattern plus replacement). +- Patterns and replacement treated as UTF-8 literals; no regex expansion. +- Duplicate patterns deduplicated with warning in preview summary. +- Replacement applied to every occurrence within file/dir name. +- Empty replacement allowed but requires confirmation message: "Replacement string is empty; affected substrings will be removed." + +## Examples + +```bash +renamer replace draft Draft DRAFT final +renamer replace "Project X" "Project-X" ProjectX --extensions .txt|.md +renamer replace tmp temp temp-backup stable --hidden --recursive --yes +``` diff --git a/specs/002-add-replace-command/data-model.md b/specs/002-add-replace-command/data-model.md new file mode 100644 index 0000000..f8e0f6f --- /dev/null +++ b/specs/002-add-replace-command/data-model.md @@ -0,0 +1,45 @@ +# Data Model: Replace Command with Multi-Pattern Support + +## Entities + +### ReplaceRequest +- **Description**: Captures all inputs driving a replace operation. +- **Fields**: + - `workingDir` (string, required): Absolute path where traversal begins. + - `patterns` ([]string, min length 2): Ordered list of literal substrings to replace. + - `replacement` (string, required): Literal value substituting each pattern. + - `includeDirectories` (bool): Whether directories participate in replacement. + - `recursive` (bool): Traverse subdirectories depth-first. + - `includeHidden` (bool): Include hidden files during traversal. + - `extensions` ([]string): Optional extension filters inherited from scope flags. + - `dryRun` (bool): Preview mode flag; true during preview, false when applying changes. +- **Validations**: + - `patterns` MUST be deduplicated case-sensitively before execution. + - `replacement` MAY be empty, but command must warn the user during preview. + - `workingDir` MUST exist and be readable before traversal. + +### ReplaceSummary +- **Description**: Aggregates preview/apply outcomes for reporting and ledger entries. +- **Fields**: + - `totalFiles` (int): Count of files/directories affected. + - `patternMatches` (map[string]int): Total substitutions per pattern. + - `conflicts` ([]ConflictDetail): Detected filename collisions with rationale. + - `ledgerEntryID` (string, optional): Identifier once committed to `.renamer` ledger. + +### ConflictDetail +- **Description**: Describes a file that could not be renamed due to collision or validation failure. +- **Fields**: + - `originalPath` (string) + - `proposedPath` (string) + - `reason` (string): Human-readable description (e.g., "target already exists"). + +## Relationships +- `ReplaceRequest` produces a stream of candidate rename operations via traversal utilities. +- `ReplaceSummary` aggregates results from executing the request and is persisted inside ledger entries. +- `ConflictDetail` records subset of `ReplaceSummary` when collisions block application. + +## State Transitions +1. **Parsing**: CLI args parsed into `ReplaceRequest`; validations run immediately. +2. **Preview**: Traversal + replacement simulation produce `ReplaceSummary` with proposed paths. +3. **Confirm**: Upon user confirmation (or `--yes`), operations apply atomically; ledger entry written. +4. **Undo**: Ledger reverse operation reads `ReplaceSummary` data to restore originals. diff --git a/specs/002-add-replace-command/plan.md b/specs/002-add-replace-command/plan.md new file mode 100644 index 0000000..c9e8033 --- /dev/null +++ b/specs/002-add-replace-command/plan.md @@ -0,0 +1,90 @@ +# Implementation Plan: Replace Command with Multi-Pattern Support + +**Branch**: `002-add-replace-command` | **Date**: 2025-10-29 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/002-add-replace-command/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. + +## Summary + +Introduce a `renamer replace` subcommand that accepts multiple literal patterns and replaces them +with a single target string while honoring existing preview → confirm → ledger → undo guarantees. +The feature will extend shared scope flags, reuse traversal/filtering pipelines, and add +automation-friendly validation and help documentation. + +## Technical Context + +**Language/Version**: Go 1.24 +**Primary Dependencies**: `spf13/cobra`, `spf13/pflag` +**Storage**: Local filesystem (no persistent database) +**Testing**: Go `testing` package with CLI integration/contract tests +**Target Platform**: Cross-platform CLI (Linux, macOS, Windows) +**Project Type**: Single CLI project +**Performance Goals**: Complete preview + apply for 100 files within 2 minutes +**Constraints**: Deterministic previews, reversible ledger entries, conflict detection before apply +**Scale/Scope**: Handles hundreds of files per invocation; patterns limited to user-provided literals + +## 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). +- Undo strategy MUST describe how the `.renamer` ledger entry is written and reversed (Persistent Undo Ledger). +- Planned rename rules MUST document their inputs, validations, and composing order (Composable Rule Engine). +- Scope handling MUST cover files vs directories (`-d`), recursion (`-r`), and extension filtering via `-e` without escaping the requested path (Scope-Aware Traversal). +- CLI UX plan MUST confirm Cobra usage, flag naming, help text, and automated tests for preview/undo flows (Ergonomic CLI Stewardship). + +**Gate Alignment**: +- Replace command will reuse preview + confirmation pipeline; no direct rename without preview. +- Ledger entries will include replacement mappings to maintain undo guarantees. +- Replacement logic will be implemented as composable rule(s) integrating with existing rename engine. +- Command will rely on shared scope flags (`--path`, `-r`, `-d`, `--hidden`, `-e`) to avoid divergence. +- Cobra command structure and automated tests will cover help/validation/undo parity. + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-add-replace-command/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +└── tasks.md +``` + +### Source Code (repository root) + +```text +cmd/ +├── root.go +└── list.go (existing CLI commands; replace command will be added here or alongside) + +internal/ +├── listing/ +├── output/ +├── traversal/ +└── (new replace-specific packages under internal/replace/) + +scripts/ +└── smoke-test-list.sh + +tests/ +├── contract/ +├── integration/ +└── fixtures/ + +docs/ +└── cli-flags.md +``` + +**Structure Decision**: Single CLI repository with commands under `cmd/` and reusable logic under +`internal/`. Replace-specific logic will live in `internal/replace/` (request parsing, rule engine, +summary). CLI command wiring will reside in `cmd/replace.go`. Tests will follow existing +contract/integration directories. + +## Complexity Tracking + +No constitution gate violations identified; additional complexity justification not required. diff --git a/specs/002-add-replace-command/quickstart.md b/specs/002-add-replace-command/quickstart.md new file mode 100644 index 0000000..b2e6703 --- /dev/null +++ b/specs/002-add-replace-command/quickstart.md @@ -0,0 +1,63 @@ +# Quickstart: Replace Command with Multi-Pattern Support + +## Goal +Demonstrate how to consolidate multiple filename patterns into a single replacement while using the +preview → apply → undo workflow safely. + +## Prerequisites +- Go toolchain (>= 1.24) installed for building the CLI locally. +- Sample directory with files containing inconsistent substrings (e.g., `draft`, `Draft`, `DRAFT`). + +## Steps + +1. **Build the CLI** + ```bash + go build -o renamer ./... + ``` + +2. **Inspect replace help** + ```bash + ./renamer replace --help + ``` + Review syntax, especially the "final argument is replacement" guidance and quoting rules. + +3. **Run a preview with multiple patterns** + ```bash + ./renamer replace draft Draft DRAFT final --dry-run + ``` + Confirm the table shows each occurrence mapped to `final` and the summary lists per-pattern counts. + +4. **Apply replacements after review** + ```bash + ./renamer replace draft Draft DRAFT final --yes + ``` + Observe the confirmation summary, then verify file names have been updated. + +5. **Undo if necessary** + ```bash + ./renamer undo + ``` + Ensure the ledger entry created by step 4 is reversed and filenames restored. + +6. **Handle patterns with spaces** + ```bash + ./renamer replace "Project X" "Project-X" ProjectX --dry-run + ``` + Verify that quoting preserves whitespace and the preview reflects the intended substitution. + +7. **Combine with scope filters** + ```bash + ./renamer replace tmp temp stable --path ./examples --extensions .log|.txt --recursive + ``` + Confirm only matching files under `./examples` are listed. + +8. **Integrate into automation** + ```bash + ./renamer replace draft Draft final --dry-run && \ + ./renamer replace draft Draft final --yes + ``` + The first command previews changes; the second applies them with exit code `0` when successful. + +## Next Steps +- Add contract/integration tests covering edge cases (empty replacement, conflicts). +- Update documentation (`docs/cli-flags.md`, README) with replace command examples. diff --git a/specs/002-add-replace-command/research.md b/specs/002-add-replace-command/research.md new file mode 100644 index 0000000..680dab7 --- /dev/null +++ b/specs/002-add-replace-command/research.md @@ -0,0 +1,32 @@ +# Phase 0 Research: Replace Command with Multi-Pattern Support + +## Decision: Literal substring replacement with ordered evaluation +- **Rationale**: Aligns with current rename semantics and keeps user expectations simple for first + iteration; avoids complexity of regex/glob interactions. Ordered application ensures predictable + handling of overlapping patterns. +- **Alternatives considered**: + - *Regex support*: powerful but significantly increases validation surface and user errors. + - *Simultaneous substitution without order*: risk of ambiguous conflicts when one pattern is subset + of another. + +## Decision: Dedicated replace service under `internal/replace` +- **Rationale**: Keeps responsibilities separated from existing listing module, enabling reusable + preview + apply logic while encapsulating pattern parsing, summary, and reporting. +- **Alternatives considered**: + - *Extending existing listing package*: would blur responsibilities between read-only listing and + mutation workflows. + - *Embedding in command file*: hinders testability and violates composable rule principle. + +## Decision: Pattern delimiter syntax `with` +- **Rationale**: Matches user description and provides a clear boundary between patterns and + replacement string. Works well with Cobra argument parsing and allows quoting for whitespace. +- **Alternatives considered**: + - *Using flags for replacement string*: more verbose and inconsistent with provided example. + - *Special separators like `--`*: less descriptive and increases documentation burden. + +## Decision: Conflict detection before apply +- **Rationale**: Maintains Preview-First Safety by ensuring duplicates or invalid filesystem names are + reported before commit. Reuses existing validation helpers from rename pipeline. +- **Alternatives considered**: + - *Best-effort renames with partial success*: violates atomic undo expectations. + - *Skipping conflicting files silently*: unsafe and would erode trust. diff --git a/specs/002-add-replace-command/spec.md b/specs/002-add-replace-command/spec.md new file mode 100644 index 0000000..baf7a0b --- /dev/null +++ b/specs/002-add-replace-command/spec.md @@ -0,0 +1,115 @@ +# Feature Specification: Replace Command with Multi-Pattern Support + +**Feature Branch**: `002-add-replace-command` +**Created**: 2025-10-29 +**Status**: Draft +**Input**: User description: "添加 替换(replace)命令,用于替换指定的字符串,支持同时对多个字符串进行替换为一个 示例: renamer replace pattern1 pattern2 with space pattern3 " + +## Clarifications + +### Session 2025-10-29 + +- Q: What syntax separates patterns from the replacement value? → A: The final positional argument is the replacement; no delimiter keyword is used. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Normalize Naming with One Command (Priority: P1) + +As a CLI user cleaning messy filenames, I want to run `renamer replace` with multiple source +patterns so I can normalize all variants to a single replacement in one pass. + +**Why this priority**: Unlocks the main value proposition—batch consolidation of inconsistent +substrings without writing custom scripts. + +**Independent Test**: Execute `renamer list` to confirm scope, then `renamer replace foo bar Baz` +and verify preview shows every `foo` and `bar` occurrence replaced with `Baz` before confirming. + +**Acceptance Scenarios**: + +1. **Given** files containing `draft`, `Draft`, and `DRAFT`, **When** the user runs `renamer replace draft Draft DRAFT final`, **Then** the preview lists every filename replaced with `final`, and execution applies the same mapping. +2. **Given** filenames where patterns appear multiple times, **When** the user runs `renamer replace beta gamma release`, **Then** each occurrence within a single filename is substituted, and the summary highlights total replacements per file. + +--- + +### User Story 2 - Script-Friendly Replacement Workflows (Priority: P2) + +As an operator embedding renamer in automation, I want deterministic exit codes and reusable +profiles for replace operations so scripts can preview, apply, and undo safely. + +**Why this priority**: Ensures pipelines can rely on the command without interactive ambiguity. + +**Independent Test**: In a CI script, run `renamer replace ... --dry-run` (preview), assert exit code +0, then run with `--yes` and check ledger entry plus `renamer undo` restores originals. + +**Acceptance Scenarios**: + +1. **Given** a non-interactive environment, **When** the user passes `--yes` to apply replacements after a successful preview, **Then** the command exits 0 on success and writes a ledger entry capturing all mappings. +2. **Given** an invalid argument (e.g., only one token supplied), **When** the command executes, **Then** it exits with a non-zero code and explains that the final positional argument becomes the replacement value. + +--- + +### User Story 3 - Validate Complex Pattern Input (Priority: P3) + +As a power user handling patterns with spaces or special characters, I want clear syntax guidance +and validation feedback so I can craft replacements confidently. + +**Why this priority**: Prevents user frustration when patterns require quoting or escaping. + +**Independent Test**: Run `renamer replace "Project X" "Project-X" ProjectX` and confirm preview +resolves both variants, while invalid quoting produces actionable guidance. + +**Acceptance Scenarios**: + +1. **Given** a pattern containing whitespace, **When** it is provided in quotes, **Then** the preview reflects the intended substring and documentation shows identical syntax describing the final argument as replacement. +2. **Given** duplicate patterns in the same command, **When** the command runs, **Then** duplicates are deduplicated with a warning so replacements remain idempotent. + +--- + +### Edge Cases + +- Replacement produces duplicate target filenames; command must surface conflicts before confirmation. +- Patterns overlap within the same filename (e.g., `aaa` replaced via `aa`); order of application must be deterministic and documented. +- Case-sensitivity opt-in: default literal matching is case-sensitive, but a future `--ignore-case` option is deferred; command must warn that matches are case-sensitive. +- Replacement string empty (aka deletion); preview must show empty substitution clearly and require explicit confirmation. +- No files match any pattern; command exits 0 with "No entries matched" messaging and no ledger entry. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The CLI MUST expose `renamer replace ` and treat all but the final token as literal patterns (quotes support whitespace). +- **FR-002**: Replace operations MUST integrate with the existing preview → confirm workflow; previews list old and new names with highlighted substrings. +- **FR-003**: Executions MUST append detailed entries to `.renamer` including original names, replacements performed, patterns supplied, and timestamps so undo remains possible. +- **FR-004**: Users MUST be able to undo the most recent replace batch via existing undo mechanics without orphaned files. +- **FR-005**: Command MUST respect global scope flags (`--recursive`, `--include-dirs`, `--hidden`, `--extensions`, `--path`) identical to `list` / `preview` behavior. +- **FR-006**: Command MUST accept two or more patterns and collapse them into a single replacement string applied to every filename in scope. +- **FR-007**: Command MUST preserve idempotence by deduplicating patterns and reporting the number of substitutions per pattern in summary output. +- **FR-008**: Invalid invocations (fewer than two arguments, empty pattern list after deduplication, missing replacement) MUST fail with exit code ≠0 and actionable usage guidance. +- **FR-009**: CLI help MUST document quoting rules for patterns containing spaces or shell special characters. + +### Key Entities *(include if feature involves data)* + +- **ReplaceRequest**: Captures working directory, scope flags, ordered pattern list, replacement string, and preview/apply options. +- **ReplaceSummary**: Aggregates per-pattern match counts, per-file changes, and conflict warnings for preview and ledger output. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users complete a multi-pattern replacement across 100 files with preview + apply in under 2 minutes end-to-end. +- **SC-002**: 95% of usability test participants interpret the final positional replacement argument correctly after reading `renamer replace --help`. +- **SC-003**: Automated regression tests confirm replace + undo leave the filesystem unchanged in 100% of scripted scenarios. +- **SC-004**: Support tickets related to manual string normalization drop by 40% within the first release cycle after launch. + +## Assumptions + +- Patterns are treated as literal substrings; regex support is out of scope for this increment. +- Case-sensitive matching aligns with current rename semantics; advanced matching can be added later. +- Replacement applies to filenames (and directories when `-d`/`--include-dirs` is set), not file contents. +- Existing infrastructure for preview, ledger, and undo can be extended without architectural changes. + +## Dependencies & Risks + +- Requires updates to shared traversal/filter utilities so replace respects global scope flags. +- Help/quickstart documentation must be updated alongside the command to prevent misuse. +- Potential filename conflicts must be detected pre-apply to avoid data loss; conflict handling relies on existing validation pipeline. diff --git a/specs/002-add-replace-command/tasks.md b/specs/002-add-replace-command/tasks.md new file mode 100644 index 0000000..e88a734 --- /dev/null +++ b/specs/002-add-replace-command/tasks.md @@ -0,0 +1,169 @@ +# Tasks: Replace Command with Multi-Pattern Support + +**Input**: Design documents from `/specs/002-add-replace-command/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: Tests are optional; include them only where they support the user story’s independent validation. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Prepare project for replace command implementation + +- [X] T001 Audit root command for shared flags; document expected additions in `cmd/root.go` +- [X] T002 Create replace package scaffold in `internal/replace/README.md` with intended module layout +- [X] T003 Add sample fixtures directory for replacement tests at `tests/fixtures/replace-samples/README.md` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core utilities required by every user story + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T004 Implement `ReplaceRequest` struct and validators in `internal/replace/request.go` +- [X] T005 [P] Implement pattern parser handling quoting/deduplication in `internal/replace/parser.go` +- [X] T006 [P] Extend traversal utilities to emit replace candidates in `internal/replace/traversal.go` +- [X] T007 [P] Implement replacement engine with overlap handling in `internal/replace/engine.go` +- [X] T008 Define summary metrics and conflict structs in `internal/replace/summary.go` +- [X] T009 Document replace syntax and flags draft in `docs/cli-flags.md` + +**Checkpoint**: Foundation ready—user story implementation can begin + +--- + +## Phase 3: User Story 1 - Normalize Naming with One Command (Priority: P1) 🎯 MVP + +**Goal**: Deliver multi-pattern replacement with preview + apply guarantees + +**Independent Test**: `renamer replace foo bar Baz --dry-run` followed by `--yes`; verify preview shows replacements, apply updates files, and `renamer undo` restores originals. + +### Tests for User Story 1 (OPTIONAL - included for confidence) + +- [X] T010 [P] [US1] Contract test for preview summary counts in `tests/contract/replace_command_test.go` +- [X] T011 [P] [US1] Integration test covering multi-pattern apply + undo in `tests/integration/replace_flow_test.go` + +### Implementation for User Story 1 + +- [X] T012 [US1] Implement CLI command wiring in `cmd/replace.go` using shared scope flags +- [X] T013 [US1] Implement preview rendering and summary output in `internal/replace/preview.go` +- [X] T014 [US1] Hook replace engine into ledger/undo pipeline in `internal/replace/apply.go` +- [X] T015 [US1] Add documentation examples to `docs/cli-flags.md` and quickstart + +**Checkpoint**: User Story 1 delivers functional `renamer replace` with preview/apply/undo + +--- + +## Phase 4: User Story 2 - Script-Friendly Replacement Workflows (Priority: P2) + +**Goal**: Ensure automation-friendly behaviors (exit codes, non-interactive usage) + +**Independent Test**: Scripted run invoking `renamer replace ... --dry-run` then `--yes`; expect consistent exit codes and ledger entry + +### Implementation for User Story 2 + +- [X] T016 [US2] Implement non-interactive flag validation (`--yes` + positional args) in `cmd/replace.go` +- [X] T017 [US2] Add ledger metadata (pattern counts) for automation in `internal/replace/summary.go` +- [X] T018 [US2] Expand integration test to assert exit codes for invalid input in `tests/integration/replace_flow_test.go` +- [X] T019 [US2] Update quickstart section with automation guidance in `specs/002-add-replace-command/quickstart.md` + +**Checkpoint**: Scripts can rely on deterministic exit codes and ledger data + +--- + +## Phase 5: User Story 3 - Validate Complex Pattern Input (Priority: P3) + +**Goal**: Provide resilient handling for whitespace/special-character patterns and user guidance + +**Independent Test**: `renamer replace "Project X" "Project-X" ProjectX --dry-run` plus invalid quoting to verify errors + +### Implementation for User Story 3 + +- [X] T020 [P] [US3] Implement quoting guidance and warnings in `cmd/replace.go` +- [X] T021 [P] [US3] Add parser coverage for whitespace patterns in `tests/unit/replace_parser_test.go` +- [X] T022 [US3] Surface duplicate pattern warnings in preview summary in `internal/replace/preview.go` +- [X] T023 [US3] Document advanced pattern examples in `docs/cli-flags.md` + +**Checkpoint**: Power users receive clear guidance and validation feedback + +--- + +## Phase N: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation, documentation, and release readiness + +- [X] T024 Update agent guidance with replace command details in `AGENTS.md` +- [X] T025 Add changelog entry describing new replace command in `docs/CHANGELOG.md` +- [X] T026 Create smoke test script covering replace + undo in `scripts/smoke-test-replace.sh` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No prerequisites +- **Foundational (Phase 2)**: Depends on setup completion; blocks all user stories +- **User Story 1 (Phase 3)**: Depends on foundational phase; MVP delivery +- **User Story 2 (Phase 4)**: Depends on User Story 1 (automation builds on base command) +- **User Story 3 (Phase 5)**: Depends on User Story 1 (parser/extensions) and shares foundation +- **Polish (Phase N)**: Runs after user stories complete + +### User Story Dependencies + +- **US1**: Requires foundational tasks +- **US2**: Requires US1 implementation + ledger integration +- **US3**: Requires US1 parser and preview infrastructure + +### Within Each User Story + +- Tests (if included) should be authored before implementation tasks +- Parser/engine updates precede CLI wiring +- Documentation updates finalize after behavior stabilizes + +### Parallel Opportunities + +- Foundational parser/engine/summary tasks (T005–T008) can progress in parallel after T004 +- US1 tests (T010–T011) can run alongside command wiring (T012–T014) +- US3 parser coverage (T021) can proceed independently while warnings (T022) integrate with preview + +--- + +## Parallel Example: User Story 1 + +```bash +# Terminal 1: Write contract test and run in watch mode +go test ./tests/contract -run TestReplaceCommandPreview + +# Terminal 2: Implement preview renderer +$EDITOR internal/replace/preview.go +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1) +1. Complete Setup and Foundational phases +2. Implement US1 tasks (T010–T015) +3. Ensure preview/apply/undo works end-to-end + +### Incremental Delivery +1. Ship US1 (core replace command) +2. Layer US2 (automation-friendly exit codes and ledger metadata) +3. Add US3 (advanced pattern validation) +4. Execute polish tasks for documentation and smoke tests + +### Parallel Team Strategy +- Engineer A: Parser/engine/summary foundational work +- Engineer B: CLI command wiring + tests (US1) +- Engineer C: Automation behaviors and documentation (US2/Polish) +- After US1, shift Engineers to handle US3 enhancements and polish concurrently diff --git a/testdata/replace/README.md b/testdata/replace/README.md new file mode 100644 index 0000000..b6d5efc --- /dev/null +++ b/testdata/replace/README.md @@ -0,0 +1,27 @@ +# Replace Command Fixtures + +This directory contains lightweight fixtures for manual and automated testing of the `renamer +replace` command. + +## Structure + +``` +replace/ +├── case-sensitivity/ +│ ├── draft.txt +│ ├── Draft.txt +│ └── README.md +├── multi-pattern/ +│ ├── alpha-beta.txt +│ ├── beta-gamma.log +│ ├── nested/ +│ │ └── gamma-delta.md +│ └── README.md +└── hidden-files/ + ├── .draft.tmp + ├── notes.txt + └── README.md +``` + +Each subdirectory contains intentionally blank files used to verify preview, apply, and undo +behaviour. Customize or copy these fixtures as needed for new tests. diff --git a/testdata/replace/case-sensitivity/Draft.txt b/testdata/replace/case-sensitivity/Draft.txt new file mode 100644 index 0000000..e69de29 diff --git a/testdata/replace/case-sensitivity/README.md b/testdata/replace/case-sensitivity/README.md new file mode 100644 index 0000000..32373a0 --- /dev/null +++ b/testdata/replace/case-sensitivity/README.md @@ -0,0 +1,3 @@ +Case-insensitive filesystem fixture. + +Use these files to verify case-only replacements, e.g. `renamer replace draft Draft`. diff --git a/testdata/replace/case-sensitivity/draft.txt b/testdata/replace/case-sensitivity/draft.txt new file mode 100644 index 0000000..e69de29 diff --git a/testdata/replace/case-sensitivity/sample.txt b/testdata/replace/case-sensitivity/sample.txt new file mode 100644 index 0000000..e69de29 diff --git a/testdata/replace/hidden-files/.draft.tmp b/testdata/replace/hidden-files/.draft.tmp new file mode 100644 index 0000000..e69de29 diff --git a/testdata/replace/hidden-files/README.md b/testdata/replace/hidden-files/README.md new file mode 100644 index 0000000..53a8c55 --- /dev/null +++ b/testdata/replace/hidden-files/README.md @@ -0,0 +1,3 @@ +Hidden files fixture. + +Use to validate `--hidden` flag handling with replace command. diff --git a/testdata/replace/hidden-files/notes.txt b/testdata/replace/hidden-files/notes.txt new file mode 100644 index 0000000..e69de29 diff --git a/testdata/replace/multi-pattern/README.md b/testdata/replace/multi-pattern/README.md new file mode 100644 index 0000000..122bff5 --- /dev/null +++ b/testdata/replace/multi-pattern/README.md @@ -0,0 +1,4 @@ +Multi-pattern replacement fixture. + +Example usage: +renamer replace alpha beta gamma final --path testdata/replace/multi-pattern --recursive --dry-run diff --git a/testdata/replace/multi-pattern/alpha-beta.txt b/testdata/replace/multi-pattern/alpha-beta.txt new file mode 100644 index 0000000..e69de29 diff --git a/testdata/replace/multi-pattern/beta-gamma.log b/testdata/replace/multi-pattern/beta-gamma.log new file mode 100644 index 0000000..e69de29 diff --git a/testdata/replace/multi-pattern/nested/gamma-delta.md b/testdata/replace/multi-pattern/nested/gamma-delta.md new file mode 100644 index 0000000..e69de29 diff --git a/tests/contract/replace_command_test.go b/tests/contract/replace_command_test.go new file mode 100644 index 0000000..0f8f51b --- /dev/null +++ b/tests/contract/replace_command_test.go @@ -0,0 +1,102 @@ +package contract + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rogeecn/renamer/internal/replace" +) + +func TestPreviewSummaryCounts(t *testing.T) { + tmp := t.TempDir() + createFile(t, filepath.Join(tmp, "draft.txt")) + createFile(t, filepath.Join(tmp, "Draft.md")) + createFile(t, filepath.Join(tmp, "notes", "DRAFT.log")) + + args := []string{"draft", "Draft", "DRAFT", "final"} + parsed, err := replace.ParseArgs(args) + if err != nil { + t.Fatalf("ParseArgs error: %v", err) + } + + req := &replace.ReplaceRequest{ + WorkingDir: tmp, + Patterns: parsed.Patterns, + Replacement: parsed.Replacement, + Recursive: true, + } + + var buf bytes.Buffer + summary, planned, err := replace.Preview(context.Background(), req, parsed, &buf) + if err != nil { + t.Fatalf("Preview error: %v", err) + } + + if summary.TotalCandidates == 0 { + t.Fatalf("expected candidates to be processed") + } + + if summary.ChangedCount != len(planned) { + t.Fatalf("changed count mismatch: %d vs %d", summary.ChangedCount, len(planned)) + } + + for _, pattern := range []string{"draft", "Draft", "DRAFT"} { + if summary.PatternMatches[pattern] == 0 { + t.Fatalf("expected matches recorded for %s", pattern) + } + } + + output := buf.String() + if !strings.Contains(output, "draft.txt -> final.txt") { + t.Fatalf("expected preview output to list replacements, got: %s", output) + } + + if summary.ReplacementWasEmpty(parsed.Replacement) { + t.Fatalf("replacement should not be empty warning for this test") + } +} + +func TestPreviewWarnsOnEmptyReplacement(t *testing.T) { + tmp := t.TempDir() + createFile(t, filepath.Join(tmp, "foo.txt")) + + args := []string{"foo", ""} + parsed, err := replace.ParseArgs(args) + if err != nil { + t.Fatalf("ParseArgs error: %v", err) + } + + req := &replace.ReplaceRequest{ + WorkingDir: tmp, + Patterns: parsed.Patterns, + Replacement: parsed.Replacement, + } + + var buf bytes.Buffer + summary, _, err := replace.Preview(context.Background(), req, parsed, &buf) + if err != nil { + t.Fatalf("Preview error: %v", err) + } + + if !summary.EmptyReplacement { + t.Fatalf("expected empty replacement flag to be set") + } + + if !strings.Contains(buf.String(), "Warning: replacement string is empty") { + t.Fatalf("expected empty replacement warning in preview output") + } +} + +func createFile(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/fixtures/replace-samples/README.md b/tests/fixtures/replace-samples/README.md new file mode 100644 index 0000000..d2282c7 --- /dev/null +++ b/tests/fixtures/replace-samples/README.md @@ -0,0 +1,9 @@ +# Replace Command Fixtures + +Use this directory to store sample file trees referenced by replace command tests. Keep fixtures +minimal and platform-neutral (ASCII names, small files). Create subdirectories per scenario, e.g.: + +- `basic/` — Simple files demonstrating multi-pattern replacements. +- `conflicts/` — Cases that intentionally trigger name collisions for validation. + +File contents are typically irrelevant; create empty files unless a test requires data. diff --git a/tests/integration/replace_flow_test.go b/tests/integration/replace_flow_test.go new file mode 100644 index 0000000..f069b1f --- /dev/null +++ b/tests/integration/replace_flow_test.go @@ -0,0 +1,101 @@ +package integration + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + renamercmd "github.com/rogeecn/renamer/cmd" + "github.com/rogeecn/renamer/internal/history" + "github.com/rogeecn/renamer/internal/replace" +) + +func TestReplaceApplyAndUndo(t *testing.T) { + tmp := t.TempDir() + + createFile(t, filepath.Join(tmp, "foo_draft.txt")) + createFile(t, filepath.Join(tmp, "bar_draft.txt")) + + parsed, err := replace.ParseArgs([]string{"draft", "final"}) + if err != nil { + t.Fatalf("ParseArgs error: %v", err) + } + + req := &replace.ReplaceRequest{ + WorkingDir: tmp, + Patterns: parsed.Patterns, + Replacement: parsed.Replacement, + } + + summary, planned, err := replace.Preview(context.Background(), req, parsed, nil) + if err != nil { + t.Fatalf("Preview error: %v", err) + } + if summary.ChangedCount != 2 { + t.Fatalf("expected 2 changes, got %d", summary.ChangedCount) + } + + entry, err := replace.Apply(context.Background(), req, planned, summary) + if err != nil { + t.Fatalf("apply error: %v", err) + } + + if len(entry.Operations) != 2 { + t.Fatalf("expected 2 operations recorded, got %d", len(entry.Operations)) + } + if entry.Metadata == nil { + t.Fatalf("expected metadata to be recorded") + } + counts, ok := entry.Metadata["patterns"].(map[string]int) + if !ok { + t.Fatalf("patterns metadata missing or wrong type: %#v", entry.Metadata) + } + if counts["draft"] != 2 { + t.Fatalf("expected pattern count for 'draft' to be 2, got %d", counts["draft"]) + } + + if _, err := os.Stat(filepath.Join(tmp, "foo_final.txt")); err != nil { + t.Fatalf("expected renamed file exists: %v", err) + } + + if _, err := os.Stat(filepath.Join(tmp, "bar_final.txt")); err != nil { + t.Fatalf("expected renamed file exists: %v", err) + } + + _, err = history.Undo(tmp) + if err != nil { + t.Fatalf("undo error: %v", err) + } + + if _, err := os.Stat(filepath.Join(tmp, "foo_draft.txt")); err != nil { + t.Fatalf("expected original file after undo: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "bar_draft.txt")); err != nil { + t.Fatalf("expected original file after undo: %v", err) + } +} + +func TestReplaceCommandInvalidArgs(t *testing.T) { + root := renamercmd.NewRootCommand() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"replace", "onlyone"}) + + err := root.Execute() + if err == nil { + t.Fatalf("expected error for insufficient arguments") + } +} + +func createFile(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("x"), 0o644); err != nil { + t.Fatalf("write file %s: %v", path, err) + } +} diff --git a/tests/unit/replace_parser_test.go b/tests/unit/replace_parser_test.go new file mode 100644 index 0000000..dd6ec62 --- /dev/null +++ b/tests/unit/replace_parser_test.go @@ -0,0 +1,31 @@ +package replace_test + +import ( + "testing" + + "github.com/rogeecn/renamer/internal/replace" +) + +func TestParseArgsHandlesWhitespaceAndDuplicates(t *testing.T) { + args := []string{" draft ", "Draft", "draft", "final"} + result, err := replace.ParseArgs(args) + if err != nil { + t.Fatalf("ParseArgs returned error: %v", err) + } + + if len(result.Patterns) != 2 { + t.Fatalf("expected 2 unique patterns, got %d", len(result.Patterns)) + } + if len(result.Duplicates) != 1 { + t.Fatalf("expected duplicate reported, got %d", len(result.Duplicates)) + } + if result.Replacement != "final" { + t.Fatalf("replacement mismatch: %s", result.Replacement) + } +} + +func TestParseArgsRequiresSufficientTokens(t *testing.T) { + if _, err := replace.ParseArgs([]string{"onlyone"}); err == nil { + t.Fatalf("expected error when replacement missing") + } +}