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

View File

@@ -20,6 +20,7 @@ tests/
- `renamer list` — preview rename scope with shared flags before executing changes.
- `renamer replace` — consolidate multiple literal patterns into a single replacement (supports `--dry-run` + `--yes`).
- `renamer remove` — delete ordered substrings from filenames with empty-name protections, duplicate warnings, and undoable ledger entries.
- `renamer undo` — revert the most recent rename/replace batch using ledger entries.
- Persistent scope flags: `--path`, `-r/--recursive`, `-d/--include-dirs`, `--hidden`, `--extensions`, `--yes`, `--dry-run`.
@@ -32,12 +33,12 @@ tests/
## Testing
- `go test ./...`
- Contract tests: `tests/contract/replace_command_test.go`
- Integration tests: `tests/integration/replace_flow_test.go`
- Smoke: `scripts/smoke-test-replace.sh`
- Contract tests: `tests/contract/replace_command_test.go`, `tests/contract/remove_command_preview_test.go`, `tests/contract/remove_command_ledger_test.go`
- Integration tests: `tests/integration/replace_flow_test.go`, `tests/integration/remove_flow_test.go`, `tests/integration/remove_undo_test.go`, `tests/integration/remove_validation_test.go`
- Smoke: `scripts/smoke-test-replace.sh`, `scripts/smoke-test-remove.sh`
## Recent Changes
- 003-add-remove-command: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`
- 003-add-remove-command: Added sequential `renamer remove` subcommand, automation-friendly ledger metadata, and CLI warnings for duplicates/empty results
- 002-add-replace-command: Added `renamer replace` command, ledger metadata, and automation docs.
- 001-list-command-filters: Added `renamer list` command with shared scope flags and formatters.

107
cmd/remove.go Normal file
View File

@@ -0,0 +1,107 @@
package cmd
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"github.com/rogeecn/renamer/internal/listing"
"github.com/rogeecn/renamer/internal/remove"
)
// NewRemoveCommand constructs the remove CLI command; exported for testing.
func NewRemoveCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "remove <pattern1> [pattern2 ...]",
Short: "Remove literal substrings sequentially from names",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
parsed, err := remove.ParseArgs(args)
if err != nil {
return err
}
scope, err := listing.ScopeFromCmd(cmd)
if err != nil {
return err
}
req, err := remove.FromListing(scope, parsed.Tokens)
if err != nil {
return err
}
dryRun, err := getBool(cmd, "dry-run")
if err != nil {
return err
}
autoApply, err := getBool(cmd, "yes")
if err != nil {
return err
}
if dryRun && autoApply {
return errors.New("--dry-run cannot be combined with --yes; remove one of them")
}
out := cmd.OutOrStdout()
summary, planned, err := remove.Preview(cmd.Context(), req, parsed, out)
if err != nil {
return err
}
for _, empty := range summary.Empties {
fmt.Fprintf(out, "Warning: %s would become empty; skipping\n", empty)
}
for _, dup := range summary.SortedDuplicates() {
fmt.Fprintf(out, "Warning: token %q provided multiple times\n", dup)
}
if len(summary.Conflicts) > 0 {
for _, conflict := range summary.Conflicts {
fmt.Fprintf(out, "CONFLICT: %s -> %s (%s)\n", conflict.OriginalPath, conflict.ProposedPath, conflict.Reason)
}
return errors.New("conflicts detected; aborting")
}
if summary.ChangedCount == 0 {
fmt.Fprintln(out, "No removals required")
return nil
}
fmt.Fprintf(out, "Planned removals: %d entries updated across %d candidates\n", summary.ChangedCount, summary.TotalCandidates)
for _, pair := range summary.SortedTokenMatches() {
fmt.Fprintf(out, " %s -> %d occurrences\n", pair.Token, pair.Count)
}
if dryRun || !autoApply {
fmt.Fprintln(out, "Preview complete. Re-run with --yes to apply.")
return nil
}
entry, err := remove.Apply(cmd.Context(), req, planned, summary, parsed.Tokens)
if err != nil {
return err
}
if len(entry.Operations) == 0 {
fmt.Fprintln(out, "Nothing to apply; preview already up to date.")
return nil
}
fmt.Fprintf(out, "Applied %d removals. Ledger updated.\n", len(entry.Operations))
return nil
},
}
cmd.Example = " renamer remove \" copy\" \" draft\" --dry-run\n renamer remove foo bar --yes --path ./docs"
return cmd
}
func init() {
rootCmd.AddCommand(NewRemoveCommand())
}

View File

@@ -46,6 +46,7 @@ func NewRootCommand() *cobra.Command {
listing.RegisterScopeFlags(cmd.PersistentFlags())
cmd.AddCommand(newListCommand())
cmd.AddCommand(NewReplaceCommand())
cmd.AddCommand(NewRemoveCommand())
cmd.AddCommand(newUndoCommand())
return cmd

View File

@@ -2,6 +2,8 @@
## Unreleased
- Add `renamer remove` subcommand with sequential multi-token deletions, empty-name safeguards, and ledger-backed undo.
- Document remove command ordering semantics, duplicate warnings, and automation guidance.
- Add `renamer replace` subcommand supporting multi-pattern replacements, preview/apply/undo, and scope flags.
- Document quoting guidance, `--dry-run` / `--yes` behavior, and automation scenarios for replace command.
- Add `renamer list` subcommand with shared scope flags and plain/table output formats.

View File

@@ -48,3 +48,26 @@ renamer replace <pattern1> [pattern2 ...] <replacement> [flags]
- List JPEGs only: `renamer --extensions .jpg list`
- Replace multiple patterns: `renamer replace draft Draft final --dry-run`
- Include dotfiles: `renamer --hidden --extensions .env list`
## Remove Command Quick Reference
```bash
renamer remove <token1> [token2 ...] [flags]
```
- Removal tokens are evaluated in the order supplied. Each token deletes literal substrings from the
current filename before the next token runs; results are previewed before any filesystem changes.
- Duplicate tokens are deduplicated automatically and surfaced as warnings so users can adjust
scripts without surprises.
- Tokens that collapse a filename to an empty string are skipped with warnings during preview/apply
to protect against accidental deletion.
- All scope flags (`--path`, `-r`, `-d`, `--hidden`, `-e`) apply, making it easy to target directories,
recurse, and limit removals by extension.
- Use `--dry-run` for automation previews and combine with `--yes` to apply unattended; conflicting
combinations (`--dry-run --yes`) exit with an error to uphold preview-first safety.
### Usage Examples
- Preview sequential removals: `renamer remove " copy" " draft" --dry-run`
- Remove tokens recursively: `renamer remove foo foo- --recursive --path ./reports`
- Combine with extension filters: `renamer remove " Project" --extensions .txt|.md --dry-run`

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

View File

@@ -11,7 +11,19 @@ touch "$TMP_DIR/report copy draft.txt"
touch "$TMP_DIR/nested/notes draft.txt"
echo "Previewing removals..."
$BIN "$ROOT_DIR/main.go" remove " copy" " draft" --path "$TMP_DIR" --recursive --dry-run >/dev/null
preview_output="$($BIN "$ROOT_DIR/main.go" remove " copy" " draft" --path "$TMP_DIR" --recursive --dry-run)"
if [[ "$preview_output" != *"report copy draft.txt -> report.txt"* ]]; then
echo "expected preview to show mapping for report copy draft.txt" >&2
echo "$preview_output" >&2
exit 1
fi
if [[ "$preview_output" != *"Preview complete."* ]]; then
echo "expected preview completion message" >&2
echo "$preview_output" >&2
exit 1
fi
if [[ ! -f "$TMP_DIR/report copy draft.txt" ]]; then
echo "preview should not modify files" >&2

View File

@@ -35,16 +35,16 @@
### Tests for User Story 1 ⚠️
- [ ] T007 [P] [US1] Add unit tests covering sequential token application and unchanged cases in `tests/unit/remove_engine_test.go`.
- [ ] T008 [P] [US1] Create contract test validating preview table output and dry-run messaging in `tests/contract/remove_command_preview_test.go`.
- [ ] T009 [P] [US1] Write integration test exercising preview → apply flow with multiple files in `tests/integration/remove_flow_test.go`.
- [X] T007 [P] [US1] Add unit tests covering sequential token application and unchanged cases in `tests/unit/remove_engine_test.go`.
- [X] T008 [P] [US1] Create contract test validating preview table output and dry-run messaging in `tests/contract/remove_command_preview_test.go`.
- [X] T009 [P] [US1] Write integration test exercising preview → apply flow with multiple files in `tests/integration/remove_flow_test.go`.
### Implementation for User Story 1
- [ ] T010 [US1] Implement sequential removal engine producing planned operations in `internal/remove/engine.go`.
- [ ] T011 [US1] Build preview pipeline that aggregates summaries, detects conflicts, and streams output in `internal/remove/preview.go`.
- [ ] T012 [US1] Implement apply pipeline executing planned operations without ledger writes in `internal/remove/apply.go`.
- [ ] T013 [US1] Wire new Cobra command in `cmd/remove.go` (with registration in `cmd/root.go`) to drive preview/apply using shared scope flags.
- [X] T010 [US1] Implement sequential removal engine producing planned operations in `internal/remove/engine.go`.
- [X] T011 [US1] Build preview pipeline that aggregates summaries, detects conflicts, and streams output in `internal/remove/preview.go`.
- [X] T012 [US1] Implement apply pipeline executing planned operations without ledger writes in `internal/remove/apply.go`.
- [X] T013 [US1] Wire new Cobra command in `cmd/remove.go` (with registration in `cmd/root.go`) to drive preview/apply using shared scope flags.
**Checkpoint**: User Story 1 functional end-to-end with preview/apply validated by automated tests.
@@ -58,13 +58,13 @@
### Tests for User Story 2 ⚠️
- [ ] T014 [P] [US2] Add contract test asserting ledger entries capture ordered tokens and match counts in `tests/contract/remove_command_ledger_test.go`.
- [ ] T015 [P] [US2] Add integration test covering `--yes` automation path and subsequent undo in `tests/integration/remove_undo_test.go`.
- [X] T014 [P] [US2] Add contract test asserting ledger entries capture ordered tokens and match counts in `tests/contract/remove_command_ledger_test.go`.
- [X] T015 [P] [US2] Add integration test covering `--yes` automation path and subsequent undo in `tests/integration/remove_undo_test.go`.
### Implementation for User Story 2
- [ ] T016 [US2] Extend apply pipeline to append ledger entries with ordered tokens and match counts in `internal/remove/apply.go`.
- [ ] T017 [US2] Update `cmd/remove.go` to support non-interactive `--yes` execution, emit automation-oriented messages, and propagate exit codes.
- [X] T016 [US2] Extend apply pipeline to append ledger entries with ordered tokens and match counts in `internal/remove/apply.go`.
- [X] T017 [US2] Update `cmd/remove.go` to support non-interactive `--yes` execution, emit automation-oriented messages, and propagate exit codes.
**Checkpoint**: User Story 2 complete—CLI safe for scripting with ledger + undo parity.
@@ -78,14 +78,14 @@
### Tests for User Story 3 ⚠️
- [ ] T018 [P] [US3] Add parser validation tests for duplicate tokens and whitespace edge cases in `tests/unit/remove_parser_test.go`.
- [ ] T019 [P] [US3] Add integration test verifying empty-basename warnings and skips in `tests/integration/remove_validation_test.go`.
- [X] T018 [P] [US3] Add parser validation tests for duplicate tokens and whitespace edge cases in `tests/unit/remove_parser_test.go`.
- [X] T019 [P] [US3] Add integration test verifying empty-basename warnings and skips in `tests/integration/remove_validation_test.go`.
### Implementation for User Story 3
- [ ] T020 [US3] Implement duplicate token deduplication with ordered warning collection in `internal/remove/parser.go`.
- [ ] T021 [US3] Add empty-basename detection and warning tracking in `internal/remove/summary.go`.
- [ ] T022 [US3] Surface duplicate and empty warnings in CLI output handling within `cmd/remove.go`.
- [X] T020 [US3] Implement duplicate token deduplication with ordered warning collection in `internal/remove/parser.go`.
- [X] T021 [US3] Add empty-basename detection and warning tracking in `internal/remove/summary.go`.
- [X] T022 [US3] Surface duplicate and empty warnings in CLI output handling within `cmd/remove.go`.
**Checkpoint**: All user stories deliver value; validations prevent risky rename plans.
@@ -95,10 +95,10 @@
**Purpose**: Documentation, tooling, and quality improvements spanning all user stories.
- [ ] T023 [P] Update remove command documentation and sequential behavior guidance in `docs/cli-flags.md`.
- [ ] T024 Record release notes for remove command launch in `docs/CHANGELOG.md`.
- [ ] T025 [P] Finalize `scripts/smoke-test-remove.sh` with assertions and integrate into CI instructions.
- [ ] T026 Add remove command walkthrough to project onboarding materials in `AGENTS.md`.
- [X] T023 [P] Update remove command documentation and sequential behavior guidance in `docs/cli-flags.md`.
- [X] T024 Record release notes for remove command launch in `docs/CHANGELOG.md`.
- [X] T025 [P] Finalize `scripts/smoke-test-remove.sh` with assertions and integrate into CI instructions.
- [X] T026 Add remove command walkthrough to project onboarding materials in `AGENTS.md`.
---

View File

32
testdata/remove/basic/README.md vendored Normal file
View File

@@ -0,0 +1,32 @@
# Remove Command Scenario Fixtures
Use this directory to capture realistic file layouts for the `renamer remove` tests.
## Layout
```
testdata/remove/basic
├── project copy draft.txt
├── Project copy.txt
├── nested
│ ├── foo draft.txt
│ ├── foo draft draft.txt
│ └── hidden
│ └── .draft copy.md
├── collisions
│ ├── alpha draft.txt
│ └── alpha.txt
└── empty-basename
├── draft
└── draft.txt
```
## Scenarios Covered
- Sequential removals (`" copy"`, `" draft"`) collapsing multiple terms.
- Duplicate token handling in nested directories.
- Hidden file interactions requiring the `--hidden` flag.
- Name collisions after removal (should report conflict prior to apply).
- Entries that would become empty names, verifying they are skipped with warnings.
Adjust or extend files as additional edge cases are added to integration tests.

View File

View File

View File

View File

View File

View File

View File

View File

View File

@@ -0,0 +1,104 @@
package contract
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
renamercmd "github.com/rogeecn/renamer/cmd"
"github.com/rogeecn/renamer/internal/history"
)
func TestRemoveCommandLedgerMetadata(t *testing.T) {
tmp := t.TempDir()
createRemoveFile(t, filepath.Join(tmp, "report copy draft.txt"))
createRemoveFile(t, filepath.Join(tmp, "notes draft.txt"))
root := renamercmd.NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"remove", " copy", " draft", "--path", tmp, "--yes"})
if err := root.Execute(); err != nil {
t.Fatalf("remove command error: %v\noutput: %s", err, out.String())
}
ledgerPath := filepath.Join(tmp, ".renamer")
data, err := os.ReadFile(ledgerPath)
if err != nil {
t.Fatalf("read ledger: %v", err)
}
lines := bytes.Split(bytes.TrimSpace(data), []byte("\n"))
if len(lines) == 0 {
t.Fatalf("expected ledger entry written")
}
var entry history.Entry
if err := json.Unmarshal(lines[len(lines)-1], &entry); err != nil {
t.Fatalf("decode ledger entry: %v", err)
}
if entry.Command != "remove" {
t.Fatalf("expected remove command recorded, got %q", entry.Command)
}
tokensVal, ok := entry.Metadata["tokens"].([]any)
if !ok {
t.Fatalf("expected tokens metadata, got %#v", entry.Metadata)
}
tokens := make([]string, len(tokensVal))
for i, v := range tokensVal {
s, ok := v.(string)
if !ok {
t.Fatalf("token entry not string: %#v", v)
}
tokens[i] = s
}
if len(tokens) != 2 || tokens[0] != " copy" || tokens[1] != " draft" {
t.Fatalf("unexpected tokens metadata: %#v", tokens)
}
matchesVal, ok := entry.Metadata["matches"].(map[string]any)
if !ok {
t.Fatalf("expected matches metadata, got %#v", entry.Metadata)
}
if len(matchesVal) != 2 {
t.Fatalf("unexpected matches metadata: %#v", matchesVal)
}
if toFloat(matchesVal[" copy"]) != 1 || toFloat(matchesVal[" draft"]) != 2 {
t.Fatalf("unexpected match counts: %#v", matchesVal)
}
// Ensure undo restores originals for automation workflows.
undo := renamercmd.NewRootCommand()
undo.SetOut(&bytes.Buffer{})
undo.SetErr(&bytes.Buffer{})
undo.SetArgs([]string{"undo", "--path", tmp})
if err := undo.Execute(); err != nil {
t.Fatalf("undo command error: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "report copy draft.txt")); err != nil {
t.Fatalf("expected original restored after undo: %v", err)
}
}
func toFloat(value any) float64 {
switch v := value.(type) {
case float64:
return v
case float32:
return float64(v)
case int:
return float64(v)
case int64:
return float64(v)
default:
return -1
}
}

View File

@@ -0,0 +1,46 @@
package contract
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
renamercmd "github.com/rogeecn/renamer/cmd"
)
func TestRemoveCommandDryRunPreview(t *testing.T) {
tmp := t.TempDir()
createRemoveFile(t, filepath.Join(tmp, "report copy draft.txt"))
createRemoveFile(t, filepath.Join(tmp, "notes draft.txt"))
root := renamercmd.NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"remove", " copy", " draft", "--path", tmp, "--dry-run"})
if err := root.Execute(); err != nil {
t.Fatalf("remove command returned error: %v (output: %s)", err, out.String())
}
output := out.String()
if !strings.Contains(output, "report copy draft.txt -> report.txt") {
t.Fatalf("expected preview mapping in output, got: %s", output)
}
if !strings.Contains(output, "Preview complete") {
t.Fatalf("expected dry-run completion message, got: %s", output)
}
}
func createRemoveFile(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("write file %s: %v", path, err)
}
}

View File

@@ -0,0 +1,86 @@
package integration
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/rogeecn/renamer/internal/history"
"github.com/rogeecn/renamer/internal/remove"
)
func TestRemoveApplyAndUndo(t *testing.T) {
tmp := t.TempDir()
createFile(t, filepath.Join(tmp, "report copy draft.txt"))
createFile(t, filepath.Join(tmp, "nested", "notes draft.txt"))
parsed, err := remove.ParseArgs([]string{" copy", " draft"})
if err != nil {
t.Fatalf("ParseArgs error: %v", err)
}
req := &remove.Request{
WorkingDir: tmp,
Tokens: parsed.Tokens,
Recursive: true,
}
if err := req.Validate(); err != nil {
t.Fatalf("request validation error: %v", err)
}
summary, planned, err := remove.Preview(context.Background(), req, parsed, nil)
if err != nil {
t.Fatalf("Preview error: %v", err)
}
if summary.ChangedCount != 2 {
t.Fatalf("expected 2 changes, got %d", summary.ChangedCount)
}
entry, err := remove.Apply(context.Background(), req, planned, summary, parsed.Tokens)
if err != nil {
t.Fatalf("Apply error: %v", err)
}
if len(entry.Operations) != 2 {
t.Fatalf("expected 2 operations recorded, got %d", len(entry.Operations))
}
if entry.Metadata == nil {
t.Fatalf("expected metadata to be recorded")
}
tokens, ok := entry.Metadata["tokens"].([]string)
if !ok {
t.Fatalf("expected ordered tokens metadata, got %#v", entry.Metadata)
}
if len(tokens) != 2 || tokens[0] != " copy" || tokens[1] != " draft" {
t.Fatalf("unexpected tokens metadata: %#v", tokens)
}
matches, ok := entry.Metadata["matches"].(map[string]int)
if !ok {
t.Fatalf("expected matches metadata, got %#v", entry.Metadata)
}
if matches[" copy"] != 1 || matches[" draft"] != 2 {
t.Fatalf("unexpected match counts: %#v", matches)
}
if _, err := os.Stat(filepath.Join(tmp, "report.txt")); err != nil {
t.Fatalf("expected renamed file exists: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "nested", "notes.txt")); err != nil {
t.Fatalf("expected nested rename exists: %v", err)
}
if _, err := history.Undo(tmp); err != nil {
t.Fatalf("undo error: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "report copy draft.txt")); err != nil {
t.Fatalf("expected original restored: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "nested", "notes draft.txt")); err != nil {
t.Fatalf("expected nested original restored: %v", err)
}
}

View File

@@ -0,0 +1,57 @@
package integration
import (
"bytes"
"os"
"path/filepath"
"testing"
renamercmd "github.com/rogeecn/renamer/cmd"
)
func TestRemoveCommandAutomationUndo(t *testing.T) {
tmp := t.TempDir()
createFile(t, filepath.Join(tmp, "alpha copy.txt"))
createFile(t, filepath.Join(tmp, "nested", "beta draft.txt"))
preview := renamercmd.NewRootCommand()
var previewOut bytes.Buffer
preview.SetOut(&previewOut)
preview.SetErr(&previewOut)
preview.SetArgs([]string{"remove", " copy", " draft", "--path", tmp, "--recursive", "--dry-run"})
if err := preview.Execute(); err != nil {
t.Fatalf("preview failed: %v\noutput: %s", err, previewOut.String())
}
apply := renamercmd.NewRootCommand()
var applyOut bytes.Buffer
apply.SetOut(&applyOut)
apply.SetErr(&applyOut)
apply.SetArgs([]string{"remove", " copy", " draft", "--path", tmp, "--recursive", "--yes"})
if err := apply.Execute(); err != nil {
t.Fatalf("apply failed: %v\noutput: %s", err, applyOut.String())
}
if !fileExists(filepath.Join(tmp, "alpha.txt")) || !fileExists(filepath.Join(tmp, "nested", "beta.txt")) {
t.Fatalf("expected files renamed after apply")
}
undo := renamercmd.NewRootCommand()
var undoOut bytes.Buffer
undo.SetOut(&undoOut)
undo.SetErr(&undoOut)
undo.SetArgs([]string{"undo", "--path", tmp})
if err := undo.Execute(); err != nil {
t.Fatalf("undo failed: %v\noutput: %s", err, undoOut.String())
}
if !fileExists(filepath.Join(tmp, "alpha copy.txt")) || !fileExists(filepath.Join(tmp, "nested", "beta draft.txt")) {
t.Fatalf("expected originals restored after undo")
}
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}

View File

@@ -0,0 +1,62 @@
package integration
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
renamercmd "github.com/rogeecn/renamer/cmd"
)
func TestRemoveCommandEmptyBasenameWarning(t *testing.T) {
tmp := t.TempDir()
createValidationFile(t, filepath.Join(tmp, "draft"))
createValidationFile(t, filepath.Join(tmp, "draft copy.txt"))
root := renamercmd.NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"remove", "draft", "--path", tmp, "--dry-run"})
if err := root.Execute(); err != nil {
t.Fatalf("remove dry-run failed: %v\noutput: %s", err, out.String())
}
if !strings.Contains(out.String(), "Warning: draft would become empty; skipping") {
t.Fatalf("expected empty basename warning, got: %s", out.String())
}
}
func TestRemoveCommandDuplicateWarning(t *testing.T) {
tmp := t.TempDir()
createValidationFile(t, filepath.Join(tmp, "foo draft draft.txt"))
root := renamercmd.NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"remove", " draft", " draft", "--path", tmp, "--dry-run"})
if err := root.Execute(); err != nil {
t.Fatalf("remove dry-run failed: %v\noutput: %s", err, out.String())
}
if !strings.Contains(out.String(), "Warning: token \" draft\" provided multiple times") {
t.Fatalf("expected duplicate warning, got: %s", out.String())
}
}
func createValidationFile(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("write file %s: %v", path, err)
}
}

View File

@@ -0,0 +1,74 @@
package replace_test
import (
"testing"
"github.com/rogeecn/renamer/internal/remove"
)
func TestApplyTokensSequentialRemoval(t *testing.T) {
candidate := remove.Candidate{
BaseName: "report copy draft.txt",
RelativePath: "report copy draft.txt",
}
result := remove.ApplyTokens(candidate, []string{" copy", " draft"})
if !result.Changed {
t.Fatalf("expected result to be marked as changed")
}
if result.ProposedName != "report.txt" {
t.Fatalf("expected proposed name to be report.txt, got %q", result.ProposedName)
}
if result.Matches[" copy"] != 1 {
t.Fatalf("expected match count for ' copy' to be 1, got %d", result.Matches[" copy"])
}
if result.Matches[" draft"] != 1 {
t.Fatalf("expected match count for ' draft' to be 1, got %d", result.Matches[" draft"])
}
}
func TestApplyTokensNoChange(t *testing.T) {
candidate := remove.Candidate{
BaseName: "notes.txt",
RelativePath: "notes.txt",
}
result := remove.ApplyTokens(candidate, []string{" copy"})
if result.Changed {
t.Fatalf("expected no change for candidate without matches")
}
if len(result.Matches) != 0 {
t.Fatalf("expected no matches to be recorded, got %#v", result.Matches)
}
if result.ProposedName != candidate.BaseName {
t.Fatalf("expected proposed name to remain %q, got %q", candidate.BaseName, result.ProposedName)
}
}
func TestApplyTokensEmptyName(t *testing.T) {
candidate := remove.Candidate{
BaseName: "draft",
RelativePath: "draft",
}
result := remove.ApplyTokens(candidate, []string{"draft"})
if !result.Changed {
t.Fatalf("expected change when removing the full name")
}
if result.ProposedName != "" {
t.Fatalf("expected proposed name to be empty, got %q", result.ProposedName)
}
if result.Matches["draft"] != 1 {
t.Fatalf("expected matches to record removal, got %#v", result.Matches)
}
}

View File

@@ -0,0 +1,46 @@
package replace_test
import (
"testing"
"github.com/rogeecn/renamer/internal/remove"
)
func TestParseArgsDeduplicatesPreservingOrder(t *testing.T) {
args := []string{" draft", " draft", " copy", " draft"}
result, err := remove.ParseArgs(args)
if err != nil {
t.Fatalf("ParseArgs returned error: %v", err)
}
expected := []string{" draft", " copy"}
if len(result.Tokens) != len(expected) {
t.Fatalf("expected %d tokens, got %d", len(expected), len(result.Tokens))
}
for i, token := range expected {
if result.Tokens[i] != token {
t.Fatalf("token[%d] mismatch: expected %q, got %q", i, token, result.Tokens[i])
}
}
if len(result.Duplicates) != 2 {
t.Fatalf("expected 2 duplicates recorded, got %d", len(result.Duplicates))
}
if result.Duplicates[0] != " draft" || result.Duplicates[1] != " draft" {
t.Fatalf("unexpected duplicates: %#v", result.Duplicates)
}
}
func TestParseArgsSkipsWhitespaceOnlyTokens(t *testing.T) {
args := []string{" ", "\t", "foo"}
result, err := remove.ParseArgs(args)
if err != nil {
t.Fatalf("ParseArgs returned error: %v", err)
}
if len(result.Tokens) != 1 || result.Tokens[0] != "foo" {
t.Fatalf("expected single token 'foo', got %#v", result.Tokens)
}
}