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

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

@@ -0,0 +1,122 @@
package history
import (
"bufio"
"encoding/json"
"errors"
"os"
"path/filepath"
"time"
)
const ledgerFileName = ".renamer"
// Operation records a single rename from source to target.
type Operation struct {
From string `json:"from"`
To string `json:"to"`
}
// Entry represents a batch of operations appended to the ledger.
type Entry struct {
Timestamp time.Time `json:"timestamp"`
Command string `json:"command"`
WorkingDir string `json:"workingDir"`
Operations []Operation `json:"operations"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// Append writes a new entry to the ledger in newline-delimited JSON format.
func Append(workingDir string, entry Entry) error {
entry.Timestamp = time.Now().UTC()
entry.WorkingDir = workingDir
path := ledgerPath(workingDir)
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
defer file.Close()
enc := json.NewEncoder(file)
return enc.Encode(entry)
}
// Undo reverts the most recent ledger entry and removes it from the ledger file.
func Undo(workingDir string) (Entry, error) {
path := ledgerPath(workingDir)
file, err := os.Open(path)
if errors.Is(err, os.ErrNotExist) {
return Entry{}, errors.New("no ledger entries available")
} else if err != nil {
return Entry{}, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
entries := make([]Entry, 0)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var e Entry
if err := json.Unmarshal(append([]byte(nil), line...), &e); err != nil {
return Entry{}, err
}
entries = append(entries, e)
}
if err := scanner.Err(); err != nil {
return Entry{}, err
}
if len(entries) == 0 {
return Entry{}, errors.New("no ledger entries available")
}
last := entries[len(entries)-1]
// Revert operations in reverse order.
for i := len(last.Operations) - 1; i >= 0; i-- {
op := last.Operations[i]
source := filepath.Join(workingDir, op.To)
destination := filepath.Join(workingDir, op.From)
if err := os.Rename(source, destination); err != nil {
return Entry{}, err
}
}
// Rewrite ledger without the last entry.
if len(entries) == 1 {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return Entry{}, err
}
} else {
tmp := path + ".tmp"
output, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return Entry{}, err
}
enc := json.NewEncoder(output)
for _, e := range entries[:len(entries)-1] {
if err := enc.Encode(e); err != nil {
output.Close()
return Entry{}, err
}
}
if err := output.Close(); err != nil {
return Entry{}, err
}
if err := os.Rename(tmp, path); err != nil {
return Entry{}, err
}
}
return last, nil
}
// ledgerPath returns the absolute path to the ledger file under workingDir.
func ledgerPath(workingDir string) string {
return filepath.Join(workingDir, ledgerFileName)
}

View File

@@ -15,15 +15,19 @@ const (
flagIncludeDirs = "include-dirs"
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.

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