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,6 +15,8 @@ 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.
@@ -24,6 +26,8 @@ func RegisterScopeFlags(flags *pflag.FlagSet) {
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")
}
}