feat: add replace subcommand with multi-pattern support

This commit is contained in:
Rogee
2025-10-29 17:46:54 +08:00
parent fa57af8a26
commit ceea09f7be
42 changed files with 1848 additions and 14 deletions

View File

@@ -3,29 +3,41 @@
Auto-generated from all feature plans. Last updated: 2025-10-29 Auto-generated from all feature plans. Last updated: 2025-10-29
## Active Technologies ## Active Technologies
- Local filesystem (no persistent database) (002-add-replace-command)
- Go 1.24 + `spf13/cobra`, `spf13/pflag` (001-list-command-filters) - Go 1.24 + `spf13/cobra`, `spf13/pflag` (001-list-command-filters)
## Project Structure ## Project Structure
```text ```text
src/ cmd/
internal/
scripts/
tests/ tests/
``` ```
## Commands ## Commands
- `renamer list` — preview rename scope with shared flags before executing changes. - `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 ## 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 ## Recent Changes
- 002-add-replace-command: Added `renamer replace` command, ledger metadata, and automation docs.
- 001-list-command-filters: Added Go 1.24 + `spf13/cobra`, `spf13/pflag` - 001-list-command-filters: Added `renamer list` command with shared scope flags and formatters.
- 001-list-command-filters: Introduced `renamer list` command with shared scope flags and formatters
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

119
cmd/replace.go Normal file
View File

@@ -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 <pattern1> [pattern2 ...] <replacement>",
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())
}

View File

@@ -29,5 +29,24 @@ func Execute() {
} }
func init() { 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()) 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
}

52
cmd/undo.go Normal file
View File

@@ -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())
}

View File

@@ -2,5 +2,7 @@
## Unreleased ## 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. - Add `renamer list` subcommand with shared scope flags and plain/table output formats.
- Document global scope flags and hidden-file behavior. - Document global scope flags and hidden-file behavior.

View File

@@ -1,15 +1,18 @@
# CLI Scope Flags # CLI Scope Flags
Renamer shares a consistent set of scope flags across every command that inspects or mutates the 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 filesystem. Use these options at the root command level so they apply to all subcommands (`list`,
`rename` alike. `replace`, future `preview`/`rename`, etc.).
| Flag | Default | Description | | Flag | Default | Description |
|------|---------|-------------| |------|---------|-------------|
| `--path` | `.` | Working directory root for traversal. |
| `-r`, `--recursive` | `false` | Traverse subdirectories depth-first. Symlinked directories are not followed. | | `-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. | | `-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. | | `-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. | | `--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`. | | `--format` | `table` | Command-specific output formatting option. For `list`, use `table` or `plain`. |
## Validation Rules ## 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 Keep this document updated whenever a new command is introduced or the global scope behavior
changes. changes.
## Replace Command Quick Reference
```bash
renamer replace <pattern1> [pattern2 ...] <replacement> [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 ### Usage Examples
- Preview files recursively: `renamer --recursive preview` - Preview files recursively: `renamer --recursive preview`
- List JPEGs only: `renamer --extensions .jpg list` - List JPEGs only: `renamer --extensions .jpg list`
- Replace multiple patterns: `renamer replace draft Draft final --dry-run`
- Include dotfiles: `renamer --hidden --extensions .env list` - Include dotfiles: `renamer --hidden --extensions .env list`

122
internal/history/history.go Normal file
View File

@@ -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)
}

View File

@@ -15,15 +15,19 @@ const (
flagIncludeDirs = "include-dirs" flagIncludeDirs = "include-dirs"
flagHidden = "hidden" flagHidden = "hidden"
flagExtensions = "extensions" flagExtensions = "extensions"
flagYes = "yes"
flagDryRun = "dry-run"
) )
// RegisterScopeFlags defines persistent flags that scope listing, preview, and rename operations. // RegisterScopeFlags defines persistent flags that scope listing, preview, and rename operations.
func RegisterScopeFlags(flags *pflag.FlagSet) { func RegisterScopeFlags(flags *pflag.FlagSet) {
flags.String(flagPath, "", "Directory to inspect (defaults to current working directory)") flags.String(flagPath, "", "Directory to inspect (defaults to current working directory)")
flags.BoolP(flagRecursive, "r", false, "Traverse subdirectories") flags.BoolP(flagRecursive, "r", false, "Traverse subdirectories")
flags.BoolP(flagIncludeDirs, "d", false, "Include directories in results") flags.BoolP(flagIncludeDirs, "d", false, "Include directories in results")
flags.Bool(flagHidden, false, "Include hidden files and directories") 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.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. // ScopeFromCmd builds a ListingRequest populated from scope flags on the provided command.

View File

@@ -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`.

86
internal/replace/apply.go Normal file
View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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
}

111
internal/replace/preview.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
},
)
}

42
scripts/smoke-test-replace.sh Executable file
View File

@@ -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."

View File

@@ -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.

View File

@@ -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`

View File

@@ -0,0 +1,70 @@
# CLI Contract: `renamer replace`
## Command Synopsis
```bash
renamer replace <pattern1> [pattern2 ...] <replacement> [flags]
```
### Global Flags (inherited from root command)
- `--path <dir>` (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 |
|----------|----------|-------------|
| `<pattern...>` | Yes (≥2) | Literal substrings to be replaced. Quotes required when containing spaces. |
| `<replacement>` | 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: <files> (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
```

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 <replacement>"
## 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 <pattern...> <replacement>` 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.

View File

@@ -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 storys 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 (T005T008) can progress in parallel after T004
- US1 tests (T010T011) can run alongside command wiring (T012T014)
- 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 (T010T015)
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

27
testdata/replace/README.md vendored Normal file
View File

@@ -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.

View File

View File

@@ -0,0 +1,3 @@
Case-insensitive filesystem fixture.
Use these files to verify case-only replacements, e.g. `renamer replace draft Draft`.

View File

View File

View File

View File

@@ -0,0 +1,3 @@
Hidden files fixture.
Use to validate `--hidden` flag handling with replace command.

View File

View File

@@ -0,0 +1,4 @@
Multi-pattern replacement fixture.
Example usage:
renamer replace alpha beta gamma final --path testdata/replace/multi-pattern --recursive --dry-run

View File

View File

View File

View File

@@ -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)
}
}

View File

@@ -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.

View File

@@ -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)
}
}

View File

@@ -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")
}
}