feat: add replace subcommand with multi-pattern support
This commit is contained in:
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user