feat: add replace subcommand with multi-pattern support

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

View File

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