Add extension normalization command
This commit is contained in:
@@ -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
92
cmd/extension.go
Normal 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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
14
cmd/undo.go
14
cmd/undo.go
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
109
internal/extension/apply.go
Normal 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
|
||||
}
|
||||
58
internal/extension/conflicts.go
Normal file
58
internal/extension/conflicts.go
Normal 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
|
||||
}
|
||||
3
internal/extension/doc.go
Normal file
3
internal/extension/doc.go
Normal 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
|
||||
164
internal/extension/engine.go
Normal file
164
internal/extension/engine.go
Normal 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
|
||||
}
|
||||
59
internal/extension/normalize.go
Normal file
59
internal/extension/normalize.go
Normal 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
|
||||
}
|
||||
77
internal/extension/parser.go
Normal file
77
internal/extension/parser.go
Normal 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
|
||||
}
|
||||
92
internal/extension/preview.go
Normal file
92
internal/extension/preview.go
Normal 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
|
||||
}
|
||||
100
internal/extension/request.go
Normal file
100
internal/extension/request.go
Normal 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
|
||||
}
|
||||
92
internal/extension/summary.go
Normal file
92
internal/extension/summary.go
Normal 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
|
||||
}
|
||||
42
scripts/smoke-test-extension.sh
Normal file
42
scripts/smoke-test-extension.sh
Normal 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."
|
||||
34
specs/004-extension-rename/checklists/requirements.md
Normal file
34
specs/004-extension-rename/checklists/requirements.md
Normal 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.
|
||||
248
specs/004-extension-rename/contracts/extension-command.yaml
Normal file
248
specs/004-extension-rename/contracts/extension-command.yaml
Normal 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'
|
||||
66
specs/004-extension-rename/data-model.md
Normal file
66
specs/004-extension-rename/data-model.md
Normal 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.
|
||||
108
specs/004-extension-rename/plan.md
Normal file
108
specs/004-extension-rename/plan.md
Normal 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_ | — | — |
|
||||
28
specs/004-extension-rename/quickstart.md
Normal file
28
specs/004-extension-rename/quickstart.md
Normal 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.
|
||||
17
specs/004-extension-rename/research.md
Normal file
17
specs/004-extension-rename/research.md
Normal 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 spec’s 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 Cobra’s 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.
|
||||
111
specs/004-extension-rename/spec.md
Normal file
111
specs/004-extension-rename/spec.md
Normal 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.
|
||||
160
specs/004-extension-rename/tasks.md
Normal file
160
specs/004-extension-rename/tasks.md
Normal 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 (3–5)** → 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 T003–T005.
|
||||
- T007, T008 depend on T006.
|
||||
- T009 depends on T006–T008.
|
||||
- T012 depends on T008.
|
||||
- T013, T014 depend on T012.
|
||||
- T017 depends on T003, T005.
|
||||
- T018 depends on T006–T007.
|
||||
- T019 depends on T017–T018.
|
||||
|
||||
---
|
||||
|
||||
## 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 (T006–T009) is complete, US2 test tasks T015 and T016 can proceed in parallel while T012–T014 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 1–2 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 1–2.
|
||||
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
21
testdata/extension/README.md
vendored
Normal 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
1
testdata/extension/sample/.hidden.JPG
vendored
Normal file
@@ -0,0 +1 @@
|
||||
hidden sample
|
||||
1
testdata/extension/sample/image_one.jpeg
vendored
Normal file
1
testdata/extension/sample/image_one.jpeg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
sample jpeg placeholder
|
||||
1
testdata/extension/sample/image_two.JPG
vendored
Normal file
1
testdata/extension/sample/image_two.JPG
vendored
Normal file
@@ -0,0 +1 @@
|
||||
second sample
|
||||
1
testdata/extension/sample/logo.jpg
vendored
Normal file
1
testdata/extension/sample/logo.jpg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
already normalized
|
||||
1
testdata/extension/sample/nested/clip.jpeg
vendored
Normal file
1
testdata/extension/sample/nested/clip.jpeg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
nested sample
|
||||
137
tests/contract/extension_command_test.go
Normal file
137
tests/contract/extension_command_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
167
tests/contract/extension_ledger_test.go
Normal file
167
tests/contract/extension_ledger_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
102
tests/contract/extension_validation_test.go
Normal file
102
tests/contract/extension_validation_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
78
tests/integration/extension_flow_test.go
Normal file
78
tests/integration/extension_flow_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
52
tests/integration/extension_undo_test.go
Normal file
52
tests/integration/extension_undo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
42
tests/integration/extension_validation_test.go
Normal file
42
tests/integration/extension_validation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user