feat: implement remove command with sequential removals
This commit is contained in:
92
internal/remove/apply.go
Normal file
92
internal/remove/apply.go
Normal 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
36
internal/remove/engine.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
106
internal/remove/preview.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user