Add extension normalization command

This commit is contained in:
Rogee
2025-10-30 10:31:53 +08:00
parent f66c59fd57
commit 6a353b5086
35 changed files with 2306 additions and 2 deletions

View File

@@ -6,6 +6,8 @@ Auto-generated from all feature plans. Last updated: 2025-10-29
- Local filesystem (no persistent database) (002-add-replace-command)
- Go 1.24 + `spf13/cobra`, `spf13/pflag` (001-list-command-filters)
- Local filesystem only (ledger persisted as `.renamer`) (003-add-remove-command)
- Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/ledger packages (004-extension-rename)
- Local filesystem + `.renamer` ledger files (004-extension-rename)
## Project Structure
@@ -38,9 +40,9 @@ tests/
- Smoke: `scripts/smoke-test-replace.sh`, `scripts/smoke-test-remove.sh`
## Recent Changes
- 004-extension-rename: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/ledger packages
- 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.
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

92
cmd/extension.go Normal file
View File

@@ -0,0 +1,92 @@
package cmd
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"github.com/rogeecn/renamer/internal/extension"
"github.com/rogeecn/renamer/internal/listing"
)
// NewExtensionCommand constructs the extension CLI command; exported for testing.
func NewExtensionCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "extension <source-ext...> <target-ext>",
Short: "Normalize multiple file extensions to a single target extension",
Args: cobra.MinimumNArgs(2),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
scope, err := listing.ScopeFromCmd(cmd)
if err != nil {
return err
}
req := extension.NewRequest(scope)
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")
}
req.SetExecutionMode(dryRun, autoApply)
parsed, err := extension.ParseArgs(args)
if err != nil {
return fmt.Errorf("invalid extension arguments: %w", err)
}
req.SetExtensions(parsed.SourcesCanonical, parsed.SourcesDisplay, parsed.Target)
req.SetWarnings(parsed.Duplicates, parsed.NoOps)
summary, planned, err := extension.Preview(cmd.Context(), req, cmd.OutOrStdout())
if err != nil {
return err
}
if summary.HasConflicts() {
return errors.New("conflicts detected; resolve them before applying")
}
if dryRun || !autoApply {
if !autoApply {
fmt.Fprintln(cmd.OutOrStdout(), "Preview complete. Re-run with --yes to apply.")
}
return nil
}
if len(planned) == 0 {
if summary.TotalCandidates == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No candidates found.")
} else {
fmt.Fprintln(cmd.OutOrStdout(), "Nothing to apply; extensions already normalized.")
}
return nil
}
entry, err := extension.Apply(cmd.Context(), req, planned, summary)
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Applied %d extension updates. Ledger updated.\n", len(entry.Operations))
return nil
},
}
cmd.Example = ` renamer extension .jpeg .JPG .jpg --dry-run
renamer extension .yaml .yml .yml --yes --recursive`
return cmd
}
func init() {
rootCmd.AddCommand(NewExtensionCommand())
}

View File

@@ -47,6 +47,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(newListCommand())
cmd.AddCommand(NewReplaceCommand())
cmd.AddCommand(NewRemoveCommand())
cmd.AddCommand(NewExtensionCommand())
cmd.AddCommand(newUndoCommand())
return cmd

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
@@ -25,7 +26,18 @@ func newUndoCommand() *cobra.Command {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Undo applied: %d operations reversed\n", len(entry.Operations))
out := cmd.OutOrStdout()
fmt.Fprintf(out, "Undo applied: %d operations reversed\n", len(entry.Operations))
if entry.Command == "extension" && entry.Metadata != nil {
if target, ok := entry.Metadata["targetExtension"].(string); ok && target != "" {
fmt.Fprintf(out, "Restored extensions to %s\n", target)
}
if sources, ok := entry.Metadata["sourceExtensions"].([]string); ok && len(sources) > 0 {
fmt.Fprintf(out, "Previous sources: %s\n", strings.Join(sources, ", "))
}
}
return nil
},
}

View File

@@ -71,3 +71,28 @@ renamer remove <token1> [token2 ...] [flags]
- 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`
## Extension Command Quick Reference
```bash
renamer extension <source-ext...> <target-ext> [flags]
```
- Provide one or more dot-prefixed source extensions followed by the target extension. Validation
fails if any token omits the leading dot or repeats the target exactly.
- Source extensions are normalized case-insensitively; duplicates and no-op tokens are surfaced as
warnings in the preview rather than silently ignored.
- Preview output lists every candidate with `changed`, `no change`, or `skipped` status so scripts
can detect conflicts before applying. Conflicting targets block apply and exit with a non-zero
code.
- Scope flags (`--path`, `-r`, `-d`, `--hidden`, `--extensions`) determine which files and
directories participate. Hidden assets remain excluded unless `--hidden` is supplied.
- `--dry-run` (default) prints the plan without touching the filesystem. Re-run with `--yes` to
apply; attempting to combine both flags exits with an error. When no files match, the command
exits `0` after printing “No candidates found.”
### Usage Examples
- Preview normalization: `renamer extension .jpeg .JPG .jpg --dry-run`
- Apply case-folded extension updates: `renamer extension .yaml .yml .yml --yes --path ./configs`
- Include hidden assets recursively: `renamer extension .TMP .tmp --recursive --hidden`

109
internal/extension/apply.go Normal file
View File

@@ -0,0 +1,109 @@
package extension
import (
"context"
"errors"
"os"
"path/filepath"
"sort"
"github.com/rogeecn/renamer/internal/history"
)
// Apply executes planned renames and records the operations in the ledger.
func Apply(ctx context.Context, req *ExtensionRequest, planned []PlannedRename, summary *ExtensionSummary) (history.Entry, error) {
entry := history.Entry{Command: "extension"}
if len(planned) == 0 {
return entry, nil
}
sort.SliceStable(planned, func(i, j int) bool {
return planned[i].Depth > planned[j].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, filepath.FromSlash(op.To))
destination := filepath.Join(req.WorkingDir, filepath.FromSlash(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
}
if op.OriginalAbsolute == op.ProposedAbsolute {
continue
}
if err := os.Rename(op.OriginalAbsolute, op.ProposedAbsolute); err != nil {
_ = revert()
return history.Entry{}, err
}
done = append(done, history.Operation{
From: filepath.ToSlash(op.OriginalRelative),
To: filepath.ToSlash(op.ProposedRelative),
})
}
if len(done) == 0 {
return entry, nil
}
entry.Operations = done
if summary != nil {
meta := make(map[string]any)
if len(req.DisplaySourceExtensions) > 0 {
meta["sourceExtensions"] = append([]string(nil), req.DisplaySourceExtensions...)
}
if req.TargetExtension != "" {
meta["targetExtension"] = req.TargetExtension
}
meta["totalCandidates"] = summary.TotalCandidates
meta["totalChanged"] = summary.TotalChanged
meta["noChange"] = summary.NoChange
if len(summary.PerExtensionCounts) > 0 {
counts := make(map[string]int, len(summary.PerExtensionCounts))
for ext, count := range summary.PerExtensionCounts {
counts[ext] = count
}
meta["perExtensionCounts"] = counts
}
scope := map[string]any{
"includeDirs": req.IncludeDirs,
"recursive": req.Recursive,
"includeHidden": req.IncludeHidden,
}
if len(req.ExtensionFilter) > 0 {
scope["extensionFilter"] = append([]string(nil), req.ExtensionFilter...)
}
meta["scope"] = scope
if len(summary.Warnings) > 0 {
meta["warnings"] = append([]string(nil), summary.Warnings...)
}
entry.Metadata = meta
}
if err := history.Append(req.WorkingDir, entry); err != nil {
_ = revert()
return history.Entry{}, err
}
return entry, nil
}

View File

@@ -0,0 +1,58 @@
package extension
import (
"errors"
"fmt"
"os"
)
type conflictDetector struct {
planned map[string]string
}
func newConflictDetector() *conflictDetector {
return &conflictDetector{planned: make(map[string]string)}
}
// evaluateTarget inspects plan collisions and filesystem conflicts. It returns true when the
// rename can proceed, false when it must be skipped, propagating any hard errors encountered.
func (d *conflictDetector) evaluateTarget(summary *ExtensionSummary, candidateRel, targetRel, originalAbs, targetAbs string) (bool, error) {
if existing, ok := d.planned[targetRel]; ok && existing != candidateRel {
summary.AddConflict(Conflict{
OriginalPath: candidateRel,
ProposedPath: targetRel,
Reason: "duplicate_target",
})
summary.AddWarning(fmt.Sprintf("skipped %s because %s also maps to %s", candidateRel, existing, targetRel))
return false, nil
}
origInfo, origErr := os.Stat(originalAbs)
if origErr != nil {
return false, origErr
}
if info, err := os.Stat(targetAbs); err == nil {
if os.SameFile(info, origInfo) {
d.planned[targetRel] = candidateRel
return true, nil
}
reason := "existing_file"
if info.IsDir() {
reason = "existing_directory"
}
summary.AddConflict(Conflict{
OriginalPath: candidateRel,
ProposedPath: targetRel,
Reason: reason,
})
summary.AddWarning(fmt.Sprintf("skipped %s because %s already exists", candidateRel, targetRel))
return false, nil
} else if !errors.Is(err, os.ErrNotExist) {
return false, err
}
d.planned[targetRel] = candidateRel
return true, nil
}

View File

@@ -0,0 +1,3 @@
// Package extension provides the engine and utilities for normalizing file
// extensions using preview-first workflows shared across renamer commands.
package extension

View File

@@ -0,0 +1,164 @@
package extension
import (
"context"
"errors"
"io/fs"
"path/filepath"
"strings"
"github.com/rogeecn/renamer/internal/traversal"
)
// PlannedRename describes a filesystem rename operation produced during planning.
type PlannedRename struct {
OriginalRelative string
OriginalAbsolute string
ProposedRelative string
ProposedAbsolute string
SourceExtension string
IsDir bool
Depth int
}
// PlanResult captures the preview summary and concrete operations required for apply.
type PlanResult struct {
Summary *ExtensionSummary
Operations []PlannedRename
}
// BuildPlan walks the scoped filesystem, collecting preview entries and rename operations.
func BuildPlan(ctx context.Context, req *ExtensionRequest) (*PlanResult, error) {
if req == nil {
return nil, errors.New("extension request cannot be nil")
}
if err := req.Normalize(); err != nil {
return nil, err
}
summary := NewSummary()
operations := make([]PlannedRename, 0)
detector := newConflictDetector()
targetExt := NormalizeTargetExtension(req.TargetExtension)
targetCanonical := CanonicalExtension(targetExt)
sourceSet := make(map[string]struct{}, len(req.SourceExtensions))
for _, source := range req.SourceExtensions {
sourceSet[CanonicalExtension(source)] = struct{}{}
}
// Include the target extension so preview surfaces existing matches as no-ops.
matchable := make(map[string]struct{}, len(sourceSet)+1)
for ext := range sourceSet {
matchable[ext] = struct{}{}
}
matchable[targetCanonical] = struct{}{}
filterSet := make(map[string]struct{}, len(req.ExtensionFilter))
for _, filter := range req.ExtensionFilter {
filterSet[CanonicalExtension(filter)] = struct{}{}
}
walker := traversal.NewWalker()
err := walker.Walk(
req.WorkingDir,
req.Recursive,
req.IncludeDirs,
req.IncludeHidden,
0,
func(relPath string, entry fs.DirEntry, depth int) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if relPath == "." {
return nil
}
isDir := entry.IsDir()
if isDir && !req.IncludeDirs {
return nil
}
name := entry.Name()
rawExt := strings.TrimSpace(filepath.Ext(name))
canonicalExt := CanonicalExtension(rawExt)
if !isDir && len(filterSet) > 0 {
if _, ok := filterSet[canonicalExt]; !ok {
return nil
}
}
if _, ok := matchable[canonicalExt]; !ok {
return nil
}
relative := filepath.ToSlash(relPath)
originalAbsolute := filepath.Join(req.WorkingDir, filepath.FromSlash(relative))
status := PreviewStatusChanged
targetRelative := relative
targetAbsolute := originalAbsolute
if canonicalExt == targetCanonical && rawExt == targetExt {
status = PreviewStatusNoChange
}
if status == PreviewStatusChanged {
base := strings.TrimSuffix(name, rawExt)
targetName := base + targetExt
dir := filepath.Dir(relative)
if dir == "." {
dir = ""
}
if dir == "" {
targetRelative = filepath.ToSlash(targetName)
} else {
targetRelative = filepath.ToSlash(filepath.Join(dir, targetName))
}
targetAbsolute = filepath.Join(req.WorkingDir, filepath.FromSlash(targetRelative))
allowed, err := detector.evaluateTarget(summary, relative, targetRelative, originalAbsolute, targetAbsolute)
if err != nil {
return err
}
if allowed {
operations = append(operations, PlannedRename{
OriginalRelative: relative,
OriginalAbsolute: originalAbsolute,
ProposedRelative: targetRelative,
ProposedAbsolute: targetAbsolute,
SourceExtension: rawExt,
IsDir: isDir,
Depth: depth,
})
} else {
status = PreviewStatusSkipped
}
}
entrySummary := PreviewEntry{
OriginalPath: relative,
ProposedPath: targetRelative,
Status: status,
SourceExtension: rawExt,
}
summary.RecordEntry(entrySummary)
return nil
},
)
if err != nil {
return nil, err
}
return &PlanResult{
Summary: summary,
Operations: operations,
}, nil
}

View File

@@ -0,0 +1,59 @@
package extension
import "strings"
// NormalizeSourceExtensions returns case-insensitive unique source extensions while preserving
// the first-seen display token for each canonical value. Duplicate entries are surfaced for warnings.
func NormalizeSourceExtensions(inputs []string) (canonical []string, display []string, duplicates []string) {
seen := make(map[string]struct{})
for _, raw := range inputs {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
continue
}
canon := CanonicalExtension(trimmed)
if _, exists := seen[canon]; exists {
duplicates = append(duplicates, trimmed)
continue
}
seen[canon] = struct{}{}
canonical = append(canonical, canon)
display = append(display, trimmed)
}
return canonical, display, duplicates
}
// NormalizeTargetExtension trims surrounding whitespace but preserves caller-provided casing.
func NormalizeTargetExtension(target string) string {
return strings.TrimSpace(target)
}
// CanonicalExtension is the shared case-folded representation used for comparisons and maps.
func CanonicalExtension(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
// ExtensionsEqual reports true when two extensions match case-insensitively.
func ExtensionsEqual(a, b string) bool {
return CanonicalExtension(a) == CanonicalExtension(b)
}
// IdentifyNoOpSources returns the original tokens that would be no-ops against the target extension.
func IdentifyNoOpSources(original []string, target string) []string {
if len(original) == 0 {
return nil
}
canonTarget := CanonicalExtension(target)
var noOps []string
for _, token := range original {
if CanonicalExtension(token) == canonTarget {
noOps = append(noOps, token)
}
}
return noOps
}

View File

@@ -0,0 +1,77 @@
package extension
import (
"errors"
"fmt"
"strings"
)
// ParseResult captures normalized CLI arguments for the extension command.
type ParseResult struct {
SourcesCanonical []string
SourcesDisplay []string
Duplicates []string
NoOps []string
Target string
}
// ParseArgs validates extension command arguments and returns normalized tokens.
func ParseArgs(args []string) (ParseResult, error) {
if len(args) < 2 {
return ParseResult{}, errors.New("at least one source extension and a target extension are required")
}
target := NormalizeTargetExtension(args[len(args)-1])
if target == "" {
return ParseResult{}, errors.New("target extension cannot be empty")
}
if !strings.HasPrefix(target, ".") {
return ParseResult{}, fmt.Errorf("target extension %q must start with '.'", target)
}
if len(target) == 1 {
return ParseResult{}, fmt.Errorf("target extension %q must include characters after '.'", target)
}
rawSources := args[:len(args)-1]
trimmedSources := make([]string, len(rawSources))
for i, src := range rawSources {
trimmed := strings.TrimSpace(src)
if trimmed == "" {
return ParseResult{}, errors.New("source extensions cannot be empty")
}
if !strings.HasPrefix(trimmed, ".") {
return ParseResult{}, fmt.Errorf("source extension %q must start with '.'", trimmed)
}
if len(trimmed) == 1 {
return ParseResult{}, fmt.Errorf("source extension %q must include characters after '.'", trimmed)
}
trimmedSources[i] = trimmed
}
canonical, display, duplicates := NormalizeSourceExtensions(trimmedSources)
targetCanonical := CanonicalExtension(target)
filteredCanonical := make([]string, 0, len(canonical))
filteredDisplay := make([]string, 0, len(display))
noOps := make([]string, 0)
for i, canon := range canonical {
if canon == targetCanonical {
noOps = append(noOps, display[i])
continue
}
filteredCanonical = append(filteredCanonical, canon)
filteredDisplay = append(filteredDisplay, display[i])
}
if len(filteredCanonical) == 0 {
return ParseResult{}, errors.New("all source extensions match the target extension; provide at least one distinct source extension")
}
return ParseResult{
SourcesCanonical: filteredCanonical,
SourcesDisplay: filteredDisplay,
Duplicates: duplicates,
NoOps: noOps,
Target: target,
}, nil
}

View File

@@ -0,0 +1,92 @@
package extension
import (
"context"
"errors"
"fmt"
"io"
"sort"
"strings"
)
// Preview generates a summary and planned operations for an extension normalization run.
func Preview(ctx context.Context, req *ExtensionRequest, out io.Writer) (*ExtensionSummary, []PlannedRename, error) {
if req == nil {
return nil, nil, errors.New("extension request cannot be nil")
}
plan, err := BuildPlan(ctx, req)
if err != nil {
return nil, nil, err
}
summary := plan.Summary
for _, dup := range req.DuplicateSources {
summary.AddWarning(fmt.Sprintf("duplicate source extension ignored: %s", dup))
}
for _, noop := range req.NoOpSources {
summary.AddWarning(fmt.Sprintf("source extension matches target and is skipped: %s", noop))
}
if summary.TotalCandidates == 0 {
summary.AddWarning("no candidates found for provided extensions")
}
if out != nil {
// Ensure deterministic ordering for preview output.
sort.SliceStable(summary.Entries, func(i, j int) bool {
if summary.Entries[i].OriginalPath == summary.Entries[j].OriginalPath {
return summary.Entries[i].ProposedPath < summary.Entries[j].ProposedPath
}
return summary.Entries[i].OriginalPath < summary.Entries[j].OriginalPath
})
conflictReasons := make(map[string]string, len(summary.Conflicts))
for _, conflict := range summary.Conflicts {
key := fmt.Sprintf("%s->%s", conflict.OriginalPath, conflict.ProposedPath)
conflictReasons[key] = conflict.Reason
}
for _, entry := range summary.Entries {
switch entry.Status {
case PreviewStatusChanged:
if entry.ProposedPath == entry.OriginalPath {
fmt.Fprintf(out, "%s (pending extension update)\n", entry.OriginalPath)
} else {
fmt.Fprintf(out, "%s -> %s\n", entry.OriginalPath, entry.ProposedPath)
}
case PreviewStatusNoChange:
fmt.Fprintf(out, "%s (no change)\n", entry.OriginalPath)
case PreviewStatusSkipped:
reason := conflictReasons[fmt.Sprintf("%s->%s", entry.OriginalPath, entry.ProposedPath)]
if reason == "" {
reason = "skipped"
}
fmt.Fprintf(out, "%s -> %s (skipped: %s)\n", entry.OriginalPath, entry.ProposedPath, reason)
default:
fmt.Fprintf(out, "%s (status: %s)\n", entry.OriginalPath, entry.Status)
}
}
if summary.TotalCandidates > 0 {
fmt.Fprintf(out, "\nSummary: %d candidates, %d will change, %d already target extension\n",
summary.TotalCandidates, summary.TotalChanged, summary.NoChange)
} else {
fmt.Fprintln(out, "No candidates found.")
}
if len(summary.Warnings) > 0 {
fmt.Fprintln(out)
for _, warning := range summary.Warnings {
if !strings.HasPrefix(strings.ToLower(warning), "warning:") {
fmt.Fprintf(out, "Warning: %s\n", warning)
} else {
fmt.Fprintln(out, warning)
}
}
}
}
return summary, plan.Operations, nil
}

View File

@@ -0,0 +1,100 @@
package extension
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/rogeecn/renamer/internal/listing"
)
// ExtensionRequest captures all inputs required to evaluate an extension normalization run.
type ExtensionRequest struct {
WorkingDir string
SourceExtensions []string
DisplaySourceExtensions []string
TargetExtension string
DuplicateSources []string
NoOpSources []string
IncludeDirs bool
Recursive bool
IncludeHidden bool
ExtensionFilter []string
DryRun bool
AutoConfirm bool
Timestamp time.Time
}
// NewRequest seeds an ExtensionRequest using the shared listing scope settings.
func NewRequest(scope *listing.ListingRequest) *ExtensionRequest {
if scope == nil {
return &ExtensionRequest{}
}
filterCopy := append([]string(nil), scope.Extensions...)
return &ExtensionRequest{
WorkingDir: scope.WorkingDir,
IncludeDirs: scope.IncludeDirectories,
Recursive: scope.Recursive,
IncludeHidden: scope.IncludeHidden,
ExtensionFilter: filterCopy,
}
}
// SetExecutionMode records dry-run/auto-apply preferences inherited from CLI flags.
func (r *ExtensionRequest) SetExecutionMode(dryRun, autoConfirm bool) {
r.DryRun = dryRun
r.AutoConfirm = autoConfirm
}
// SetExtensions stores source and target extensions before normalization.
func (r *ExtensionRequest) SetExtensions(canonical []string, display []string, target string) {
r.SourceExtensions = append(r.SourceExtensions[:0], canonical...)
r.DisplaySourceExtensions = append(r.DisplaySourceExtensions[:0], display...)
r.TargetExtension = target
}
// SetWarnings captures duplicate or no-op tokens for later surfacing in preview output.
func (r *ExtensionRequest) SetWarnings(dupes, noOps []string) {
r.DuplicateSources = append(r.DuplicateSources[:0], dupes...)
r.NoOpSources = append(r.NoOpSources[:0], noOps...)
}
// Normalize ensures working directory and timestamp fields are ready for execution.
func (r *ExtensionRequest) Normalize() error {
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)
}
if r.Timestamp.IsZero() {
r.Timestamp = time.Now().UTC()
}
return nil
}

View File

@@ -0,0 +1,92 @@
package extension
import (
"strings"
)
// PreviewStatus represents the outcome for a single preview entry.
type PreviewStatus string
const (
PreviewStatusChanged PreviewStatus = "changed"
PreviewStatusNoChange PreviewStatus = "no_change"
PreviewStatusSkipped PreviewStatus = "skipped"
)
// PreviewEntry captures a single original → proposed path mapping for preview output.
type PreviewEntry struct {
OriginalPath string
ProposedPath string
Status PreviewStatus
SourceExtension string
}
// Conflict describes a proposed rename that cannot be applied safely.
type Conflict struct {
OriginalPath string
ProposedPath string
Reason string
}
// ExtensionSummary aggregates counts, conflicts, warnings, and ledger metadata.
type ExtensionSummary struct {
TotalCandidates int
TotalChanged int
NoChange int
PerExtensionCounts map[string]int
Conflicts []Conflict
Warnings []string
Entries []PreviewEntry
LedgerMetadata map[string]any
}
// NewSummary constructs an empty ExtensionSummary with initialized maps.
func NewSummary() *ExtensionSummary {
return &ExtensionSummary{
PerExtensionCounts: make(map[string]int),
LedgerMetadata: make(map[string]any),
}
}
// RecordEntry appends a preview entry and updates aggregate counters.
func (s *ExtensionSummary) RecordEntry(entry PreviewEntry) {
s.Entries = append(s.Entries, entry)
s.TotalCandidates++
switch entry.Status {
case PreviewStatusChanged:
s.TotalChanged++
case PreviewStatusNoChange:
s.NoChange++
}
if entry.SourceExtension != "" {
key := strings.ToLower(entry.SourceExtension)
s.PerExtensionCounts[key]++
}
}
// AddConflict registers a new conflict encountered during planning.
func (s *ExtensionSummary) AddConflict(conflict Conflict) {
s.Conflicts = append(s.Conflicts, conflict)
}
// AddWarning ensures warning messages are collected without duplication.
func (s *ExtensionSummary) AddWarning(message string) {
if message == "" {
return
}
for _, existing := range s.Warnings {
if existing == message {
return
}
}
s.Warnings = append(s.Warnings, message)
}
// HasConflicts reports whether any blocking conflicts were recorded.
func (s *ExtensionSummary) HasConflicts() bool {
return len(s.Conflicts) > 0
}

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN="go run"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
touch "$TMP_DIR/photo.jpeg"
touch "$TMP_DIR/poster.JPG"
touch "$TMP_DIR/logo.jpg"
echo "Previewing extension normalization..."
$BIN "$ROOT_DIR/main.go" extension .jpeg .JPG .jpg --path "$TMP_DIR" --dry-run >/dev/null
echo "Applying extension normalization..."
$BIN "$ROOT_DIR/main.go" extension .jpeg .JPG .jpg --path "$TMP_DIR" --yes >/dev/null
if [[ ! -f "$TMP_DIR/photo.jpg" ]]; then
echo "expected photo.jpg to exist" >&2
exit 1
fi
if [[ ! -f "$TMP_DIR/poster.jpg" ]]; then
echo "expected poster.jpg to exist" >&2
exit 1
fi
echo "Undoing extension normalization..."
$BIN "$ROOT_DIR/main.go" undo --path "$TMP_DIR" >/dev/null
if [[ ! -f "$TMP_DIR/photo.jpeg" ]]; then
echo "undo failed to restore photo.jpeg" >&2
exit 1
fi
if [[ ! -f "$TMP_DIR/poster.JPG" ]]; then
echo "undo failed to restore poster.JPG" >&2
exit 1
fi
echo "Extension smoke test succeeded."

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Extension Command for Multi-Extension Normalization
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2025-10-29
**Feature**: [spec.md](../spec.md)
## Content Quality
- [X] No implementation details (languages, frameworks, APIs)
- [X] Focused on user value and business needs
- [X] Written for non-technical stakeholders
- [X] All mandatory sections completed
## Requirement Completeness
- [X] No [NEEDS CLARIFICATION] markers remain
- [X] Requirements are testable and unambiguous
- [X] Success criteria are measurable
- [X] Success criteria are technology-agnostic (no implementation details)
- [X] All acceptance scenarios are defined
- [X] Edge cases are identified
- [X] Scope is clearly bounded
- [X] Dependencies and assumptions identified
## Feature Readiness
- [X] All functional requirements have clear acceptance criteria
- [X] User scenarios cover primary flows
- [X] Feature meets measurable outcomes defined in Success Criteria
- [X] No implementation details leak into specification
## Notes
- Specification validated on 2025-10-29.

View File

@@ -0,0 +1,248 @@
openapi: 3.1.0
info:
title: Renamer Extension Command API
version: 0.1.0
description: >
Contract representation of the `renamer extension` command workflows (preview/apply/undo)
for testing harnesses and documentation parity.
servers:
- url: cli://renamer
description: Command-line invocation surface
paths:
/extension/preview:
post:
summary: Preview extension normalization results
description: Mirrors `renamer extension [source-ext...] [target-ext] --dry-run`
operationId: previewExtension
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ExtensionRequest'
responses:
'200':
description: Successful preview
content:
application/json:
schema:
$ref: '#/components/schemas/ExtensionPreview'
'400':
description: Validation error (invalid extensions, missing arguments)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/extension/apply:
post:
summary: Apply extension normalization
description: Mirrors `renamer extension [source-ext...] [target-ext] --yes`
operationId: applyExtension
requestBody:
required: true
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ExtensionRequest'
- type: object
properties:
dryRun:
type: boolean
const: false
responses:
'200':
description: Successful apply (may include no-change entries)
content:
application/json:
schema:
$ref: '#/components/schemas/ExtensionApplyResult'
'409':
description: Conflict detected; apply refused
content:
application/json:
schema:
$ref: '#/components/schemas/ConflictResponse'
'400':
description: Validation error (invalid extensions, missing arguments)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/extension/undo:
post:
summary: Undo the most recent extension batch
description: Mirrors `renamer undo` when last ledger entry was from extension command.
operationId: undoExtension
responses:
'200':
description: Undo succeeded and ledger updated
content:
application/json:
schema:
$ref: '#/components/schemas/UndoResult'
'409':
description: Ledger inconsistent or missing entry
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
schemas:
ExtensionRequest:
type: object
required:
- workingDir
- sourceExtensions
- targetExtension
properties:
workingDir:
type: string
description: Absolute path for command scope.
sourceExtensions:
type: array
description: Ordered list of dot-prefixed extensions to normalize (case-insensitive uniqueness).
minItems: 1
items:
type: string
pattern: '^\\.[\\w\\-]+$'
targetExtension:
type: string
description: Dot-prefixed extension applied to all matches.
pattern: '^\\.[\\w\\-]+$'
includeDirs:
type: boolean
default: false
recursive:
type: boolean
default: false
includeHidden:
type: boolean
default: false
extensionFilter:
type: array
items:
type: string
pattern: '^\\.[\\w\\-]+$'
dryRun:
type: boolean
default: true
autoConfirm:
type: boolean
default: false
ExtensionPreview:
type: object
required:
- totalCandidates
- totalChanged
- noChange
- entries
properties:
totalCandidates:
type: integer
minimum: 0
totalChanged:
type: integer
minimum: 0
noChange:
type: integer
minimum: 0
conflicts:
type: array
items:
$ref: '#/components/schemas/Conflict'
warnings:
type: array
items:
type: string
entries:
type: array
items:
$ref: '#/components/schemas/PreviewEntry'
ExtensionApplyResult:
type: object
required:
- totalApplied
- noChange
- ledgerEntryId
properties:
totalApplied:
type: integer
minimum: 0
noChange:
type: integer
minimum: 0
ledgerEntryId:
type: string
warnings:
type: array
items:
type: string
UndoResult:
type: object
required:
- restored
- ledgerEntryId
properties:
restored:
type: integer
ledgerEntryId:
type: string
message:
type: string
Conflict:
type: object
required:
- originalPath
- proposedPath
- reason
properties:
originalPath:
type: string
proposedPath:
type: string
reason:
type: string
enum:
- duplicate_target
- existing_file
PreviewEntry:
type: object
required:
- originalPath
- proposedPath
- status
properties:
originalPath:
type: string
proposedPath:
type: string
status:
type: string
enum:
- changed
- no_change
- skipped
sourceExtension:
type: string
ErrorResponse:
type: object
required:
- error
properties:
error:
type: string
remediation:
type: string
ConflictResponse:
type: object
required:
- error
- conflicts
properties:
error:
type: string
conflicts:
type: array
items:
$ref: '#/components/schemas/Conflict'

View File

@@ -0,0 +1,66 @@
# Data Model Extension Command
## Entity: ExtensionRequest
- **Fields**
- `WorkingDir string` — Absolute path resolved from CLI `--path` or current directory.
- `SourceExtensions []string` — Ordered list of unique, dot-prefixed source extensions (case-insensitive comparisons).
- `TargetExtension string` — Dot-prefixed extension applied verbatim during rename.
- `IncludeDirs bool` — Mirrors `--include-dirs` scope flag.
- `Recursive bool` — Mirrors `--recursive` traversal flag.
- `IncludeHidden bool` — True only when `--hidden` supplied.
- `ExtensionFilter []string` — Normalized extension filter from `--extensions`, applied before source matching.
- `DryRun bool` — Indicates preview-only execution (`--dry-run`).
- `AutoConfirm bool` — Captures `--yes` for non-interactive apply.
- `Timestamp time.Time` — Invocation timestamp for ledger correlation.
- **Validation Rules**
- Require at least one source extension and one target extension (total args ≥ 2).
- All extensions MUST start with `.` and contain ≥1 alphanumeric character after the dot.
- Deduplicate source extensions case-insensitively; warn on duplicates/no-ops.
- Target extension MUST NOT be empty and MUST include leading dot.
- Reject empty string tokens after trimming whitespace.
- **Relationships**
- Consumed by the extension rule engine to enumerate candidate files.
- Serialized into ledger metadata alongside `ExtensionSummary`.
## Entity: ExtensionSummary
- **Fields**
- `TotalCandidates int` — Number of filesystem entries examined post-scope filtering.
- `TotalChanged int` — Count of files scheduled for rename (target extension applied).
- `NoChange int` — Count of files already matching `TargetExtension`.
- `PerExtensionCounts map[string]int` — Matches per source extension (case-insensitive key).
- `Conflicts []Conflict` — Entries describing colliding target paths.
- `Warnings []string` — Validation and scope warnings (duplicates, no matches, hidden skipped).
- `PreviewEntries []PreviewEntry` — Ordered list of original/new paths with status `changed|no_change|skipped`.
- `LedgerMetadata map[string]any` — Snapshot persisted with ledger entry (sources, target, flags).
- **Validation Rules**
- Conflicts list MUST be empty before allowing apply.
- `NoChange + TotalChanged` MUST equal count of entries included in preview.
- Preview entries MUST be deterministic (stable sort by original path).
- **Relationships**
- Emitted to preview renderer for CLI output formatting.
- Persisted with ledger entry for undo operations and audits.
## Entity: Conflict
- **Fields**
- `OriginalPath string` — Existing file path causing the collision.
- `ProposedPath string` — Target path that clashes with another candidate.
- `Reason string` — Short code (e.g., `duplicate_target`, `existing_file`) describing conflict type.
- **Validation Rules**
- `ProposedPath` MUST be unique across non-conflicting entries.
- Reasons limited to known enum for consistent messaging.
- **Relationships**
- Referenced within `ExtensionSummary.Conflicts`.
- Propagated to CLI preview warnings.
## Entity: PreviewEntry
- **Fields**
- `OriginalPath string`
- `ProposedPath string`
- `Status string``changed`, `no_change`, or `skipped`.
- `SourceExtension string` — Detected source extension (normalized lowercase).
- **Validation Rules**
- `Status` MUST be one of the defined constants.
- `ProposedPath` MUST equal `OriginalPath` when `Status == "no_change"`.
- **Relationships**
- Aggregated into `ExtensionSummary.PreviewEntries`.
- Used by preview renderer and ledger writer.

View File

@@ -0,0 +1,108 @@
# Implementation Plan: Extension Command for Multi-Extension Normalization
**Branch**: `004-extension-rename` | **Date**: 2025-10-30 | **Spec**: `specs/004-extension-rename/spec.md`
**Input**: Feature specification from `/specs/004-extension-rename/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
## Summary
Deliver a `renamer extension` subcommand that normalizes one or more source extensions to a single target extension with deterministic previews, ledger-backed undo, and automation-friendly exit codes. The implementation will extend existing replace/remove infrastructure, ensuring case-insensitive extension matching, hidden-file opt-in, conflict detection, and “no change” handling for already-targeted files.
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: Go 1.24
**Primary Dependencies**: `spf13/cobra`, `spf13/pflag`, internal traversal/ledger packages
**Storage**: Local filesystem + `.renamer` ledger files
**Testing**: `go test ./...`, smoke + integration scripts under `tests/` and `scripts/`
**Target Platform**: Cross-platform CLI (Linux, macOS, Windows shells)
**Project Type**: Single CLI project (`cmd/`, `internal/`, `tests/`, `scripts/`)
**Performance Goals**: Normalize 500 files (preview+apply) in <2 minutes end-to-end
**Constraints**: Preview-first workflow, ledger append-only, hidden files excluded unless `--hidden`
**Scale/Scope**: Operates on thousands of filesystem entries per invocation within local directory trees
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Preview flow MUST show deterministic rename mappings and require explicit confirmation (Preview-First Safety). ✅ Extend existing preview engine to list original → target paths, highlighting extension changes and “no change” rows; apply remains gated by `--yes`.
- Undo strategy MUST describe how the `.renamer` ledger entry is written and reversed (Persistent Undo Ledger). ✅ Record source extension list, target, per-file outcomes in ledger entry and ensure undo replays entries via existing ledger service.
- Planned rename rules MUST document their inputs, validations, and composing order (Composable Rule Engine). ✅ Define an extension normalization rule that consumes scope matches, validates tokens, deduplicates case-insensitively, and reuses sequencing from replace engine.
- Scope handling MUST cover files vs directories (`-d`), recursion (`-r`), and extension filtering via `-e` without escaping the requested path (Scope-Aware Traversal). ✅ Continue relying on shared traversal component honoring flags; ensure hidden assets stay excluded unless `--hidden`.
- CLI UX plan MUST confirm Cobra usage, flag naming, help text, and automated tests for preview/undo flows (Ergonomic CLI Stewardship). ✅ Implement `extension` Cobra command mirroring existing flag sets, update help text, and add contract/integration tests for preview, apply, and undo.
*Post-Design Verification (2025-10-30): Research and design artifacts document preview flow, ledger metadata, rule composition, scope behavior, and Cobra UX updates—no gate violations detected.*
## Project Structure
### Documentation (this feature)
```text
specs/[###-feature]/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
cmd/
├── root.go
├── list.go
├── replace.go
├── remove.go
└── undo.go
internal/
├── filters/
├── history/
├── listing/
├── output/
├── remove/
├── replace/
├── traversal/
└── extension/ # new package for extension normalization engine
scripts/
├── smoke-test-remove.sh
└── smoke-test-replace.sh
tests/
├── contract/
│ ├── remove_command_ledger_test.go
│ ├── remove_command_preview_test.go
│ ├── replace_command_test.go
│ └── (new) extension_command_test.go
├── integration/
│ ├── remove_flow_test.go
│ ├── remove_undo_test.go
│ ├── remove_validation_test.go
│ └── replace_flow_test.go
└── fixtures/ # shared test inputs
```
**Structure Decision**: Maintain the single CLI project layout, adding an `internal/extension` package plus contract/integration tests mirroring existing replace/remove coverage.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| _None_ | — | — |

View File

@@ -0,0 +1,28 @@
# Quickstart Extension Normalization Command
1. **Preview extension changes.**
```bash
renamer extension .jpeg .JPG .jpg --dry-run
```
- Shows original → proposed paths.
- Highlights conflicts or “no change” rows for files already ending in `.jpg`.
2. **Include nested directories or hidden assets when needed.**
```bash
renamer extension .yaml .yml .yml --recursive --hidden --dry-run
```
- `--recursive` traverses subdirectories.
- `--hidden` opt-in keeps hidden files in scope.
3. **Apply changes after confirming preview.**
```bash
renamer extension .jpeg .JPG .jpg --yes
```
- `--yes` auto-confirms preview results.
- Command exits `0` even if no files matched (prints “no candidates found”).
4. **Undo the most recent batch if needed.**
```bash
renamer undo
```
- Restores original extensions using `.renamer` ledger entry.

View File

@@ -0,0 +1,17 @@
# Phase 0 Research Extension Command
## Decision: Reuse Replace Engine Structure for Extension Normalization
- **Rationale**: The replace command already supports preview/apply workflows, ledger logging, and shared traversal flags. By reusing its service abstractions (scope resolution → rule application → preview), we minimize new surface area while ensuring compliance with preview-first safety.
- **Alternatives considered**: Building a standalone engine dedicated to extensions was rejected because it would duplicate scope traversal, preview formatting, and ledger writing logic, increasing maintenance and divergence risk.
## Decision: Normalize Extensions Using Case-Insensitive Matching
- **Rationale**: Filesystems differ in case sensitivity; normalizing via `strings.EqualFold` (or equivalent) ensures consistent behavior regardless of platform, aligning with the specs clarification and reducing surprise for users migrating mixed-case assets.
- **Alternatives considered**: Relying on filesystem semantics was rejected because it would produce divergent behavior (e.g., Linux vs. macOS). Requiring exact-case matches was rejected for being unfriendly to legacy archives with mixed casing.
## Decision: Record Detailed Extension Metadata in Ledger Entries
- **Rationale**: Persisting the original extension list, target extension, and per-file before/after paths in ledger metadata keeps undo operations auditable and allows future analytics (e.g., reporting normalized counts). Existing ledger schema supports additional metadata fields without incompatible changes.
- **Alternatives considered**: Storing only file path mappings was rejected because it obscures which extensions were targeted, hindering debugging. Creating a new ledger file was rejected for complicating undo logic.
## Decision: Extend Cobra CLI with Positional Arguments for Extensions
- **Rationale**: Cobra natural handling of positional args enables `renamer extension [sources...] [target]`. Using argument parsing consistent with replace/remove reduces UX learning curve, and Cobras validation hooks simplify enforcing leading-dot requirements.
- **Alternatives considered**: Introducing new flags (e.g., `--sources`, `--target`) was rejected because it diverges from existing command patterns and complicates scripting. Using prompts was rejected due to automation needs.

View File

@@ -0,0 +1,111 @@
# Feature Specification: Extension Command for Multi-Extension Normalization
**Feature Branch**: `004-extension-rename`
**Created**: 2025-10-29
**Status**: Draft
**Input**: User description: "实现扩展名修改Extension命令类似于 replace 命令,可以支持把多个扩展名更改为一个指定的扩展名"
## Clarifications
### Session 2025-10-30
- Q: Should extension comparisons treat casing uniformly or follow the host filesystem? → A: Always case-insensitive
- Q: How should hidden files be handled when `--hidden` is omitted? → A: Exclude hidden entries
- Q: What exit behavior should occur when no files match the given extensions? → A: Exit 0 with notice
- Q: How should files that already have the target extension be represented? → A: Preview as no-change; skip on apply
## User Scenarios & Testing _(mandatory)_
### User Story 1 - Normalize Legacy Extensions in Bulk (Priority: P1)
As a power user cleaning project assets, I want a command to replace multiple file extensions with a single standardized extension so that I can align legacy files (e.g., `.jpeg`, `.JPG`) without hand-editing each one.
**Why this priority**: Delivers the core business value—fast extension normalization across large folders.
**Independent Test**: In a sample directory containing `.jpeg`, `.JPG`, and `.png`, run `renamer extension .jpeg .JPG .png .jpg --dry-run`, verify preview shows the new `.jpg` extension for each, then apply with `--yes` and confirm filesystem updates.
**Acceptance Scenarios**:
1. **Given** files with extensions `.jpeg` and `.JPG`, **When** the user runs `renamer extension .jpeg .JPG .jpg`, **Then** the preview lists each file with the `.jpg` extension and apply renames successfully.
2. **Given** nested directories, **When** the user adds `--recursive`, **Then** all matching extensions in subdirectories are normalized while unrelated files remain untouched.
---
### User Story 2 - Automation-Friendly Extension Updates (Priority: P2)
As an operator integrating renamer into CI scripts, I want deterministic exit codes, ledger entries, and undo support when changing extensions so automated jobs remain auditable and recoverable.
**Why this priority**: Ensures enterprise workflows can rely on extension updates without risking data loss.
**Independent Test**: Run `renamer extension .yaml .yml .yml --yes --path ./fixtures`, verify exit code `0`, inspect `.renamer` ledger for recorded operations, and confirm `renamer undo` restores originals.
**Acceptance Scenarios**:
1. **Given** a non-interactive environment with `--yes`, **When** the command completes without conflicts, **Then** exit code is `0` and ledger metadata captures original extension list and target extension.
2. **Given** a ledger entry exists, **When** `renamer undo` runs, **Then** all files revert to their prior extensions even if the command was executed by automation.
---
### User Story 3 - Validate Extension Inputs and Conflicts (Priority: P3)
As a user preparing an extension migration, I want validation and preview warnings for invalid tokens, duplicate target names, and no-op operations so I can adjust before committing changes.
**Why this priority**: Reduces support load from misconfigured commands and protects against accidental overwrites.
**Independent Test**: Run `renamer extension .mp3 .MP3 mp3 --dry-run`, confirm validation fails because tokens must include leading `.`, and run a scenario where resulting filenames collide to ensure conflicts abort the apply step.
**Acceptance Scenarios**:
1. **Given** invalid input (e.g., missing leading `.` or fewer than two arguments), **When** the command executes, **Then** it exits with non-zero status and prints actionable guidance.
2. **Given** two files that would become the same path after extension normalization, **When** the preview runs, **Then** conflicts are listed and the apply step refuses to proceed until resolved.
---
### Edge Cases
- How does the rename plan surface conflicts when multiple files map to the same normalized extension?
- When the target extension already matches some files, they appear in preview with a “no change” indicator and are skipped during apply without raising errors.
- Hidden files and directories remain excluded unless the user supplies `--hidden`.
- When no files match in preview or apply, the command must surface a “no candidates found” notice while completing successfully.
## Requirements _(mandatory)_
### Functional Requirements
- **FR-001**: CLI MUST provide a dedicated `extension` subcommand that accepts one or more source extensions and a single target extension as positional arguments.
- **FR-002**: Preview → confirm workflow MUST mirror existing commands: list original paths, proposed paths, and highlight extension changes before apply.
- **FR-003**: Executions MUST append ledger entries capturing original extension list, target extension, affected files, and timestamps to support undo.
- **FR-004**: Users MUST be able to undo the most recent extension batch via existing undo mechanics without leaving orphaned files.
- **FR-005**: Command MUST respect global scope flags (`--path`, `--recursive`, `--include-dirs`, `--hidden`, `--extensions`, `--dry-run`, `--yes`) consistent with `list` and `replace`, excluding hidden files and directories unless `--hidden` is explicitly supplied.
- **FR-006**: Extension parsing MUST require leading `.` tokens, deduplicate case-insensitively, warn when duplicates or no-op tokens are supplied, and compare file extensions case-insensitively across all platforms.
- **FR-007**: Preview MUST detect target conflicts (two files mapping to the same new path) and block apply until conflicts are resolved.
- **FR-008**: Invalid invocations (e.g., fewer than two arguments, empty tokens after trimming) MUST exit with non-zero status and provide remediation tips.
- **FR-009**: Help output MUST clearly explain argument order, sequential evaluation rules, and interaction with scope flags.
- **FR-010**: When no files match the provided extensions, preview and apply runs MUST emit a clear “no candidates found” message and exit with status `0`.
- **FR-011**: Preview MUST surface already-targeted files with a “no change” marker, and apply MUST skip them while returning success.
### Key Entities
- **ExtensionRequest**: Working directory, ordered source extension list, target extension, scope flags, dry-run/apply settings.
- **ExtensionSummary**: Totals for candidates, changed files, per-extension match counts, conflicts, and warning messages used for preview and ledger metadata.
## Success Criteria _(mandatory)_
### Measurable Outcomes
- **SC-001**: Users normalize 500 files extensions (preview + apply) in under 2 minutes end-to-end.
- **SC-002**: 95% of beta testers correctly supply arguments (source extensions + target) after reading `renamer extension --help` without additional guidance.
- **SC-003**: Automated regression tests confirm extension change + undo leave the filesystem unchanged in 100% of scripted scenarios.
- **SC-004**: Support tickets related to inconsistent file extensions drop by 30% within the first release cycle after launch.
## Assumptions
- Command name will be `renamer extension` to align with existing verb-noun conventions.
- Source extensions are literal matches with leading dots; wildcard or regex patterns remain out of scope.
- Target extension must include a leading dot and is applied case-sensitively as provided.
- Existing traversal, summary, and ledger infrastructure can be extended from the replace/remove commands.
## Dependencies & Risks
- Requires new extension-specific packages analogous to replace/remove for parser, engine, summary, and CLI wiring.
- Help/quickstart documentation must be updated to explain argument order and extension validation.
- Potential filename conflicts after normalization must be detected pre-apply to avoid overwriting files.

View File

@@ -0,0 +1,160 @@
# Tasks: Extension Command for Multi-Extension Normalization
**Input**: Design documents from `/specs/004-extension-rename/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
**Tests**: Contract and integration tests are included because the specification mandates deterministic previews, ledger-backed undo, and automation safety.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Establish initial package and CLI scaffolding.
- [X] T001 Create package doc stub for extension engine in `internal/extension/doc.go`
- [X] T002 Register placeholder Cobra subcommand in `cmd/extension.go` and wire it into `cmd/root.go`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core data structures and utilities required by all user stories.
- [X] T003 Define `ExtensionRequest` parsing helpers in `internal/extension/request.go`
- [X] T004 Define `ExtensionSummary`, preview statuses, and metadata container in `internal/extension/summary.go`
- [X] T005 Implement case-insensitive normalization and dedup helpers in `internal/extension/normalize.go`
**Checkpoint**: Foundation ready — user story implementation can now begin.
---
## Phase 3: User Story 1 Normalize Legacy Extensions in Bulk (Priority: P1) 🎯 MVP
**Goal**: Provide preview and apply flows that replace multiple source extensions with a single target extension across the scoped filesystem.
**Independent Test**: In a directory containing mixed `.jpeg`, `.JPG`, and `.png` files, run `renamer extension .jpeg .JPG .png .jpg --dry-run` to inspect preview output, then run with `--yes` to confirm filesystem updates.
### Tests for User Story 1
- [X] T010 [P] [US1] Add preview/apply contract coverage in `tests/contract/extension_command_test.go`
- [X] T011 [P] [US1] Add normalization happy-path integration flow in `tests/integration/extension_flow_test.go`
### Implementation for User Story 1
- [X] T006 [US1] Implement scoped candidate discovery and planning in `internal/extension/engine.go`
- [X] T007 [US1] Render deterministic preview entries with change/no-change markers in `internal/extension/preview.go`
- [X] T008 [US1] Apply planned renames with filesystem operations in `internal/extension/apply.go`
- [X] T009 [US1] Wire Cobra command to preview/apply pipeline with scope flags in `cmd/extension.go`
**Checkpoint**: User Story 1 functional and independently testable.
---
## Phase 4: User Story 2 Automation-Friendly Extension Updates (Priority: P2)
**Goal**: Ensure ledger entries, exit codes, and undo support enable scripted execution without manual intervention.
**Independent Test**: Execute `renamer extension .yaml .yml .yml --yes --path ./fixtures`, verify exit code `0`, inspect `.renamer` ledger for metadata, then run `renamer undo` to restore originals.
### Tests for User Story 2
- [X] T015 [P] [US2] Extend contract tests to verify ledger metadata and exit codes in `tests/contract/extension_ledger_test.go`
- [X] T016 [P] [US2] Add automation/undo integration scenario in `tests/integration/extension_undo_test.go`
### Implementation for User Story 2
- [X] T012 [US2] Persist extension-specific metadata during apply in `internal/extension/apply.go`
- [X] T013 [US2] Ensure undo and CLI output handle extension batches in `cmd/undo.go`
- [X] T014 [US2] Guarantee deterministic exit codes and non-match notices in `cmd/extension.go`
**Checkpoint**: User Stories 1 and 2 functional and independently testable.
---
## Phase 5: User Story 3 Validate Extension Inputs and Conflicts (Priority: P3)
**Goal**: Provide robust input validation and conflict detection to prevent unsafe applies.
**Independent Test**: Run `renamer extension .mp3 .MP3 mp3 --dry-run` to confirm validation failure for missing dot, and run a collision scenario to verify preview conflicts block apply.
### Tests for User Story 3
- [X] T020 [P] [US3] Add validation and conflict contract coverage in `tests/contract/extension_validation_test.go`
- [X] T021 [P] [US3] Add conflict-blocking integration scenario in `tests/integration/extension_validation_test.go`
### Implementation for User Story 3
- [X] T017 [US3] Implement CLI argument validation and error messaging in `internal/extension/parser.go`
- [X] T018 [US3] Detect conflicting target paths and accumulate preview warnings in `internal/extension/conflicts.go`
- [X] T019 [US3] Surface validation failures and conflict gating in `cmd/extension.go`
**Checkpoint**: All user stories functional with independent validation.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Documentation, tooling, and quality improvements spanning multiple stories.
- [X] T022 Update CLI flag documentation for extension command in `docs/cli-flags.md`
- [X] T023 Add smoke test script covering extension preview/apply/undo in `scripts/smoke-test-extension.sh`
- [X] T024 Run gofmt and `go test ./...` from repository root `./`
---
## Dependencies & Execution Order
### Phase Dependencies
1. **Setup (Phase 1)** → primes new package and CLI wiring.
2. **Foundational (Phase 2)** → must complete before any user story work.
3. **User Story Phases (35)** → execute in priority order (P1 → P2 → P3) once foundational tasks finish.
4. **Polish (Phase 6)** → after desired user stories are complete.
### User Story Dependencies
- **US1 (P1)** → depends on Phase 2 completion; delivers MVP.
- **US2 (P2)** → depends on US1 groundwork (ledger metadata builds atop apply logic).
- **US3 (P3)** → depends on US1 preview/apply pipeline; validation hooks extend existing command.
### Task Dependencies (Selected)
- T006 depends on T003T005.
- T007, T008 depend on T006.
- T009 depends on T006T008.
- T012 depends on T008.
- T013, T014 depend on T012.
- T017 depends on T003, T005.
- T018 depends on T006T007.
- T019 depends on T017T018.
---
## Parallel Execution Examples
- **Within US1**: After T009, tasks T010 and T011 can run in parallel to add contract and integration coverage.
- **Across Stories**: Once US1 implementation (T006T009) is complete, US2 test tasks T015 and T016 can proceed in parallel while T012T014 are under development.
- **Validation Work**: For US3, T020 and T021 can execute in parallel after T019 ensures CLI gating is wired.
---
## Implementation Strategy
### MVP First
1. Complete Phases 12 to establish scaffolding and data structures.
2. Deliver Phase 3 (US1) to unlock core extension normalization (MVP).
3. Validate with contract/integration tests (T010, T011) and smoke through Quickstart scenario.
### Incremental Delivery
1. After MVP, implement Phase 4 (US2) to add ledger/undo guarantees for automation.
2. Follow with Phase 5 (US3) to harden validation and conflict handling.
3. Finish with Phase 6 polish tasks for documentation and operational scripts.
### Parallel Approach
1. One developer completes Phases 12.
2. Parallelize US1 implementation (engine vs. CLI vs. tests) once foundations are ready.
3. Assign US2 automation tasks to a second developer after US1 apply logic stabilizes.
4. Run US3 validation tasks concurrently with Polish updates once US2 nears completion.

21
testdata/extension/README.md vendored Normal file
View File

@@ -0,0 +1,21 @@
# Extension Command Test Data
This directory contains sample files for manual and automated validation of the
`renamer extension` workflow. To avoid mutating source fixtures directly, copy
the `sample/` folder to a temporary location before running commands that
perform real filesystem changes.
Example usage:
```bash
TMP_DIR=$(mktemp -d)
cp -R testdata/extension/sample/* "$TMP_DIR/"
go run ./main.go extension .jpeg .JPG .jpg --path "$TMP_DIR" --dry-run
```
The sample set includes:
- Mixed-case `.jpeg`/`.JPG` extensions
- Nested directories (`sample/nested`)
- An already-normalized `.jpg` file
- A hidden file to verify `--hidden` opt-in behavior

1
testdata/extension/sample/.hidden.JPG vendored Normal file
View File

@@ -0,0 +1 @@
hidden sample

View File

@@ -0,0 +1 @@
sample jpeg placeholder

View File

@@ -0,0 +1 @@
second sample

1
testdata/extension/sample/logo.jpg vendored Normal file
View File

@@ -0,0 +1 @@
already normalized

View File

@@ -0,0 +1 @@
nested sample

View File

@@ -0,0 +1,137 @@
package contract
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/rogeecn/renamer/internal/extension"
"github.com/rogeecn/renamer/internal/listing"
)
func TestExtensionPreviewAndApply(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
writeTestFile(t, filepath.Join(tmp, "photo.jpeg"))
writeTestFile(t, filepath.Join(tmp, "banner.JPG"))
writeTestFile(t, filepath.Join(tmp, "logo.jpg"))
writeTestFile(t, filepath.Join(tmp, "notes.txt"))
scope := &listing.ListingRequest{
WorkingDir: tmp,
IncludeDirectories: false,
Recursive: false,
IncludeHidden: false,
Extensions: nil,
Format: listing.FormatTable,
}
if err := scope.Validate(); err != nil {
t.Fatalf("validate scope: %v", err)
}
req := extension.NewRequest(scope)
req.SetExecutionMode(true, false)
sources := []string{".jpeg", ".JPG", ".jpg"}
canonical, display, duplicates := extension.NormalizeSourceExtensions(sources)
target := extension.NormalizeTargetExtension(".jpg")
targetCanonical := extension.CanonicalExtension(target)
filteredCanonical := make([]string, 0, len(canonical))
filteredDisplay := make([]string, 0, len(display))
noOps := make([]string, 0)
for i, canon := range canonical {
if canon == targetCanonical {
noOps = append(noOps, display[i])
continue
}
filteredCanonical = append(filteredCanonical, canon)
filteredDisplay = append(filteredDisplay, display[i])
}
if len(filteredCanonical) == 0 {
t.Fatalf("expected canonical sources after filtering")
}
req.SetExtensions(filteredCanonical, filteredDisplay, target)
req.SetWarnings(duplicates, noOps)
var buf bytes.Buffer
summary, planned, err := extension.Preview(context.Background(), req, &buf)
if err != nil {
t.Fatalf("Preview error: %v", err)
}
if summary.TotalCandidates != 3 {
t.Fatalf("expected 3 candidates, got %d", summary.TotalCandidates)
}
if summary.TotalChanged != 2 {
t.Fatalf("expected 2 changed entries, got %d", summary.TotalChanged)
}
if summary.NoChange != 1 {
t.Fatalf("expected 1 no-change entry, got %d", summary.NoChange)
}
if len(planned) != 2 {
t.Fatalf("expected 2 planned renames, got %d", len(planned))
}
output := buf.String()
if !strings.Contains(output, "photo.jpeg -> photo.jpg") {
t.Fatalf("expected preview to include photo rename, output: %s", output)
}
if !strings.Contains(output, "banner.JPG -> banner.jpg") {
t.Fatalf("expected preview to include banner rename, output: %s", output)
}
if !strings.Contains(output, "logo.jpg (no change)") {
t.Fatalf("expected preview to mark logo as no change, output: %s", output)
}
if !strings.Contains(output, "Summary: 3 candidates, 2 will change, 1 already target extension") {
t.Fatalf("expected summary line, output: %s", output)
}
if len(summary.Warnings) == 0 {
t.Fatalf("expected warnings for duplicates/no-ops")
}
req.SetExecutionMode(false, true)
entry, err := extension.Apply(context.Background(), req, planned, summary)
if err != nil {
t.Fatalf("Apply error: %v", err)
}
if len(entry.Operations) != len(planned) {
t.Fatalf("expected %d ledger operations, got %d", len(planned), len(entry.Operations))
}
if _, err := os.Stat(filepath.Join(tmp, "photo.jpg")); err != nil {
t.Fatalf("expected photo.jpg after apply: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "banner.jpg")); err != nil {
t.Fatalf("expected banner.jpg after apply: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "logo.jpg")); err != nil {
t.Fatalf("expected logo.jpg to remain: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "photo.jpeg")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected photo.jpeg to be renamed, err=%v", err)
}
ledger := filepath.Join(tmp, ".renamer")
if _, err := os.Stat(ledger); err != nil {
t.Fatalf("expected ledger file to be created: %v", err)
}
}
func writeTestFile(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("test"), 0o644); err != nil {
t.Fatalf("write file %s: %v", path, err)
}
}

View File

@@ -0,0 +1,167 @@
package contract
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
renamercmd "github.com/rogeecn/renamer/cmd"
"github.com/rogeecn/renamer/internal/extension"
"github.com/rogeecn/renamer/internal/listing"
)
func TestExtensionApplyMetadataCaptured(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
writeTestFile(t, filepath.Join(tmp, "clip.jpeg"))
writeTestFile(t, filepath.Join(tmp, "poster.JPG"))
writeTestFile(t, filepath.Join(tmp, "flyer.jpg"))
scope := &listing.ListingRequest{
WorkingDir: tmp,
IncludeDirectories: false,
Recursive: false,
IncludeHidden: false,
Extensions: nil,
Format: listing.FormatTable,
}
if err := scope.Validate(); err != nil {
t.Fatalf("validate scope: %v", err)
}
req := extension.NewRequest(scope)
req.SetExecutionMode(true, false)
sources := []string{".jpeg", ".JPG", ".jpg"}
canonical, display, duplicates := extension.NormalizeSourceExtensions(sources)
target := extension.NormalizeTargetExtension(".jpg")
targetCanonical := extension.CanonicalExtension(target)
filteredCanonical := make([]string, 0, len(canonical))
filteredDisplay := make([]string, 0, len(display))
noOps := make([]string, 0)
for i, canon := range canonical {
if canon == targetCanonical {
noOps = append(noOps, display[i])
continue
}
filteredCanonical = append(filteredCanonical, canon)
filteredDisplay = append(filteredDisplay, display[i])
}
req.SetExtensions(filteredCanonical, filteredDisplay, target)
req.SetWarnings(duplicates, noOps)
summary, planned, err := extension.Preview(context.Background(), req, nil)
if err != nil {
t.Fatalf("preview error: %v", err)
}
req.SetExecutionMode(false, true)
entry, err := extension.Apply(context.Background(), req, planned, summary)
if err != nil {
t.Fatalf("apply error: %v", err)
}
if entry.Metadata == nil {
t.Fatalf("expected metadata to be recorded")
}
sourcesMeta, ok := entry.Metadata["sourceExtensions"].([]string)
if !ok || len(sourcesMeta) != len(filteredDisplay) {
t.Fatalf("sourceExtensions metadata mismatch: %#v", entry.Metadata["sourceExtensions"])
}
if sourcesMeta[0] != ".jpeg" {
t.Fatalf("expected .jpeg in source metadata, got %v", sourcesMeta)
}
targetMeta, ok := entry.Metadata["targetExtension"].(string)
if !ok || targetMeta != target {
t.Fatalf("targetExtension metadata mismatch: %v", targetMeta)
}
if changed, ok := entry.Metadata["totalChanged"].(int); !ok || changed != summary.TotalChanged {
t.Fatalf("totalChanged metadata mismatch: %v", entry.Metadata["totalChanged"])
}
if noChange, ok := entry.Metadata["noChange"].(int); !ok || noChange != summary.NoChange {
t.Fatalf("noChange metadata mismatch: %v", entry.Metadata["noChange"])
}
counts, ok := entry.Metadata["perExtensionCounts"].(map[string]int)
if !ok {
t.Fatalf("perExtensionCounts metadata missing: %#v", entry.Metadata["perExtensionCounts"])
}
if counts[".jpeg"] == 0 || counts[".jpg"] == 0 {
t.Fatalf("expected counts for .jpeg and .jpg, got %#v", counts)
}
scopeMeta, ok := entry.Metadata["scope"].(map[string]any)
if !ok {
t.Fatalf("scope metadata missing: %#v", entry.Metadata["scope"])
}
if includeHidden, _ := scopeMeta["includeHidden"].(bool); includeHidden {
t.Fatalf("includeHidden should be false, got %v", includeHidden)
}
warnings, ok := entry.Metadata["warnings"].([]string)
if !ok || len(warnings) == 0 {
t.Fatalf("warnings metadata missing: %#v", entry.Metadata["warnings"])
}
joined := strings.Join(warnings, " ")
if !strings.Contains(joined, "duplicate source extension") {
t.Fatalf("expected duplicate warning in metadata: %v", warnings)
}
ledger := filepath.Join(tmp, ".renamer")
if _, err := os.Stat(ledger); err != nil {
t.Fatalf("ledger not created: %v", err)
}
if err := os.Remove(ledger); err != nil {
t.Fatalf("cleanup ledger: %v", err)
}
}
func TestExtensionCommandExitCodes(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
var out bytes.Buffer
cmd := renamercmd.NewRootCommand()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"extension", ".jpeg", ".jpg", "--dry-run", "--path", tmp})
if err := cmd.Execute(); err != nil {
t.Fatalf("expected dry-run to exit successfully, err=%v output=%s", err, out.String())
}
if !strings.Contains(out.String(), "No candidates found.") {
t.Fatalf("expected no candidates notice, output=%s", out.String())
}
writeTestFile(t, filepath.Join(tmp, "clip.jpeg"))
out.Reset()
cmd = renamercmd.NewRootCommand()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"extension", ".jpeg", ".jpg", "--yes", "--path", tmp})
if err := cmd.Execute(); err != nil {
t.Fatalf("expected apply to exit successfully, err=%v output=%s", err, out.String())
}
if _, err := os.Stat(filepath.Join(tmp, "clip.jpg")); err != nil {
t.Fatalf("expected clip.jpg after apply: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "clip.jpeg")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected clip.jpeg to be renamed, err=%v", err)
}
}

View File

@@ -0,0 +1,102 @@
package contract
import (
"context"
"errors"
"path/filepath"
"testing"
"github.com/rogeecn/renamer/internal/extension"
"github.com/rogeecn/renamer/internal/listing"
)
func TestParseArgsValidation(t *testing.T) {
cases := []struct {
name string
args []string
}{
{"tooFew", []string{".jpg"}},
{"emptySource", []string{" ", ".jpg"}},
{"missingDotSource", []string{"jpg", ".png"}},
{"missingDotTarget", []string{".jpg", "png"}},
}
for _, tc := range cases {
_, err := extension.ParseArgs(tc.args)
if err == nil {
t.Fatalf("expected error for case %s", tc.name)
}
}
_, err := extension.ParseArgs([]string{".jpg", ".JPG"})
if err == nil {
t.Fatalf("expected error when all sources match target")
}
parsed, err := extension.ParseArgs([]string{".jpeg", ".JPG", ".jpg"})
if err != nil {
t.Fatalf("unexpected error for valid args: %v", err)
}
if len(parsed.SourcesCanonical) != 1 || parsed.SourcesCanonical[0] != ".jpeg" {
t.Fatalf("expected canonical list to contain .jpeg only, got %#v", parsed.SourcesCanonical)
}
if len(parsed.NoOps) != 1 {
t.Fatalf("expected .jpg to be treated as no-op")
}
}
func TestPreviewDetectsConflicts(t *testing.T) {
tmp := t.TempDir()
writeTestFile(t, filepath.Join(tmp, "report.jpeg"))
writeTestFile(t, filepath.Join(tmp, "report.jpg"))
scope := &listing.ListingRequest{
WorkingDir: tmp,
IncludeDirectories: false,
Recursive: false,
IncludeHidden: false,
Format: listing.FormatTable,
}
if err := scope.Validate(); err != nil {
t.Fatalf("validate scope: %v", err)
}
req := extension.NewRequest(scope)
parsed, err := extension.ParseArgs([]string{".jpeg", ".jpg"})
if err != nil {
t.Fatalf("parse error: %v", err)
}
req.SetExtensions(parsed.SourcesCanonical, parsed.SourcesDisplay, parsed.Target)
req.SetWarnings(parsed.Duplicates, parsed.NoOps)
summary, planned, err := extension.Preview(context.Background(), req, nil)
if err != nil {
t.Fatalf("preview error: %v", err)
}
if !summary.HasConflicts() {
t.Fatalf("expected conflict when target already exists")
}
if len(planned) != 0 {
t.Fatalf("expected no operations due to conflict, got %d", len(planned))
}
if len(summary.Warnings) == 0 {
t.Fatalf("expected warning recorded for conflict")
}
// Apply should be skipped by caller; invoking directly without operations should no-op.
req.SetExecutionMode(false, true)
entry, err := extension.Apply(context.Background(), req, planned, summary)
if err != nil {
t.Fatalf("apply error: %v", err)
}
if len(entry.Operations) != 0 {
t.Fatalf("expected zero operations recorded when conflicts present")
}
if _, err := extension.ParseArgs([]string{".jpeg", ".jpg"}); err != nil {
// ensure previous parse errors do not leak state
if !errors.Is(err, nil) {
// unreachable, but keeps staticcheck happy
}
}
}

View File

@@ -0,0 +1,78 @@
package integration
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
renamercmd "github.com/rogeecn/renamer/cmd"
)
func TestExtensionCommandFlow(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
createFile(t, filepath.Join(tmp, "image.jpeg"))
createFile(t, filepath.Join(tmp, "poster.JPG"))
createFile(t, filepath.Join(tmp, "logo.jpg"))
var previewOut bytes.Buffer
preview := renamercmd.NewRootCommand()
preview.SetOut(&previewOut)
preview.SetErr(&previewOut)
preview.SetArgs([]string{"extension", ".jpeg", ".JPG", ".jpg", "--dry-run", "--path", tmp})
if err := preview.Execute(); err != nil {
t.Fatalf("preview command failed: %v\noutput: %s", err, previewOut.String())
}
output := previewOut.String()
if !strings.Contains(output, "image.jpeg -> image.jpg") {
t.Fatalf("expected preview output to include image rename, got:\n%s", output)
}
if !strings.Contains(output, "poster.JPG -> poster.jpg") {
t.Fatalf("expected preview output to include poster rename, got:\n%s", output)
}
if !strings.Contains(output, "logo.jpg (no change)") {
t.Fatalf("expected preview output to include no-change row for logo, got:\n%s", output)
}
var applyOut bytes.Buffer
apply := renamercmd.NewRootCommand()
apply.SetOut(&applyOut)
apply.SetErr(&applyOut)
apply.SetArgs([]string{"extension", ".jpeg", ".JPG", ".jpg", "--yes", "--path", tmp})
if err := apply.Execute(); err != nil {
t.Fatalf("apply command failed: %v\noutput: %s", err, applyOut.String())
}
if _, err := os.Stat(filepath.Join(tmp, "image.jpg")); err != nil {
t.Fatalf("expected image.jpg after apply: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "poster.jpg")); err != nil {
t.Fatalf("expected poster.jpg after apply: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "logo.jpg")); err != nil {
t.Fatalf("expected logo.jpg to remain: %v", err)
}
var undoOut bytes.Buffer
undo := renamercmd.NewRootCommand()
undo.SetOut(&undoOut)
undo.SetErr(&undoOut)
undo.SetArgs([]string{"undo", "--path", tmp})
if err := undo.Execute(); err != nil {
t.Fatalf("undo command failed: %v\noutput: %s", err, undoOut.String())
}
if _, err := os.Stat(filepath.Join(tmp, "image.jpeg")); err != nil {
t.Fatalf("expected image.jpeg after undo: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "poster.JPG")); err != nil {
t.Fatalf("expected poster.JPG after undo: %v", err)
}
}

View File

@@ -0,0 +1,52 @@
package integration
import (
"bytes"
"os"
"path/filepath"
"testing"
renamercmd "github.com/rogeecn/renamer/cmd"
)
func TestExtensionAutomationUndo(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
createFile(t, filepath.Join(tmp, "config.yaml"))
createFile(t, filepath.Join(tmp, "notes.yml"))
var applyOut bytes.Buffer
apply := renamercmd.NewRootCommand()
apply.SetOut(&applyOut)
apply.SetErr(&applyOut)
apply.SetArgs([]string{"extension", ".yaml", ".yml", ".yml", "--yes", "--path", tmp})
if err := apply.Execute(); err != nil {
t.Fatalf("automation apply failed: %v\noutput: %s", err, applyOut.String())
}
if _, err := os.Stat(filepath.Join(tmp, "config.yml")); err != nil {
t.Fatalf("expected config.yml after apply: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "config.yaml")); !os.IsNotExist(err) {
t.Fatalf("expected config.yaml renamed, err=%v", err)
}
var undoOut bytes.Buffer
undo := renamercmd.NewRootCommand()
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 _, err := os.Stat(filepath.Join(tmp, "config.yaml")); err != nil {
t.Fatalf("expected config.yaml after undo: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "config.yml")); !os.IsNotExist(err) {
t.Fatalf("expected config.yml removed after undo, err=%v", err)
}
}

View File

@@ -0,0 +1,42 @@
package integration
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
renamercmd "github.com/rogeecn/renamer/cmd"
)
func TestExtensionCommandBlocksConflicts(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
createFile(t, filepath.Join(tmp, "doc.jpeg"))
createFile(t, filepath.Join(tmp, "doc.jpg"))
var out bytes.Buffer
cmd := renamercmd.NewRootCommand()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"extension", ".jpeg", ".jpg", ".jpg", "--yes", "--path", tmp})
err := cmd.Execute()
if err == nil {
t.Fatalf("expected conflict to produce an error")
}
if !strings.Contains(out.String(), "existing") && !strings.Contains(out.String(), "conflict") {
t.Fatalf("expected conflict messaging in output, got: %s", out.String())
}
// Ensure files unchanged after failed apply.
if _, err := os.Stat(filepath.Join(tmp, "doc.jpeg")); err != nil {
t.Fatalf("expected doc.jpeg to remain: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "doc.jpg")); err != nil {
t.Fatalf("expected doc.jpg to remain: %v", err)
}
}