feat: add replace subcommand with multi-pattern support
This commit is contained in:
26
AGENTS.md
26
AGENTS.md
@@ -3,29 +3,41 @@
|
||||
Auto-generated from all feature plans. Last updated: 2025-10-29
|
||||
|
||||
## Active Technologies
|
||||
|
||||
- Local filesystem (no persistent database) (002-add-replace-command)
|
||||
- Go 1.24 + `spf13/cobra`, `spf13/pflag` (001-list-command-filters)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
cmd/
|
||||
internal/
|
||||
scripts/
|
||||
tests/
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `renamer list` — preview rename scope with shared flags before executing changes.
|
||||
- Persistent scope flags: `--path`, `-r/--recursive`, `-d/--include-dirs`, `--hidden`, `--extensions`.
|
||||
- `renamer replace` — consolidate multiple literal patterns into a single replacement (supports `--dry-run` + `--yes`).
|
||||
- `renamer undo` — revert the most recent rename/replace batch using ledger entries.
|
||||
- Persistent scope flags: `--path`, `-r/--recursive`, `-d/--include-dirs`, `--hidden`, `--extensions`, `--yes`, `--dry-run`.
|
||||
|
||||
## Code Style
|
||||
|
||||
Go 1.24: Follow standard conventions
|
||||
- Go 1.24: follow gofmt (already checked in CI)
|
||||
- Prefer composable packages under `internal/` for reusable logic
|
||||
- Keep CLI wiring thin; place business logic in services
|
||||
|
||||
## Testing
|
||||
|
||||
- `go test ./...`
|
||||
- Contract tests: `tests/contract/replace_command_test.go`
|
||||
- Integration tests: `tests/integration/replace_flow_test.go`
|
||||
- Smoke: `scripts/smoke-test-replace.sh`
|
||||
|
||||
## Recent Changes
|
||||
|
||||
- 001-list-command-filters: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`
|
||||
- 001-list-command-filters: Introduced `renamer list` command with shared scope flags and formatters
|
||||
- 002-add-replace-command: Added `renamer replace` command, ledger metadata, and automation docs.
|
||||
- 001-list-command-filters: Added `renamer list` command with shared scope flags and formatters.
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
119
cmd/replace.go
Normal file
119
cmd/replace.go
Normal 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())
|
||||
}
|
||||
19
cmd/root.go
19
cmd/root.go
@@ -29,5 +29,24 @@ func Execute() {
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Register persistent flags shared by all subcommands (`list`, `replace`, etc.).
|
||||
// These scope flags remain centralized so new commands automatically inherit
|
||||
// traversal behavior without duplicating flag definitions.
|
||||
listing.RegisterScopeFlags(rootCmd.PersistentFlags())
|
||||
}
|
||||
|
||||
// NewRootCommand creates a fresh root command with all subcommands and flags registered.
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "renamer",
|
||||
Short: "Safe, scriptable batch renaming utility",
|
||||
Long: rootCmd.Long,
|
||||
}
|
||||
|
||||
listing.RegisterScopeFlags(cmd.PersistentFlags())
|
||||
cmd.AddCommand(newListCommand())
|
||||
cmd.AddCommand(NewReplaceCommand())
|
||||
cmd.AddCommand(newUndoCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
52
cmd/undo.go
Normal file
52
cmd/undo.go
Normal 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())
|
||||
}
|
||||
@@ -2,5 +2,7 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Add `renamer replace` subcommand supporting multi-pattern replacements, preview/apply/undo, and scope flags.
|
||||
- Document quoting guidance, `--dry-run` / `--yes` behavior, and automation scenarios for replace command.
|
||||
- Add `renamer list` subcommand with shared scope flags and plain/table output formats.
|
||||
- Document global scope flags and hidden-file behavior.
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
# CLI Scope Flags
|
||||
|
||||
Renamer shares a consistent set of scope flags across every command that inspects or mutates the
|
||||
filesystem. Use these options at the root command level so they apply to `list`, `preview`, and
|
||||
`rename` alike.
|
||||
filesystem. Use these options at the root command level so they apply to all subcommands (`list`,
|
||||
`replace`, future `preview`/`rename`, etc.).
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--path` | `.` | Working directory root for traversal. |
|
||||
| `-r`, `--recursive` | `false` | Traverse subdirectories depth-first. Symlinked directories are not followed. |
|
||||
| `-d`, `--include-dirs` | `false` | Limit results to directories only (files and symlinks are suppressed). Directory traversal still occurs even when the flag is absent. |
|
||||
| `-e`, `--extensions` | *(none)* | Pipe-separated list of file extensions (e.g. `.jpg|.mov`). Tokens must start with a dot, are lowercased internally, and duplicates are ignored. |
|
||||
| `--hidden` | `false` | Include dot-prefixed files and directories. By default they are excluded from listings and rename previews. |
|
||||
| `--yes` | `false` | Apply changes without an interactive confirmation prompt (mutating commands only). |
|
||||
| `--dry-run` | `false` | Force preview-only behavior even when `--yes` is supplied. |
|
||||
| `--format` | `table` | Command-specific output formatting option. For `list`, use `table` or `plain`. |
|
||||
|
||||
## Validation Rules
|
||||
@@ -22,8 +25,26 @@ filesystem. Use these options at the root command level so they apply to `list`,
|
||||
Keep this document updated whenever a new command is introduced or the global scope behavior
|
||||
changes.
|
||||
|
||||
## Replace Command Quick Reference
|
||||
|
||||
```bash
|
||||
renamer replace <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
|
||||
|
||||
- Preview files recursively: `renamer --recursive preview`
|
||||
- List JPEGs only: `renamer --extensions .jpg list`
|
||||
- Replace multiple patterns: `renamer replace draft Draft final --dry-run`
|
||||
- Include dotfiles: `renamer --hidden --extensions .env list`
|
||||
|
||||
122
internal/history/history.go
Normal file
122
internal/history/history.go
Normal 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)
|
||||
}
|
||||
@@ -15,15 +15,19 @@ const (
|
||||
flagIncludeDirs = "include-dirs"
|
||||
flagHidden = "hidden"
|
||||
flagExtensions = "extensions"
|
||||
flagYes = "yes"
|
||||
flagDryRun = "dry-run"
|
||||
)
|
||||
|
||||
// RegisterScopeFlags defines persistent flags that scope listing, preview, and rename operations.
|
||||
func RegisterScopeFlags(flags *pflag.FlagSet) {
|
||||
flags.String(flagPath, "", "Directory to inspect (defaults to current working directory)")
|
||||
flags.BoolP(flagRecursive, "r", false, "Traverse subdirectories")
|
||||
flags.BoolP(flagIncludeDirs, "d", false, "Include directories in results")
|
||||
flags.Bool(flagHidden, false, "Include hidden files and directories")
|
||||
flags.StringP(flagExtensions, "e", "", "Pipe-delimited list of extensions to include (e.g. .jpg|.png)")
|
||||
flags.String(flagPath, "", "Directory to inspect (defaults to current working directory)")
|
||||
flags.BoolP(flagRecursive, "r", false, "Traverse subdirectories")
|
||||
flags.BoolP(flagIncludeDirs, "d", false, "Include directories in results")
|
||||
flags.Bool(flagHidden, false, "Include hidden files and directories")
|
||||
flags.StringP(flagExtensions, "e", "", "Pipe-delimited list of extensions to include (e.g. .jpg|.png)")
|
||||
flags.Bool(flagYes, false, "Apply changes without interactive confirmation (mutating commands)")
|
||||
flags.Bool(flagDryRun, false, "Force preview-only output without applying changes")
|
||||
}
|
||||
|
||||
// ScopeFromCmd builds a ListingRequest populated from scope flags on the provided command.
|
||||
|
||||
13
internal/replace/README.md
Normal file
13
internal/replace/README.md
Normal 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
86
internal/replace/apply.go
Normal 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
|
||||
}
|
||||
38
internal/replace/engine.go
Normal file
38
internal/replace/engine.go
Normal 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,
|
||||
}
|
||||
}
|
||||
52
internal/replace/parser.go
Normal file
52
internal/replace/parser.go
Normal 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
111
internal/replace/preview.go
Normal 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
|
||||
}
|
||||
61
internal/replace/request.go
Normal file
61
internal/replace/request.go
Normal 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
|
||||
}
|
||||
80
internal/replace/summary.go
Normal file
80
internal/replace/summary.go
Normal 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
|
||||
}
|
||||
73
internal/replace/traversal.go
Normal file
73
internal/replace/traversal.go
Normal 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
42
scripts/smoke-test-replace.sh
Executable 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."
|
||||
29
specs/002-add-replace-command/checklists/release.md
Normal file
29
specs/002-add-replace-command/checklists/release.md
Normal 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.
|
||||
34
specs/002-add-replace-command/checklists/requirements.md
Normal file
34
specs/002-add-replace-command/checklists/requirements.md
Normal 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`
|
||||
70
specs/002-add-replace-command/contracts/replace-command.md
Normal file
70
specs/002-add-replace-command/contracts/replace-command.md
Normal 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
|
||||
```
|
||||
45
specs/002-add-replace-command/data-model.md
Normal file
45
specs/002-add-replace-command/data-model.md
Normal 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.
|
||||
90
specs/002-add-replace-command/plan.md
Normal file
90
specs/002-add-replace-command/plan.md
Normal 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.
|
||||
63
specs/002-add-replace-command/quickstart.md
Normal file
63
specs/002-add-replace-command/quickstart.md
Normal 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.
|
||||
32
specs/002-add-replace-command/research.md
Normal file
32
specs/002-add-replace-command/research.md
Normal 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.
|
||||
115
specs/002-add-replace-command/spec.md
Normal file
115
specs/002-add-replace-command/spec.md
Normal 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.
|
||||
169
specs/002-add-replace-command/tasks.md
Normal file
169
specs/002-add-replace-command/tasks.md
Normal 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 story’s independent validation.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Prepare project for replace command implementation
|
||||
|
||||
- [X] T001 Audit root command for shared flags; document expected additions in `cmd/root.go`
|
||||
- [X] T002 Create replace package scaffold in `internal/replace/README.md` with intended module layout
|
||||
- [X] T003 Add sample fixtures directory for replacement tests at `tests/fixtures/replace-samples/README.md`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core utilities required by every user story
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
- [X] T004 Implement `ReplaceRequest` struct and validators in `internal/replace/request.go`
|
||||
- [X] T005 [P] Implement pattern parser handling quoting/deduplication in `internal/replace/parser.go`
|
||||
- [X] T006 [P] Extend traversal utilities to emit replace candidates in `internal/replace/traversal.go`
|
||||
- [X] T007 [P] Implement replacement engine with overlap handling in `internal/replace/engine.go`
|
||||
- [X] T008 Define summary metrics and conflict structs in `internal/replace/summary.go`
|
||||
- [X] T009 Document replace syntax and flags draft in `docs/cli-flags.md`
|
||||
|
||||
**Checkpoint**: Foundation ready—user story implementation can begin
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Normalize Naming with One Command (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Deliver multi-pattern replacement with preview + apply guarantees
|
||||
|
||||
**Independent Test**: `renamer replace foo bar Baz --dry-run` followed by `--yes`; verify preview shows replacements, apply updates files, and `renamer undo` restores originals.
|
||||
|
||||
### Tests for User Story 1 (OPTIONAL - included for confidence)
|
||||
|
||||
- [X] T010 [P] [US1] Contract test for preview summary counts in `tests/contract/replace_command_test.go`
|
||||
- [X] T011 [P] [US1] Integration test covering multi-pattern apply + undo in `tests/integration/replace_flow_test.go`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T012 [US1] Implement CLI command wiring in `cmd/replace.go` using shared scope flags
|
||||
- [X] T013 [US1] Implement preview rendering and summary output in `internal/replace/preview.go`
|
||||
- [X] T014 [US1] Hook replace engine into ledger/undo pipeline in `internal/replace/apply.go`
|
||||
- [X] T015 [US1] Add documentation examples to `docs/cli-flags.md` and quickstart
|
||||
|
||||
**Checkpoint**: User Story 1 delivers functional `renamer replace` with preview/apply/undo
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Script-Friendly Replacement Workflows (Priority: P2)
|
||||
|
||||
**Goal**: Ensure automation-friendly behaviors (exit codes, non-interactive usage)
|
||||
|
||||
**Independent Test**: Scripted run invoking `renamer replace ... --dry-run` then `--yes`; expect consistent exit codes and ledger entry
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T016 [US2] Implement non-interactive flag validation (`--yes` + positional args) in `cmd/replace.go`
|
||||
- [X] T017 [US2] Add ledger metadata (pattern counts) for automation in `internal/replace/summary.go`
|
||||
- [X] T018 [US2] Expand integration test to assert exit codes for invalid input in `tests/integration/replace_flow_test.go`
|
||||
- [X] T019 [US2] Update quickstart section with automation guidance in `specs/002-add-replace-command/quickstart.md`
|
||||
|
||||
**Checkpoint**: Scripts can rely on deterministic exit codes and ledger data
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Validate Complex Pattern Input (Priority: P3)
|
||||
|
||||
**Goal**: Provide resilient handling for whitespace/special-character patterns and user guidance
|
||||
|
||||
**Independent Test**: `renamer replace "Project X" "Project-X" ProjectX --dry-run` plus invalid quoting to verify errors
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T020 [P] [US3] Implement quoting guidance and warnings in `cmd/replace.go`
|
||||
- [X] T021 [P] [US3] Add parser coverage for whitespace patterns in `tests/unit/replace_parser_test.go`
|
||||
- [X] T022 [US3] Surface duplicate pattern warnings in preview summary in `internal/replace/preview.go`
|
||||
- [X] T023 [US3] Document advanced pattern examples in `docs/cli-flags.md`
|
||||
|
||||
**Checkpoint**: Power users receive clear guidance and validation feedback
|
||||
|
||||
---
|
||||
|
||||
## Phase N: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final validation, documentation, and release readiness
|
||||
|
||||
- [X] T024 Update agent guidance with replace command details in `AGENTS.md`
|
||||
- [X] T025 Add changelog entry describing new replace command in `docs/CHANGELOG.md`
|
||||
- [X] T026 Create smoke test script covering replace + undo in `scripts/smoke-test-replace.sh`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No prerequisites
|
||||
- **Foundational (Phase 2)**: Depends on setup completion; blocks all user stories
|
||||
- **User Story 1 (Phase 3)**: Depends on foundational phase; MVP delivery
|
||||
- **User Story 2 (Phase 4)**: Depends on User Story 1 (automation builds on base command)
|
||||
- **User Story 3 (Phase 5)**: Depends on User Story 1 (parser/extensions) and shares foundation
|
||||
- **Polish (Phase N)**: Runs after user stories complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: Requires foundational tasks
|
||||
- **US2**: Requires US1 implementation + ledger integration
|
||||
- **US3**: Requires US1 parser and preview infrastructure
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests (if included) should be authored before implementation tasks
|
||||
- Parser/engine updates precede CLI wiring
|
||||
- Documentation updates finalize after behavior stabilizes
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- Foundational parser/engine/summary tasks (T005–T008) can progress in parallel after T004
|
||||
- US1 tests (T010–T011) can run alongside command wiring (T012–T014)
|
||||
- US3 parser coverage (T021) can proceed independently while warnings (T022) integrate with preview
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Terminal 1: Write contract test and run in watch mode
|
||||
go test ./tests/contract -run TestReplaceCommandPreview
|
||||
|
||||
# Terminal 2: Implement preview renderer
|
||||
$EDITOR internal/replace/preview.go
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1)
|
||||
1. Complete Setup and Foundational phases
|
||||
2. Implement US1 tasks (T010–T015)
|
||||
3. Ensure preview/apply/undo works end-to-end
|
||||
|
||||
### Incremental Delivery
|
||||
1. Ship US1 (core replace command)
|
||||
2. Layer US2 (automation-friendly exit codes and ledger metadata)
|
||||
3. Add US3 (advanced pattern validation)
|
||||
4. Execute polish tasks for documentation and smoke tests
|
||||
|
||||
### Parallel Team Strategy
|
||||
- Engineer A: Parser/engine/summary foundational work
|
||||
- Engineer B: CLI command wiring + tests (US1)
|
||||
- Engineer C: Automation behaviors and documentation (US2/Polish)
|
||||
- After US1, shift Engineers to handle US3 enhancements and polish concurrently
|
||||
27
testdata/replace/README.md
vendored
Normal file
27
testdata/replace/README.md
vendored
Normal 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.
|
||||
0
testdata/replace/case-sensitivity/Draft.txt
vendored
Normal file
0
testdata/replace/case-sensitivity/Draft.txt
vendored
Normal file
3
testdata/replace/case-sensitivity/README.md
vendored
Normal file
3
testdata/replace/case-sensitivity/README.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
Case-insensitive filesystem fixture.
|
||||
|
||||
Use these files to verify case-only replacements, e.g. `renamer replace draft Draft`.
|
||||
0
testdata/replace/case-sensitivity/draft.txt
vendored
Normal file
0
testdata/replace/case-sensitivity/draft.txt
vendored
Normal file
0
testdata/replace/case-sensitivity/sample.txt
vendored
Normal file
0
testdata/replace/case-sensitivity/sample.txt
vendored
Normal file
0
testdata/replace/hidden-files/.draft.tmp
vendored
Normal file
0
testdata/replace/hidden-files/.draft.tmp
vendored
Normal file
3
testdata/replace/hidden-files/README.md
vendored
Normal file
3
testdata/replace/hidden-files/README.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
Hidden files fixture.
|
||||
|
||||
Use to validate `--hidden` flag handling with replace command.
|
||||
0
testdata/replace/hidden-files/notes.txt
vendored
Normal file
0
testdata/replace/hidden-files/notes.txt
vendored
Normal file
4
testdata/replace/multi-pattern/README.md
vendored
Normal file
4
testdata/replace/multi-pattern/README.md
vendored
Normal 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
|
||||
0
testdata/replace/multi-pattern/alpha-beta.txt
vendored
Normal file
0
testdata/replace/multi-pattern/alpha-beta.txt
vendored
Normal file
0
testdata/replace/multi-pattern/beta-gamma.log
vendored
Normal file
0
testdata/replace/multi-pattern/beta-gamma.log
vendored
Normal file
0
testdata/replace/multi-pattern/nested/gamma-delta.md
vendored
Normal file
0
testdata/replace/multi-pattern/nested/gamma-delta.md
vendored
Normal file
102
tests/contract/replace_command_test.go
Normal file
102
tests/contract/replace_command_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
9
tests/fixtures/replace-samples/README.md
vendored
Normal file
9
tests/fixtures/replace-samples/README.md
vendored
Normal 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.
|
||||
101
tests/integration/replace_flow_test.go
Normal file
101
tests/integration/replace_flow_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
31
tests/unit/replace_parser_test.go
Normal file
31
tests/unit/replace_parser_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user