feat: implement remove command with sequential removals

This commit is contained in:
Rogee
2025-10-29 18:59:55 +08:00
parent 446bd46b95
commit f66c59fd57
31 changed files with 986 additions and 110 deletions

92
internal/remove/apply.go Normal file
View File

@@ -0,0 +1,92 @@
package remove
import (
"context"
"errors"
"os"
"path/filepath"
"sort"
"github.com/rogeecn/renamer/internal/history"
)
// Apply executes planned removals and appends the result to the ledger.
func Apply(ctx context.Context, req *Request, planned []PlannedOperation, summary Summary, orderedTokens []string) (history.Entry, error) {
entry := history.Entry{Command: "remove"}
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
matchesCopy := make(map[string]int, len(summary.TokenMatches))
for token, count := range summary.TokenMatches {
matchesCopy[token] = count
}
tokensCopy := append([]string(nil), orderedTokens...)
entry.Metadata = map[string]any{
"tokens": tokensCopy,
"matches": matchesCopy,
"changed": summary.ChangedCount,
"totalCandidates": summary.TotalCandidates,
}
if len(summary.Empties) > 0 {
entry.Metadata["empties"] = append([]string(nil), summary.Empties...)
}
if err := history.Append(req.WorkingDir, entry); err != nil {
_ = revert()
return history.Entry{}, err
}
return entry, nil
}

36
internal/remove/engine.go Normal file
View File

@@ -0,0 +1,36 @@
package remove
import "strings"
// Result captures the outcome of applying sequential removals to a candidate.
type Result struct {
Candidate Candidate
ProposedName string
Matches map[string]int
Changed bool
}
// ApplyTokens removes each token sequentially from the candidate's basename.
func ApplyTokens(candidate Candidate, tokens []string) Result {
current := candidate.BaseName
matches := make(map[string]int, len(tokens))
for _, token := range tokens {
if token == "" {
continue
}
count := strings.Count(current, token)
if count == 0 {
continue
}
current = strings.ReplaceAll(current, token, "")
matches[token] += count
}
return Result{
Candidate: candidate,
ProposedName: current,
Matches: matches,
Changed: current != candidate.BaseName,
}
}

View File

@@ -14,16 +14,15 @@ func ParseArgs(args []string) (ParseArgsResult, error) {
seen := make(map[string]int)
for _, raw := range args {
token := strings.TrimSpace(raw)
if token == "" {
if strings.TrimSpace(raw) == "" {
continue
}
if _, exists := seen[token]; exists {
result.Duplicates = append(result.Duplicates, token)
if _, exists := seen[raw]; exists {
result.Duplicates = append(result.Duplicates, raw)
continue
}
seen[token] = len(result.Tokens)
result.Tokens = append(result.Tokens, token)
seen[raw] = len(result.Tokens)
result.Tokens = append(result.Tokens, raw)
}
if len(result.Tokens) == 0 {

106
internal/remove/preview.go Normal file
View File

@@ -0,0 +1,106 @@
package remove
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 removals and writes a human-readable summary to out.
func Preview(ctx context.Context, req *Request, parsed ParseArgsResult, out io.Writer) (Summary, []PlannedOperation, error) {
summary := NewSummary()
for _, dup := range parsed.Duplicates {
summary.AddDuplicate(dup)
}
planned := make([]PlannedOperation, 0)
plannedTargets := make(map[string]string)
err := Traverse(ctx, req, func(candidate Candidate) error {
res := ApplyTokens(candidate, parsed.Tokens)
summary.RecordCandidate(res)
if !res.Changed {
return nil
}
if res.ProposedName == "" {
summary.AddEmpty(candidate.RelativePath)
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 {
if origInfo, origErr := os.Stat(candidate.OriginalPath); origErr == nil && os.SameFile(info, origInfo) {
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
}
return summary, planned, nil
}

View File

@@ -46,7 +46,7 @@ func (r *Request) Validate() error {
}
for i, token := range r.Tokens {
if token == "" {
return fmt.Errorf("token at position %d is empty after trimming", i)
return fmt.Errorf("token at position %d is empty", i)
}
}
return nil

View File

@@ -2,112 +2,98 @@ package remove
import "sort"
// Summary aggregates results across preview/apply phases.
// ConflictDetail describes a rename that cannot proceed.
type ConflictDetail struct {
OriginalPath string
ProposedPath string
Reason string
}
// Summary aggregates preview/apply metrics for reporting and ledger metadata.
type Summary struct {
totalCandidates int
changedCount int
conflicts []Conflict
empties []string
tokenMatches map[string]int
duplicates []string
TotalCandidates int
ChangedCount int
TokenMatches map[string]int
Conflicts []ConflictDetail
Empties []string
Duplicates []string
}
// Conflict describes a rename conflict detected during planning.
type Conflict struct {
Original string
Proposed string
Reason string
}
// NewSummary constructs a ready-to-use Summary.
// NewSummary constructs an initialized summary instance.
func NewSummary() Summary {
return Summary{
tokenMatches: make(map[string]int),
conflicts: make([]Conflict, 0),
empties: make([]string, 0),
duplicates: make([]string, 0),
TokenMatches: make(map[string]int),
}
}
// RecordCandidate increments the total candidate count.
func (s *Summary) RecordCandidate() {
s.totalCandidates++
// RecordCandidate updates aggregate counts based on a candidate result.
func (s *Summary) RecordCandidate(res Result) {
s.TotalCandidates++
if !res.Changed {
return
}
s.ChangedCount++
for token, count := range res.Matches {
s.TokenMatches[token] += count
}
}
// RecordChange increments changed items.
func (s *Summary) RecordChange() {
s.changedCount++
// AddConflict registers a conflict for reporting.
func (s *Summary) AddConflict(conflict ConflictDetail) {
s.Conflicts = append(s.Conflicts, conflict)
}
// AddTokenMatch records the number of matches for a token.
func (s *Summary) AddTokenMatch(token string, count int) {
s.tokenMatches[token] += count
}
// AddConflict registers a detected conflict.
func (s *Summary) AddConflict(c Conflict) {
s.conflicts = append(s.conflicts, c)
}
// AddEmpty registers a path skipped due to empty result names.
// AddEmpty records a path whose resulting name would be empty.
func (s *Summary) AddEmpty(path string) {
s.empties = append(s.empties, path)
s.Empties = append(s.Empties, path)
}
// AddDuplicate tracks duplicate tokens encountered during parsing.
// AddDuplicate stores duplicate tokens captured during parsing.
func (s *Summary) AddDuplicate(token string) {
s.duplicates = append(s.duplicates, token)
if token == "" {
return
}
s.Duplicates = append(s.Duplicates, token)
}
// TotalCandidates returns how many items were considered.
func (s Summary) TotalCandidates() int {
return s.totalCandidates
// SortedDuplicates returns unique duplicate tokens sorted for deterministic output.
func (s *Summary) SortedDuplicates() []string {
if len(s.Duplicates) == 0 {
return nil
}
seen := make(map[string]struct{}, len(s.Duplicates))
result := make([]string, 0, len(s.Duplicates))
for _, dup := range s.Duplicates {
if _, ok := seen[dup]; ok {
continue
}
seen[dup] = struct{}{}
result = append(result, dup)
}
sort.Strings(result)
return result
}
// ChangedCount returns the number of items whose names changed.
func (s Summary) ChangedCount() int {
return s.changedCount
}
// Conflicts returns a copy of conflict info.
func (s Summary) Conflicts() []Conflict {
out := make([]Conflict, len(s.conflicts))
copy(out, s.conflicts)
return out
}
// Empties returns paths skipped for empty basename results.
func (s Summary) Empties() []string {
out := make([]string, len(s.empties))
copy(out, s.empties)
return out
}
// TokenMatches returns a sorted slice of tokens and counts.
func (s Summary) TokenMatches() []struct {
// SortedTokenMatches returns token match counts sorted alphabetically by token.
func (s *Summary) SortedTokenMatches() []struct {
Token string
Count int
} {
pairs := make([]struct {
if len(s.TokenMatches) == 0 {
return nil
}
result := make([]struct {
Token string
Count int
}, 0, len(s.tokenMatches))
for token, count := range s.tokenMatches {
pairs = append(pairs, struct {
}, 0, len(s.TokenMatches))
for token, count := range s.TokenMatches {
result = append(result, struct {
Token string
Count int
}{Token: token, Count: count})
}
sort.Slice(pairs, func(i, j int) bool {
return pairs[i].Token < pairs[j].Token
sort.Slice(result, func(i, j int) bool {
return result[i].Token < result[j].Token
})
return pairs
}
// Duplicates returns duplicates flagged by the parser.
func (s Summary) Duplicates() []string {
out := make([]string, len(s.duplicates))
copy(out, s.duplicates)
sort.Strings(out)
return out
return result
}

View File

@@ -55,6 +55,10 @@ func Traverse(ctx context.Context, req *Request, fn func(Candidate) error) error
}
}
if isDir && !req.IncludeDirectories {
return nil
}
candidate := Candidate{
RelativePath: filepath.ToSlash(relPath),
OriginalPath: filepath.Join(req.WorkingDir, relPath),