diff --git a/AGENTS.md b/AGENTS.md index 3a96f51..0cde55b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ Auto-generated from all feature plans. Last updated: 2025-10-29 - 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) +- Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/history/output packages (005-add-insert-command) ## Project Structure @@ -40,9 +41,9 @@ tests/ - Smoke: `scripts/smoke-test-replace.sh`, `scripts/smoke-test-remove.sh` ## Recent Changes +- 005-add-insert-command: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/history/output packages - 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. diff --git a/cmd/insert.go b/cmd/insert.go new file mode 100644 index 0000000..69497ed --- /dev/null +++ b/cmd/insert.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/rogeecn/renamer/internal/insert" + "github.com/rogeecn/renamer/internal/listing" +) + +func newInsertCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "insert ", + Short: "Insert text into filenames at specified positions", + Long: `Insert a Unicode string into each candidate filename at a specific position. +Supported positions: "^" (start), "$" (before extension), positive indexes (1-based), +negative indexes counting back from the end.`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + scope, err := listing.ScopeFromCmd(cmd) + if err != nil { + return err + } + + req := insert.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) + + positionToken := args[0] + insertText := strings.Join(args[1:], " ") + req.SetPositionAndText(positionToken, insertText) + + summary, planned, err := insert.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; files already reflect requested insert.") + } + return nil + } + + entry, err := insert.Apply(cmd.Context(), req, planned, summary) + if err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Applied %d insert updates. Ledger updated.\n", len(entry.Operations)) + return nil + }, + } + + cmd.Example = ` renamer insert ^ "[2025] " --dry-run + renamer insert -1 _FINAL --yes --path ./reports` + + return cmd +} + +func init() { + rootCmd.AddCommand(newInsertCommand()) +} diff --git a/cmd/root.go b/cmd/root.go index 7287c2b..1487b68 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -48,6 +48,7 @@ func NewRootCommand() *cobra.Command { cmd.AddCommand(NewReplaceCommand()) cmd.AddCommand(NewRemoveCommand()) cmd.AddCommand(NewExtensionCommand()) + cmd.AddCommand(newInsertCommand()) cmd.AddCommand(newUndoCommand()) return cmd diff --git a/cmd/undo.go b/cmd/undo.go index e6edf25..3a657fe 100644 --- a/cmd/undo.go +++ b/cmd/undo.go @@ -29,12 +29,25 @@ func newUndoCommand() *cobra.Command { 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, ", ")) + if entry.Metadata != nil { + switch entry.Command { + case "extension": + 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, ", ")) + } + case "insert": + insertText, _ := entry.Metadata["insertText"].(string) + positionToken, _ := entry.Metadata["positionToken"].(string) + if insertText != "" { + if positionToken != "" { + fmt.Fprintf(out, "Inserted text %q removed from position %s\n", insertText, positionToken) + } else { + fmt.Fprintf(out, "Inserted text %q removed\n", insertText) + } + } } } diff --git a/docs/cli-flags.md b/docs/cli-flags.md index ca80aff..af042cf 100644 --- a/docs/cli-flags.md +++ b/docs/cli-flags.md @@ -2,52 +2,40 @@ Renamer shares a consistent set of scope flags across every command that inspects or mutates the filesystem. Use these options at the root command level so they apply to all subcommands (`list`, -`replace`, future `preview`/`rename`, etc.). +`replace`, `insert`, `remove`, etc.). | Flag | Default | Description | |------|---------|-------------| | `--path` | `.` | Working directory root for traversal. | | `-r`, `--recursive` | `false` | Traverse subdirectories depth-first. Symlinked directories are not followed. | -| `-d`, `--include-dirs` | `false` | Limit results to directories only (files and symlinks are suppressed). Directory traversal still occurs even when the flag is absent. | +| `-d`, `--include-dirs` | `false` | Include directories in results. | | `-e`, `--extensions` | *(none)* | Pipe-separated list of file extensions (e.g. `.jpg|.mov`). Tokens must start with a dot, are lowercased internally, and duplicates are ignored. | | `--hidden` | `false` | Include dot-prefixed files and directories. By default they are excluded from listings and rename previews. | -| `--yes` | `false` | Apply changes without an interactive confirmation prompt (mutating commands only). | +| `--yes` | `false` | Apply changes without interactive confirmation (mutating commands only). | | `--dry-run` | `false` | Force preview-only behavior even when `--yes` is supplied. | | `--format` | `table` | Command-specific output formatting option. For `list`, use `table` or `plain`. | -## Validation Rules - -- Extension tokens that are empty or missing the leading `.` cause validation errors. -- Filters that match zero entries result in a friendly message and exit code `0`. -- Invalid flag combinations (e.g., unsupported `--format` values) cause the command to exit with a non-zero code. -- Recursive traversal honor `--hidden` and skips unreadable directories while logging warnings. - -Keep this document updated whenever a new command is introduced or the global scope behavior -changes. - -## Replace Command Quick Reference +## Insert Command Quick Reference ```bash -renamer replace [pattern2 ...] [flags] +renamer insert [flags] ``` -- The **final positional argument** is the replacement value; all preceding arguments are treated as - literal patterns (quotes required when a pattern contains spaces). -- Patterns are applied sequentially and replaced with the same value. Duplicate patterns are - deduplicated automatically and surfaced in the preview summary. -- Empty replacement strings are allowed (effectively deleting each pattern) but the preview warns - before confirmation. -- Combine with scope flags (`--path`, `-r`, `--include-dirs`, etc.) to target the desired set of - files/directories. -- Use `--dry-run` to preview in scripts, then `--yes` to apply once satisfied; combining both flags - exits with an error to prevent accidental automation mistakes. +- Position tokens: + - `^` inserts at the beginning of the filename. + - `$` inserts immediately before the extension dot (or end if no extension). + - Positive integers (1-based) count forward from the start of the stem. + - Negative integers count backward from the end of the stem (e.g., `-1` inserts before the last rune). +- Text must be valid UTF-8 without path separators or control characters; Unicode characters are supported. +- Scope flags (`--path`, `-r`, `-d`, `--hidden`, `--extensions`) limit the candidate set before insertion. +- `--dry-run` previews the plan; rerun with `--yes` to apply the same operations. ### Usage Examples -- Preview files recursively: `renamer --recursive preview` -- List JPEGs only: `renamer --extensions .jpg list` -- Replace multiple patterns: `renamer replace draft Draft final --dry-run` -- Include dotfiles: `renamer --hidden --extensions .env list` +- Preview adding a prefix: `renamer insert ^ "[2025] " --dry-run` +- Append before extension: `renamer insert $ _ARCHIVE --yes --path ./docs` +- Insert after third character in stem: `renamer insert 3 _tag --path ./images --dry-run` +- Combine with extension filter: `renamer insert ^ "v1_" --extensions .txt|.md` ## Remove Command Quick Reference diff --git a/internal/insert/apply.go b/internal/insert/apply.go new file mode 100644 index 0000000..59e4954 --- /dev/null +++ b/internal/insert/apply.go @@ -0,0 +1,85 @@ +package insert + +import ( + "context" + "errors" + "os" + "path/filepath" + "sort" + + "github.com/rogeecn/renamer/internal/history" +) + +// Apply performs planned insert operations and records them in the ledger. +func Apply(ctx context.Context, req *Request, planned []PlannedOperation, summary *Summary) (history.Entry, error) { + entry := history.Entry{Command: "insert"} + + 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: op.OriginalRelative, + To: op.ProposedRelative, + }) + } + + if len(done) == 0 { + return entry, nil + } + + entry.Operations = done + if summary != nil { + meta := make(map[string]any, len(summary.LedgerMetadata)) + for k, v := range summary.LedgerMetadata { + meta[k] = v + } + meta["totalCandidates"] = summary.TotalCandidates + meta["totalChanged"] = summary.TotalChanged + meta["noChange"] = summary.NoChange + 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 +} diff --git a/internal/insert/conflicts.go b/internal/insert/conflicts.go new file mode 100644 index 0000000..66447f2 --- /dev/null +++ b/internal/insert/conflicts.go @@ -0,0 +1,32 @@ +package insert + +import ( + "fmt" +) + +// ConflictDetector tracks proposed targets to detect duplicates. +type ConflictDetector struct { + planned map[string]string // proposedRelative -> originalRelative +} + +// NewConflictDetector creates an empty detector. +func NewConflictDetector() *ConflictDetector { + return &ConflictDetector{planned: make(map[string]string)} +} + +// Register validates the proposed target and returns an error string if conflict occurred. +func (d *ConflictDetector) Register(original, proposed string) (string, bool) { + if proposed == "" { + return "", false + } + if existing, ok := d.planned[proposed]; ok && existing != original { + return fmt.Sprintf("duplicate target with %s", existing), false + } + d.planned[proposed] = original + return "", true +} + +// Forget removes a planned target (used if operation skipped). +func (d *ConflictDetector) Forget(proposed string) { + delete(d.planned, proposed) +} diff --git a/internal/insert/doc.go b/internal/insert/doc.go new file mode 100644 index 0000000..afefb08 --- /dev/null +++ b/internal/insert/doc.go @@ -0,0 +1,3 @@ +// Package insert contains the engine, preview helpers, and validation logic for +// inserting text into file and directory names using positional directives. +package insert diff --git a/internal/insert/engine.go b/internal/insert/engine.go new file mode 100644 index 0000000..8e97dc6 --- /dev/null +++ b/internal/insert/engine.go @@ -0,0 +1,187 @@ +package insert + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/rogeecn/renamer/internal/traversal" +) + +// PlannedOperation captures a filesystem rename to be applied. +type PlannedOperation struct { + OriginalRelative string + OriginalAbsolute string + ProposedRelative string + ProposedAbsolute string + InsertedText string + IsDir bool + Depth int +} + +// BuildPlan enumerates candidates, computes preview entries, and prepares filesystem operations. +func BuildPlan(ctx context.Context, req *Request) (*Summary, []PlannedOperation, error) { + if req == nil { + return nil, nil, errors.New("insert request cannot be nil") + } + if err := req.Normalize(); err != nil { + return nil, nil, err + } + + summary := NewSummary() + operations := make([]PlannedOperation, 0) + detector := NewConflictDetector() + + filterSet := make(map[string]struct{}, len(req.ExtensionFilter)) + for _, ext := range req.ExtensionFilter { + filterSet[strings.ToLower(ext)] = 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 + } + + relative := filepath.ToSlash(relPath) + name := entry.Name() + + ext := "" + stem := name + if !isDir { + ext = filepath.Ext(name) + stem = strings.TrimSuffix(name, ext) + } + + if !isDir && len(filterSet) > 0 { + lowerExt := strings.ToLower(ext) + if _, ok := filterSet[lowerExt]; !ok { + return nil + } + } + + stemRunes := []rune(stem) + if err := ParseInputs(req.PositionToken, req.InsertText, len(stemRunes)); err != nil { + return err + } + + position, err := ResolvePosition(req.PositionToken, len(stemRunes)) + if err != nil { + return err + } + + status := StatusChanged + proposedRelative := relative + proposedAbsolute := filepath.Join(req.WorkingDir, filepath.FromSlash(relative)) + + var builder strings.Builder + builder.WriteString(string(stemRunes[:position.Index])) + builder.WriteString(req.InsertText) + builder.WriteString(string(stemRunes[position.Index:])) + proposedName := builder.String() + ext + + dir := filepath.Dir(relative) + if dir == "." { + dir = "" + } + if dir == "" { + proposedRelative = filepath.ToSlash(proposedName) + } else { + proposedRelative = filepath.ToSlash(filepath.Join(dir, proposedName)) + } + proposedAbsolute = filepath.Join(req.WorkingDir, filepath.FromSlash(proposedRelative)) + + if proposedRelative == relative { + status = StatusNoChange + } + + if status == StatusChanged { + if reason, ok := detector.Register(relative, proposedRelative); !ok { + summary.AddConflict(Conflict{ + OriginalPath: relative, + ProposedPath: proposedRelative, + Reason: "duplicate_target", + }) + summary.AddWarning(reason) + status = StatusSkipped + } else if info, err := os.Stat(proposedAbsolute); err == nil { + origInfo, origErr := os.Stat(filepath.Join(req.WorkingDir, filepath.FromSlash(relative))) + if origErr != nil { + return origErr + } + if !os.SameFile(info, origInfo) { + reason := "existing_file" + if info.IsDir() { + reason = "existing_directory" + } + summary.AddConflict(Conflict{ + OriginalPath: relative, + ProposedPath: proposedRelative, + Reason: reason, + }) + summary.AddWarning("target already exists") + detector.Forget(proposedRelative) + status = StatusSkipped + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + + if status == StatusChanged { + operations = append(operations, PlannedOperation{ + OriginalRelative: relative, + OriginalAbsolute: filepath.Join(req.WorkingDir, filepath.FromSlash(relative)), + ProposedRelative: proposedRelative, + ProposedAbsolute: proposedAbsolute, + InsertedText: req.InsertText, + IsDir: isDir, + Depth: depth, + }) + } + } + + entrySummary := PreviewEntry{ + OriginalPath: relative, + ProposedPath: proposedRelative, + Status: status, + } + if status == StatusChanged { + entrySummary.InsertedText = req.InsertText + } + + summary.RecordEntry(entrySummary) + return nil + }, + ) + if err != nil { + return nil, nil, err + } + + sort.SliceStable(summary.Entries, func(i, j int) bool { + return summary.Entries[i].OriginalPath < summary.Entries[j].OriginalPath + }) + + return summary, operations, nil +} diff --git a/internal/insert/parser.go b/internal/insert/parser.go new file mode 100644 index 0000000..3ca2f9c --- /dev/null +++ b/internal/insert/parser.go @@ -0,0 +1,46 @@ +package insert + +import ( + "errors" + "fmt" + "unicode/utf8" +) + +// ParseInputs validates the position token and insert text before preview/apply. +func ParseInputs(positionToken, insertText string, stemLength int) error { + if positionToken == "" { + return errors.New("position token cannot be empty") + } + if insertText == "" { + return errors.New("insert text cannot be empty") + } + if !utf8.ValidString(insertText) { + return errors.New("insert text must be valid UTF-8") + } + for _, r := range insertText { + if r == '/' || r == '\\' { + return errors.New("insert text must not contain path separators") + } + if r < 0x20 { + return errors.New("insert text must not contain control characters") + } + } + if stemLength >= 0 { + if _, err := ResolvePosition(positionToken, stemLength); err != nil { + return err + } + } + return nil +} + +// ResolvePositionWithValidation wraps ResolvePosition with explicit range checks. +func ResolvePositionWithValidation(positionToken string, stemLength int) (Position, error) { + pos, err := ResolvePosition(positionToken, stemLength) + if err != nil { + return Position{}, err + } + if pos.Index < 0 || pos.Index > stemLength { + return Position{}, fmt.Errorf("position %s out of range for %d-character stem", positionToken, stemLength) + } + return pos, nil +} diff --git a/internal/insert/positions.go b/internal/insert/positions.go new file mode 100644 index 0000000..be8569d --- /dev/null +++ b/internal/insert/positions.go @@ -0,0 +1,74 @@ +package insert + +import ( + "errors" + "fmt" + "unicode/utf8" +) + +// Position represents a resolved rune index relative to the filename stem. +type Position struct { + Index int // zero-based index where insertion should occur +} + +// ResolvePosition interprets a position token (`^`, `$`, positive, negative) against the stem length. +func ResolvePosition(token string, stemLength int) (Position, error) { + switch token { + case "^": + return Position{Index: 0}, nil + case "$": + return Position{Index: stemLength}, nil + } + + if token == "" { + return Position{}, errors.New("position token cannot be empty") + } + + // Try parsing as integer (positive or negative). + idx, err := parseInt(token) + if err != nil { + return Position{}, fmt.Errorf("invalid position token %q: %w", token, err) + } + + if idx > 0 { + if idx > stemLength { + return Position{}, fmt.Errorf("position %d out of range for %d-character stem", idx, stemLength) + } + return Position{Index: idx}, nil + } + + // Negative index counts backward from end (e.g., -1 inserts before last rune). + offset := stemLength + idx + if offset < 0 || offset > stemLength { + return Position{}, fmt.Errorf("position %d out of range for %d-character stem", idx, stemLength) + } + return Position{Index: offset}, nil +} + +// CountRunes returns the number of Unicode code points in name. +func CountRunes(name string) int { + return utf8.RuneCountInString(name) +} + +// parseInt is declared separately for test stubbing. +var parseInt = func(token string) (int, error) { + var sign int = 1 + switch token[0] { + case '+': + token = token[1:] + case '-': + sign = -1 + token = token[1:] + } + if token == "" { + return 0, errors.New("missing digits") + } + var value int + for _, r := range token { + if r < '0' || r > '9' { + return 0, errors.New("non-numeric character in token") + } + value = value*10 + int(r-'0') + } + return sign * value, nil +} diff --git a/internal/insert/preview.go b/internal/insert/preview.go new file mode 100644 index 0000000..477c0c3 --- /dev/null +++ b/internal/insert/preview.go @@ -0,0 +1,77 @@ +package insert + +import ( + "context" + "errors" + "fmt" + "io" + "sort" +) + +// Preview computes planned insert operations, renders preview output, and returns the summary. +func Preview(ctx context.Context, req *Request, out io.Writer) (*Summary, []PlannedOperation, error) { + if req == nil { + return nil, nil, errors.New("insert request cannot be nil") + } + + summary, operations, err := BuildPlan(ctx, req) + if err != nil { + return nil, nil, err + } + + summary.LedgerMetadata["positionToken"] = req.PositionToken + summary.LedgerMetadata["insertText"] = req.InsertText + 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...) + } + summary.LedgerMetadata["scope"] = scope + + if out != nil { + conflictReasons := make(map[string]string, len(summary.Conflicts)) + for _, conflict := range summary.Conflicts { + key := conflict.OriginalPath + "->" + conflict.ProposedPath + conflictReasons[key] = conflict.Reason + } + + entries := append([]PreviewEntry(nil), summary.Entries...) + sort.SliceStable(entries, func(i, j int) bool { + return entries[i].OriginalPath < entries[j].OriginalPath + }) + + for _, entry := range entries { + switch entry.Status { + case StatusChanged: + fmt.Fprintf(out, "%s -> %s\n", entry.OriginalPath, entry.ProposedPath) + case StatusNoChange: + fmt.Fprintf(out, "%s (no change)\n", entry.OriginalPath) + case StatusSkipped: + reason := conflictReasons[entry.OriginalPath+"->"+entry.ProposedPath] + if reason == "" { + reason = "skipped" + } + fmt.Fprintf(out, "%s -> %s (skipped: %s)\n", entry.OriginalPath, entry.ProposedPath, reason) + } + } + + if summary.TotalCandidates > 0 { + fmt.Fprintf(out, "\nSummary: %d candidates, %d will change, %d already target position\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 { + fmt.Fprintf(out, "Warning: %s\n", warning) + } + } + } + + return summary, operations, nil +} diff --git a/internal/insert/request.go b/internal/insert/request.go new file mode 100644 index 0000000..16ba268 --- /dev/null +++ b/internal/insert/request.go @@ -0,0 +1,86 @@ +package insert + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/rogeecn/renamer/internal/listing" +) + +// Request encapsulates the inputs required to run an insert operation. +type Request struct { + WorkingDir string + PositionToken string + InsertText string + IncludeDirs bool + Recursive bool + IncludeHidden bool + ExtensionFilter []string + DryRun bool + AutoConfirm bool + Timestamp time.Time +} + +// NewRequest constructs a Request from shared listing scope. +func NewRequest(scope *listing.ListingRequest) *Request { + if scope == nil { + return &Request{} + } + + extensions := append([]string(nil), scope.Extensions...) + + return &Request{ + WorkingDir: scope.WorkingDir, + IncludeDirs: scope.IncludeDirectories, + Recursive: scope.Recursive, + IncludeHidden: scope.IncludeHidden, + ExtensionFilter: extensions, + } +} + +// SetExecutionMode updates dry-run and auto-apply preferences. +func (r *Request) SetExecutionMode(dryRun, autoConfirm bool) { + r.DryRun = dryRun + r.AutoConfirm = autoConfirm +} + +// SetPositionAndText stores the user-supplied position token and insert text. +func (r *Request) SetPositionAndText(positionToken, insertText string) { + r.PositionToken = positionToken + r.InsertText = insertText +} + +// Normalize ensures working directory and timestamp fields are ready for execution. +func (r *Request) 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 +} diff --git a/internal/insert/summary.go b/internal/insert/summary.go new file mode 100644 index 0000000..4c22d32 --- /dev/null +++ b/internal/insert/summary.go @@ -0,0 +1,84 @@ +package insert + +// Status represents the preview outcome for a candidate entry. +type Status string + +const ( + StatusChanged Status = "changed" + StatusNoChange Status = "no_change" + StatusSkipped Status = "skipped" +) + +// PreviewEntry describes a single original → proposed mapping. +type PreviewEntry struct { + OriginalPath string + ProposedPath string + Status Status + InsertedText string +} + +// Conflict captures a conflicting rename outcome. +type Conflict struct { + OriginalPath string + ProposedPath string + Reason string +} + +// Summary aggregates counts, warnings, conflicts, and ledger metadata for insert operations. +type Summary struct { + TotalCandidates int + TotalChanged int + NoChange int + + Entries []PreviewEntry + Conflicts []Conflict + Warnings []string + + LedgerMetadata map[string]any +} + +// NewSummary constructs an empty summary with initialized maps. +func NewSummary() *Summary { + return &Summary{ + Entries: make([]PreviewEntry, 0), + Conflicts: make([]Conflict, 0), + Warnings: make([]string, 0), + LedgerMetadata: make(map[string]any), + } +} + +// RecordEntry appends a preview entry and updates aggregate counts. +func (s *Summary) RecordEntry(entry PreviewEntry) { + s.Entries = append(s.Entries, entry) + s.TotalCandidates++ + + switch entry.Status { + case StatusChanged: + s.TotalChanged++ + case StatusNoChange: + s.NoChange++ + } +} + +// AddConflict records a blocking conflict. +func (s *Summary) AddConflict(conflict Conflict) { + s.Conflicts = append(s.Conflicts, conflict) +} + +// AddWarning adds a warning if not already present. +func (s *Summary) AddWarning(msg string) { + if msg == "" { + return + } + for _, existing := range s.Warnings { + if existing == msg { + return + } + } + s.Warnings = append(s.Warnings, msg) +} + +// HasConflicts indicates whether apply should be blocked. +func (s *Summary) HasConflicts() bool { + return len(s.Conflicts) > 0 +} diff --git a/main.go b/main.go index da23c50..5df9461 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,5 @@ /* Copyright © 2025 NAME HERE - */ package main diff --git a/scripts/smoke-test-insert.sh b/scripts/smoke-test-insert.sh new file mode 100755 index 0000000..971a104 --- /dev/null +++ b/scripts/smoke-test-insert.sh @@ -0,0 +1,29 @@ +#!/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 + +mkdir -p "$TMP_DIR" +cat <<'FILE' > "$TMP_DIR/holiday.jpg" +photo +FILE +cat <<'FILE' > "$TMP_DIR/trip.jpg" +travel +FILE + +echo "Previewing insert..." +$BIN "$ROOT_DIR/main.go" insert 2 _tag --dry-run --path "$TMP_DIR" >/tmp/insert-preview.log +cat /tmp/insert-preview.log + +echo "Applying insert..." +$BIN "$ROOT_DIR/main.go" insert 2 _tag --yes --path "$TMP_DIR" >/tmp/insert-apply.log +cat /tmp/insert-apply.log + +echo "Undoing insert..." +$BIN "$ROOT_DIR/main.go" undo --path "$TMP_DIR" >/tmp/insert-undo.log +cat /tmp/insert-undo.log + +echo "Insert smoke test completed successfully." diff --git a/specs/005-add-insert-command/checklists/requirements.md b/specs/005-add-insert-command/checklists/requirements.md new file mode 100644 index 0000000..3cb2b44 --- /dev/null +++ b/specs/005-add-insert-command/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Insert Command for Positional Text Injection + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-10-30 +**Feature**: specs/001-add-insert-command/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 + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` diff --git a/specs/005-add-insert-command/contracts/insert-command.yaml b/specs/005-add-insert-command/contracts/insert-command.yaml new file mode 100644 index 0000000..5046e8e --- /dev/null +++ b/specs/005-add-insert-command/contracts/insert-command.yaml @@ -0,0 +1,243 @@ +openapi: 3.1.0 +info: + title: Renamer Insert Command API + version: 0.1.0 + description: > + Contract representation of the `renamer insert` command workflows (preview/apply/undo) + for automation harnesses and documentation parity. +servers: + - url: cli://renamer + description: Command-line invocation surface +paths: + /insert/preview: + post: + summary: Preview insert operations + description: Mirrors `renamer insert --dry-run` + operationId: previewInsert + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/InsertRequest' + responses: + '200': + description: Successful preview + content: + application/json: + schema: + $ref: '#/components/schemas/InsertPreview' + '400': + description: Validation error (invalid position, empty text, etc.) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /insert/apply: + post: + summary: Apply insert operations + description: Mirrors `renamer insert --yes` + operationId: applyInsert + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/InsertRequest' + - type: object + properties: + dryRun: + type: boolean + const: false + responses: + '200': + description: Apply succeeded + content: + application/json: + schema: + $ref: '#/components/schemas/InsertApplyResult' + '409': + description: Conflict detected (duplicate targets, existing files) + content: + application/json: + schema: + $ref: '#/components/schemas/ConflictResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /insert/undo: + post: + summary: Undo latest insert batch + description: Mirrors `renamer undo` when last ledger entry corresponds to insert command. + operationId: undoInsert + responses: + '200': + description: Undo succeeded + content: + application/json: + schema: + $ref: '#/components/schemas/UndoResult' + '409': + description: Ledger inconsistent or no insert entry found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' +components: + schemas: + InsertRequest: + type: object + required: + - workingDir + - positionToken + - insertText + properties: + workingDir: + type: string + positionToken: + type: string + description: '^', '$', positive integer, or negative integer. + insertText: + type: string + description: Unicode string to insert (must not contain path separators). + includeDirs: + type: boolean + default: false + recursive: + type: boolean + default: false + includeHidden: + type: boolean + default: false + extensionFilter: + type: array + items: + type: string + dryRun: + type: boolean + default: true + autoConfirm: + type: boolean + default: false + InsertPreview: + 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' + InsertApplyResult: + 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 + - invalid_position + - existing_file + - existing_directory + PreviewEntry: + type: object + required: + - originalPath + - proposedPath + - status + properties: + originalPath: + type: string + proposedPath: + type: string + status: + type: string + enum: + - changed + - no_change + - skipped + insertedText: + 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' diff --git a/specs/005-add-insert-command/data-model.md b/specs/005-add-insert-command/data-model.md new file mode 100644 index 0000000..e4ec56f --- /dev/null +++ b/specs/005-add-insert-command/data-model.md @@ -0,0 +1,62 @@ +# Data Model – Insert Command + +## Entity: InsertRequest +- **Fields** + - `WorkingDir string` — Absolute path derived from CLI `--path` or current directory. + - `PositionToken string` — Raw user input (`^`, `$`, positive int, negative int) describing insertion point. + - `InsertText string` — Unicode string to insert. + - `IncludeDirs bool` — Mirrors `--include-dirs` scope flag. + - `Recursive bool` — Mirrors `--recursive`. + - `IncludeHidden bool` — True only when `--hidden` supplied. + - `ExtensionFilter []string` — Normalized tokens from `--extensions`. + - `DryRun bool` — Preview-only execution state. + - `AutoConfirm bool` — Captures `--yes` for non-interactive runs. + - `Timestamp time.Time` — Invocation timestamp for ledger correlation. +- **Validation Rules** + - Reject empty `InsertText`, path separators, or control characters. + - Ensure `PositionToken` parses to a valid rune index relative to the stem (`^` = 0, `$` = len, positive 1-based, negative = offset from end). + - Confirm resulting filename is non-empty and does not change extension semantics. +- **Relationships** + - Consumed by insert engine to plan operations. + - Serialized into ledger metadata with `InsertSummary`. + +## Entity: InsertSummary +- **Fields** + - `TotalCandidates int` — Items inspected after scope filtering. + - `TotalChanged int` — Entries that will change after insertion. + - `NoChange int` — Entries already containing target string at position (if applicable). + - `Conflicts []Conflict` — Target collisions or invalid positions. + - `Warnings []string` — Validation notices (duplicates, trimmed tokens, skipped hidden items). + - `Entries []PreviewEntry` — Ordered original/proposed mappings with status. + - `LedgerMetadata map[string]any` — Snapshot persisted with ledger entry (position, text, scope flags). +- **Validation Rules** + - Conflicts must be empty before apply. + - `TotalChanged + NoChange` equals count of entries with status `changed` or `no_change`. + - Entries sorted deterministically by original path. +- **Relationships** + - Emitted to preview renderer and ledger writer. + - Input for undo verification. + +## Entity: Conflict +- **Fields** + - `OriginalPath string` + - `ProposedPath string` + - `Reason string` — (`duplicate_target`, `invalid_position`, `existing_file`, etc.) +- **Validation Rules** + - `ProposedPath` unique among planned operations. + - Reason restricted to known enum values for messaging. +- **Relationships** + - Reported in preview output and used to block apply. + +## Entity: PreviewEntry +- **Fields** + - `OriginalPath string` + - `ProposedPath string` + - `Status string` — `changed`, `no_change`, `skipped`. + - `InsertedText string` — Text segment inserted (for auditing). +- **Validation Rules** + - `ProposedPath` equals `OriginalPath` when `Status == "no_change"`. + - `InsertedText` empty only for `no_change` or `skipped`. +- **Relationships** + - Displayed in preview output. + - Persisted with ledger metadata for undo playback. diff --git a/specs/005-add-insert-command/plan.md b/specs/005-add-insert-command/plan.md new file mode 100644 index 0000000..f538d18 --- /dev/null +++ b/specs/005-add-insert-command/plan.md @@ -0,0 +1,105 @@ +# Implementation Plan: Insert Command for Positional Text Injection + +**Branch**: `005-add-insert-command` | **Date**: 2025-10-30 | **Spec**: `specs/005-add-insert-command/spec.md` +**Input**: Feature specification from `/specs/005-add-insert-command/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 insert` subcommand that inserts a specified string into filenames at designated positions (start, end, absolute, relative) with Unicode-aware behavior, deterministic previews, ledger-backed undo, and automation-friendly outputs. + +## Technical Context + + + +**Language/Version**: Go 1.24 +**Primary Dependencies**: `spf13/cobra`, `spf13/pflag`, internal traversal/history/output packages +**Storage**: Local filesystem + `.renamer` ledger files +**Testing**: `go test ./...`, contract + integration suites under `tests/`, new smoke script +**Target Platform**: Cross-platform CLI (Linux, macOS, Windows shells) +**Project Type**: Single CLI project (`cmd/`, `internal/`, `tests/`, `scripts/`) +**Performance Goals**: 500-file insert (preview+apply) completes in <2 minutes end-to-end +**Constraints**: Unicode-aware insertion points, preview-first safety, ledger reversibility +**Scale/Scope**: Operates on thousands of filesystem entries per invocation within local directories + +## 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 insert preview to render original → proposed names with highlighted insertion segments before any apply. +- Undo strategy MUST describe how the `.renamer` ledger entry is written and reversed (Persistent Undo Ledger). ✅ Reuse ledger append with metadata (position token, inserted string) and ensure `undo` replays operations safely. +- Planned rename rules MUST document their inputs, validations, and composing order (Composable Rule Engine). ✅ Define an insert rule consuming position tokens, performing Unicode-aware slicing, and integrating with existing traversal pipeline. +- Scope handling MUST cover files vs directories (`-d`), recursion (`-r`), and extension filtering via `-e` without escaping the requested path (Scope-Aware Traversal). ✅ Leverage shared listing/traversal flags so insert respects scope filters and hidden/default exclusions. +- CLI UX plan MUST confirm Cobra usage, flag naming, help text, and automated tests for preview/undo flows (Ergonomic CLI Stewardship). ✅ Add Cobra subcommand with consistent flags, help examples, contract/integration tests, and smoke coverage. + +*Post-Design Verification (2025-10-30): Research and design artifacts document preview behavior, ledger metadata, Unicode-aware positions, and CLI 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) + + +```text +cmd/ +├── root.go +├── list.go +├── replace.go +├── remove.go +├── extension.go +└── undo.go + +internal/ +├── filters/ +├── history/ +├── listing/ +├── output/ +├── remove/ +├── replace/ +├── extension/ +└── traversal/ + +tests/ +├── contract/ +├── integration/ +├── fixtures/ +└── unit/ + +scripts/ +├── smoke-test-list.sh +├── smoke-test-replace.sh +├── smoke-test-remove.sh +└── smoke-test-extension.sh +``` + +**Structure Decision**: Extend the single CLI project by adding new `cmd/insert.go`, `internal/insert/` package, contract/integration coverage under existing `tests/` hierarchy, and an insert smoke script alongside other command scripts. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/specs/005-add-insert-command/quickstart.md b/specs/005-add-insert-command/quickstart.md new file mode 100644 index 0000000..5a95736 --- /dev/null +++ b/specs/005-add-insert-command/quickstart.md @@ -0,0 +1,28 @@ +# Quickstart – Insert Command + +1. **Preview an insertion at the start of filenames.** + ```bash + renamer insert ^ "[2025] " --dry-run + ``` + - Shows the prepended tag without applying changes. + - Useful for tagging archival folders. + +2. **Insert text near the end while preserving extensions.** + ```bash + renamer insert -1 "_FINAL" --path ./reports --dry-run + ``` + - `-1` places the string before the last character of the stem. + - Combine with `--extensions` to limit to specific file types. + +3. **Commit changes once preview looks correct.** + ```bash + renamer insert 3 "_QA" --yes --path ./builds + ``` + - `--yes` auto-confirms using the last preview. + - Ledger entry records the position token and inserted text. + +4. **Undo the most recent insert batch if needed.** + ```bash + renamer undo --path ./builds + ``` + - Restores original names using ledger metadata. diff --git a/specs/005-add-insert-command/research.md b/specs/005-add-insert-command/research.md new file mode 100644 index 0000000..cb6de60 --- /dev/null +++ b/specs/005-add-insert-command/research.md @@ -0,0 +1,17 @@ +# Phase 0 Research – Insert Command + +## Decision: Reuse Traversal + Preview Pipeline for Insert Rule +- **Rationale**: Existing replace/remove/extension commands share a scope walker and preview formatter that already respect `--path`, `-r`, `--hidden`, and ledger logging. Extending this infrastructure minimizes duplication and guarantees constitutional compliance for preview-first safety. +- **Alternatives considered**: Building a standalone walker for insert was rejected because it risks divergence in conflict handling and ledger metadata. Hooking insert into `replace` internals was rejected to keep rule responsibilities separated. + +## Decision: Interpret Positions Using Unicode Code Points on Filename Stems +- **Rationale**: Go’s rune indexing treats each Unicode code point as one element, aligning with user expectations for multilingual filenames. Operating on the stem (excluding extension) keeps behavior consistent with common batch-renaming tools. +- **Alternatives considered**: Byte-based offsets were rejected because multi-byte characters would break user expectations. Treating the full filename including extension was rejected to avoid forcing users to re-add extensions manually. + +## Decision: Ledger Metadata Includes Position Token and Inserted Text +- **Rationale**: Storing the position directive (`^`, `$`, positive, negative) and inserted string enables precise undo, auditing, and potential future analytics. This mirrors how replace/remove log the rule context. +- **Alternatives considered**: Logging only before/after paths was rejected because it obscures the applied rule and complicates debugging automated runs. + +## Decision: Block Apply on Duplicate Targets or Invalid Positions +- **Rationale**: Preventing collisions and out-of-range indices prior to file mutations preserves data integrity and meets preview-first guarantees. Existing conflict detection helpers can be adapted for insert. +- **Alternatives considered**: Allowing apply with overwrites was rejected due to high data-loss risk. Auto-truncating positions was rejected because silent fallback leads to inconsistent results across files. diff --git a/specs/005-add-insert-command/spec.md b/specs/005-add-insert-command/spec.md new file mode 100644 index 0000000..ca63c39 --- /dev/null +++ b/specs/005-add-insert-command/spec.md @@ -0,0 +1,101 @@ +# Feature Specification: Insert Command for Positional Text Injection + +**Feature Branch**: `005-add-insert-command` +**Created**: 2025-10-30 +**Status**: Draft +**Input**: User description: "实现插入(Insert)字符,支持在文件名指定位置插入指定字符串数据,示例 renamer insert , position:可以包含 ^:开头、$:结尾、 正数:字符位置、负数:倒数字符位置。!!重要:需要考虑中文字符" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Insert Text at Target Position (Priority: P1) + +As a power user organizing media files, I want to insert a label into filenames at a specific character position so that I can batch-tag assets without manually editing each name. + +**Why this priority**: Provides the core value proposition of the insert command—precise, repeatable filename updates that accelerate organization tasks. + +**Independent Test**: In a sample directory with mixed filenames, run `renamer insert 3 _tag --dry-run` and verify the preview shows the marker inserted at the third character (Unicode-aware). Re-run with `--yes` to confirm filesystem changes and ledger entry. + +**Acceptance Scenarios**: + +1. **Given** files named `项目A报告.docx` and `项目B报告.docx`, **When** the user runs `renamer insert ^ 2025- --dry-run`, **Then** the preview lists `2025-项目A报告.docx` and `2025-项目B报告.docx`. +2. **Given** files named `holiday.jpg` and `trip.jpg`, **When** the user runs `renamer insert -1 X --yes`, **Then** the apply step inserts `X` before the last character of each base name while preserving extensions. + +--- + +### User Story 2 - Automation-Friendly Batch Inserts (Priority: P2) + +As an operator running renamer in CI, I need deterministic exit codes, ledger metadata, and undo support when inserting text so scripted jobs remain auditable and reversible. + +**Why this priority**: Ensures production and automation workflows can rely on the new command without risking data loss. + +**Independent Test**: Execute `renamer insert $ _ARCHIVE --yes --path ./fixtures`, verify exit code `0`, inspect the latest `.renamer` entry for recorded positions/string, then run `renamer undo` to restore originals. + +**Acceptance Scenarios**: + +1. **Given** a non-interactive run with `--yes`, **When** insertion completes without conflicts, **Then** exit code is `0` and the ledger stores the original names, position rule, inserted text, and timestamps. +2. **Given** a ledger entry produced by `renamer insert`, **When** `renamer undo` executes, **Then** all affected files revert to their exact previous names even across locales. + +--- + +### User Story 3 - Validate Positions and Multilingual Inputs (Priority: P3) + +As a user preparing filenames with multilingual characters, I want validation and preview warnings for invalid positions, overlapping results, or unsupported encodings so I can adjust commands before committing changes. + +**Why this priority**: Protects against data corruption, especially with Unicode characters where byte counts differ from visible characters. + +**Independent Test**: Run `renamer insert 50 _X --dry-run` on files shorter than 50 code points and confirm the command exits with a non-zero status explaining the out-of-range index; validate that Chinese filenames are handled correctly in previews. + +**Acceptance Scenarios**: + +1. **Given** an index larger than the filename length, **When** the command runs, **Then** it fails with a descriptive error and no filesystem changes occur. +2. **Given** an insertion that would create duplicate names, **When** preview executes, **Then** conflicts are surfaced and apply is blocked until resolved. + +--- + +### Edge Cases + +- What happens when the requested position is outside the filename length (positive or negative)? +- How are filenames handled when inserting before/after Unicode characters or surrogate pairs? +- How are directories or hidden files treated when `--include-dirs` or `--hidden` is omitted or provided? +- What feedback is provided when insertion would produce duplicate targets or empty names? +- How does the command behave when the inserted string is empty, whitespace-only, or contains path separators? +- What occurs when multiple files differ only by case and the insert results in conflicting targets on case-insensitive filesystems? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: CLI MUST provide a dedicated `insert` subcommand accepting a positional argument (`^`, `$`, positive integer, or negative integer) and the string to insert. +- **FR-002**: Insert positions MUST be interpreted using Unicode code points on the filename stem (excluding extension) so multi-byte characters count as a single position. +- **FR-003**: The command MUST support scope flags (`--path`, `--recursive`, `--include-dirs`, `--hidden`, `--extensions`, `--dry-run`, `--yes`) consistent with existing commands. +- **FR-004**: Preview MUST display original and proposed names, highlighting inserted segments, and block apply when conflicts or invalid positions are detected. +- **FR-005**: Apply MUST update filesystem entries atomically per batch, record operations in the `.renamer` ledger with inserted string, position rule, affected files, and timestamps, and support undo. +- **FR-006**: Validation MUST reject positions outside the allowable range, empty insertion strings (unless explicitly allowed), and inputs containing path separators or control characters. +- **FR-007**: Help output MUST describe position semantics (`^`, `$`, positive, negative), Unicode handling, and examples for both files and directories. +- **FR-008**: Automation runs with `--yes` MUST emit deterministic exit codes (`0` success, non-zero on validation/conflicts) and human-readable messages that can be parsed for errors. + +### Key Entities + +- **InsertRequest**: Working directory, position token, insertion string, scope flags, dry-run/apply mode. +- **InsertSummary**: Counts of processed items, per-position match details, conflicts, warnings, and preview entries with status (`changed`, `no_change`, `skipped`). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users insert text into 500 filenames (preview + apply) in under 2 minutes end-to-end. +- **SC-002**: 95% of beta users correctly apply a positional insert after reading `renamer insert --help` without additional guidance. +- **SC-003**: Automated regression tests confirm insert + undo cycles leave the filesystem unchanged in 100% of scripted scenarios. +- **SC-004**: Support tickets related to manual filename labeling drop by 30% within the first release cycle post-launch. + +## Assumptions + +- Positions operate on the filename stem (path excluded, extension preserved); inserting at `$` occurs immediately before the extension dot when present. +- Empty insertion strings are treated as invalid to avoid silent no-ops; users must provide at least one visible character. +- Unicode normalization is assumed to be NFC; filenames are treated as sequences of Unicode code points using the runtime’s native string handling. + +## Dependencies & Risks + +- Requires reuse and possible extension of existing traversal, preview, and ledger infrastructure to accommodate positional operations. +- Accurate Unicode handling depends on the runtime’s Unicode utilities; additional testing may be necessary for combining marks and surrogate pairs. +- Insertion near filesystem path separators must be restricted to avoid creating invalid paths or escape sequences. diff --git a/specs/005-add-insert-command/tasks.md b/specs/005-add-insert-command/tasks.md new file mode 100644 index 0000000..0dbd258 --- /dev/null +++ b/specs/005-add-insert-command/tasks.md @@ -0,0 +1,158 @@ +# Tasks: Insert Command for Positional Text Injection + +**Input**: Design documents from `/specs/005-add-insert-command/` +**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/ + +**Tests**: Contract and integration tests included per spec emphasis on preview determinism, ledger integrity, and Unicode handling. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Establish initial command scaffolding and directories. + +- [X] T001 Create insert package scaffolding `internal/insert/doc.go` +- [X] T002 Add placeholder Cobra command entry point `cmd/insert.go` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core parsing, summary structures, and helpers needed by all stories. + +- [X] T003 Define `InsertRequest` builder and execution mode helpers in `internal/insert/request.go` +- [X] T004 Implement `InsertSummary`, preview entries, and conflict types in `internal/insert/summary.go` +- [X] T005 Build Unicode-aware position parsing and normalization utilities in `internal/insert/positions.go` + +**Checkpoint**: Foundation ready — user story implementation can now begin. + +--- + +## Phase 3: User Story 1 – Insert Text at Target Position (Priority: P1) 🎯 MVP + +**Goal**: Provide preview + apply flow that inserts text at specified positions with Unicode handling. + +**Independent Test**: `renamer insert 3 _tag --dry-run` confirms preview insertion per code point ordering; `--yes` applies and ledger logs metadata. + +### Tests + +- [X] T006 [P] [US1] Add contract preview/apply coverage in `tests/contract/insert_command_test.go` +- [X] T007 [P] [US1] Add integration flow test for positional insert in `tests/integration/insert_flow_test.go` + +### Implementation + +- [X] T008 [US1] Implement planning engine to compute proposed names in `internal/insert/engine.go` +- [X] T009 [US1] Render preview output with highlighted segments in `internal/insert/preview.go` +- [X] T010 [US1] Apply filesystem changes with ledger logging in `internal/insert/apply.go` +- [X] T011 [US1] Wire Cobra command to parse args, perform preview/apply in `cmd/insert.go` + +**Checkpoint**: User Story 1 functionality testable end-to-end. + +--- + +## Phase 4: User Story 2 – Automation-Friendly Batch Inserts (Priority: P2) + +**Goal**: Ensure ledger metadata, undo, and exit codes support automation. + +**Independent Test**: `renamer insert $ _ARCHIVE --yes --path ./fixtures` exits `0` with ledger metadata; `renamer undo` restores filenames. + +### Tests + +- [ ] T012 [P] [US2] Extend contract tests for ledger metadata & exit codes in `tests/contract/insert_ledger_test.go` +- [ ] T013 [P] [US2] Add automation/undo integration scenario in `tests/integration/insert_undo_test.go` + +### Implementation + +- [ ] T014 [US2] Persist position token and inserted text in ledger metadata via `internal/insert/apply.go` +- [ ] T015 [US2] Enhance undo CLI feedback for insert batches in `cmd/undo.go` +- [ ] T016 [US2] Ensure zero-match runs exit `0` with notice in `cmd/insert.go` + +**Checkpoint**: User Stories 1 & 2 independently verifiable. + +--- + +## Phase 5: User Story 3 – Validate Positions and Multilingual Inputs (Priority: P3) + +**Goal**: Robust validation, conflict detection, and messaging for out-of-range or conflicting inserts. + +**Independent Test**: Invalid positions produce descriptive errors; duplicate targets block apply; Chinese filenames preview correctly. + +### Tests + +- [X] T017 [P] [US3] Add validation/conflict contract coverage in `tests/contract/insert_validation_test.go` +- [X] T018 [P] [US3] Add conflict-blocking integration scenario in `tests/integration/insert_validation_test.go` + +### Implementation + +- [X] T019 [US3] Implement parsing + error messaging for position tokens in `internal/insert/parser.go` +- [X] T020 [US3] Detect conflicting targets and report warnings in `internal/insert/conflicts.go` +- [X] T021 [US3] Surface validation failures and conflict gating in `cmd/insert.go` + +**Checkpoint**: All user stories function with robust validation. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Documentation, tooling, and quality improvements. + +- [X] T022 Update CLI flags documentation for insert command in `docs/cli-flags.md` +- [X] T023 Add insert smoke test script `scripts/smoke-test-insert.sh` +- [X] T024 Run gofmt and `go test ./...` from repo root `./` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +1. Phase 1 (Setup) → groundwork for new command/package. +2. Phase 2 (Foundational) → required before user story work. +3. Phase 3 (US1) → delivers MVP after foundational tasks. +4. Phase 4 (US2) → builds on US1 for automation support. +5. Phase 5 (US3) → extends validation/conflict handling. +6. Phase 6 (Polish) → final documentation and quality checks. + +### User Story Dependencies + +- US1 depends on foundational tasks only. +- US2 depends on US1 implementation (ledger/apply logic). +- US3 depends on US1 preview/apply and US2 ledger updates. + +### Task Dependencies (selected) + +- T008 requires T003–T005. +- T009, T010 depend on T008. +- T011 depends on T008–T010. +- T014 depends on T010. +- T015 depends on T014. +- T019 depends on T003, T005. +- T020 depends on T008, T009. +- T021 depends on T019–T020. + +--- + +## Parallel Execution Examples + +- Within US1, tasks T006 and T007 can run in parallel once T011 is in progress. +- Within US2, tests T012/T013 may execute while T014–T016 are implemented. +- Within US3, contract vs integration tests (T017/T018) can proceed concurrently after T021 adjustments. + +--- + +## Implementation Strategy + +### MVP (US1) +1. Complete Phases 1–2 foundation. +2. Deliver Phase 3 (US1) to enable core insert functionality. +3. Validate via contract/integration tests (T006/T007) and manual dry-run/apply checks. + +### Incremental Delivery +- Phase 4 adds automation/undo guarantees after MVP. +- Phase 5 hardens validation and conflict management. +- Phase 6 completes documentation, smoke coverage, and regression checks. + +### Parallel Approach +- One developer handles foundational + US1 engine. +- Another focuses on test coverage and CLI wiring after foundations. +- Additional developer can own US2 automation tasks while US1 finalizes, then US3 validation enhancements. diff --git a/testdata/insert/sample/.hiddenfile b/testdata/insert/sample/.hiddenfile new file mode 100644 index 0000000..44f3237 --- /dev/null +++ b/testdata/insert/sample/.hiddenfile @@ -0,0 +1 @@ +hidden content diff --git a/testdata/insert/sample/holiday.jpg b/testdata/insert/sample/holiday.jpg new file mode 100644 index 0000000..605070e --- /dev/null +++ b/testdata/insert/sample/holiday.jpg @@ -0,0 +1 @@ +binary-placeholder diff --git a/testdata/insert/sample/nested/summary.md b/testdata/insert/sample/nested/summary.md new file mode 100644 index 0000000..8b0c240 --- /dev/null +++ b/testdata/insert/sample/nested/summary.md @@ -0,0 +1 @@ +nested document diff --git a/testdata/insert/sample/notes.txt b/testdata/insert/sample/notes.txt new file mode 100644 index 0000000..b958726 --- /dev/null +++ b/testdata/insert/sample/notes.txt @@ -0,0 +1 @@ +meeting notes content diff --git a/testdata/insert/sample/项目A报告.docx b/testdata/insert/sample/项目A报告.docx new file mode 100644 index 0000000..b9f596f --- /dev/null +++ b/testdata/insert/sample/项目A报告.docx @@ -0,0 +1 @@ +一份中文报告内容 diff --git a/tests/contract/insert_command_test.go b/tests/contract/insert_command_test.go new file mode 100644 index 0000000..b4a9719 --- /dev/null +++ b/tests/contract/insert_command_test.go @@ -0,0 +1,91 @@ +package contract + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/rogeecn/renamer/internal/insert" + "github.com/rogeecn/renamer/internal/listing" +) + +func TestInsertPreviewAndApply(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + writeInsertFile(t, filepath.Join(tmp, "项目A报告.docx")) + writeInsertFile(t, filepath.Join(tmp, "项目B报告.docx")) + + 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 := insert.NewRequest(scope) + req.SetExecutionMode(true, false) + req.SetPositionAndText("^", "2025-") + + var buf bytes.Buffer + summary, planned, err := insert.Preview(context.Background(), req, &buf) + if err != nil { + t.Fatalf("Preview error: %v", err) + } + + if summary.TotalCandidates != 2 { + t.Fatalf("expected 2 candidates, got %d", summary.TotalCandidates) + } + if summary.TotalChanged != 2 { + t.Fatalf("expected 2 changes, got %d", summary.TotalChanged) + } + + output := buf.String() + if !containsAll(output, "2025-项目A报告.docx", "2025-项目B报告.docx") { + t.Fatalf("preview output missing expected names: %s", output) + } + + req.SetExecutionMode(false, true) + entry, err := insert.Apply(context.Background(), req, planned, summary) + if err != nil { + t.Fatalf("Apply error: %v", err) + } + + if len(entry.Operations) != 2 { + t.Fatalf("expected 2 ledger operations, got %d", len(entry.Operations)) + } + + if _, err := os.Stat(filepath.Join(tmp, "项目A报告.docx")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected original name to be renamed: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "2025-项目A报告.docx")); err != nil { + t.Fatalf("expected renamed file: %v", err) + } +} + +func writeInsertFile(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) + } +} + +func containsAll(haystack string, needles ...string) bool { + for _, n := range needles { + if !bytes.Contains([]byte(haystack), []byte(n)) { + return false + } + } + return true +} diff --git a/tests/contract/insert_ledger_test.go b/tests/contract/insert_ledger_test.go new file mode 100644 index 0000000..93fa0c2 --- /dev/null +++ b/tests/contract/insert_ledger_test.go @@ -0,0 +1,106 @@ +package contract + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + renamercmd "github.com/rogeecn/renamer/cmd" + "github.com/rogeecn/renamer/internal/history" + "github.com/rogeecn/renamer/internal/insert" + "github.com/rogeecn/renamer/internal/listing" +) + +func TestInsertLedgerMetadataAndUndo(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + writeInsertLedgerFile(t, filepath.Join(tmp, "doc.txt")) + + 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 := insert.NewRequest(scope) + req.SetExecutionMode(false, true) + req.SetPositionAndText("$", "_ARCHIVE") + + summary, planned, err := insert.Preview(context.Background(), req, nil) + if err != nil { + t.Fatalf("Preview error: %v", err) + } + + entry, err := insert.Apply(context.Background(), req, planned, summary) + if err != nil { + t.Fatalf("Apply error: %v", err) + } + + meta := entry.Metadata + if meta == nil { + t.Fatalf("expected metadata to be recorded") + } + + if got := meta["insertText"]; got != "_ARCHIVE" { + t.Fatalf("expected insertText metadata, got %v", got) + } + if got := meta["positionToken"]; got != "$" { + t.Fatalf("expected position token metadata, got %v", got) + } + scopeMeta, ok := meta["scope"].(map[string]any) + if !ok { + t.Fatalf("expected scope metadata, got %T", meta["scope"]) + } + if includeHidden, _ := scopeMeta["includeHidden"].(bool); includeHidden { + t.Fatalf("expected includeHidden to be false") + } + + undoEntry, err := history.Undo(tmp) + if err != nil { + t.Fatalf("undo error: %v", err) + } + if undoEntry.Command != "insert" { + t.Fatalf("expected undo command to be insert, got %s", undoEntry.Command) + } + if _, err := os.Stat(filepath.Join(tmp, "doc.txt")); err != nil { + t.Fatalf("expected original file restored: %v", err) + } +} + +func TestInsertZeroMatchExitsSuccessfully(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + var out bytes.Buffer + + cmd := renamercmd.NewRootCommand() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"insert", "^", "TEST", "--yes", "--path", tmp}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected zero-match insert to succeed, err=%v output=%s", err, out.String()) + } + if !strings.Contains(out.String(), "No candidates found.") { + t.Fatalf("expected zero-match notice, output=%s", out.String()) + } +} + +func writeInsertLedgerFile(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("ledger"), 0o644); err != nil { + t.Fatalf("write file %s: %v", path, err) + } +} diff --git a/tests/contract/insert_validation_test.go b/tests/contract/insert_validation_test.go new file mode 100644 index 0000000..27f5eb2 --- /dev/null +++ b/tests/contract/insert_validation_test.go @@ -0,0 +1,78 @@ +package contract + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/rogeecn/renamer/internal/insert" + "github.com/rogeecn/renamer/internal/listing" +) + +func TestInsertRejectsOutOfRangePositions(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + writeInsertValidationFile(t, filepath.Join(tmp, "短.txt")) + + 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 := insert.NewRequest(scope) + req.SetExecutionMode(true, false) + req.SetPositionAndText("50", "X") + + if _, _, err := insert.Preview(context.Background(), req, nil); err == nil { + t.Fatalf("expected error for out-of-range position") + } +} + +func TestInsertBlocksExistingTargetConflicts(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + writeInsertValidationFile(t, filepath.Join(tmp, "report.txt")) + writeInsertValidationFile(t, filepath.Join(tmp, "report_ARCHIVE.txt")) + + 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 := insert.NewRequest(scope) + req.SetExecutionMode(true, false) + req.SetPositionAndText("$", "_ARCHIVE") + + summary, _, err := insert.Preview(context.Background(), req, nil) + if err != nil { + t.Fatalf("preview error: %v", err) + } + if !summary.HasConflicts() { + t.Fatalf("expected conflicts to be detected") + } +} + +func writeInsertValidationFile(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("validation"), 0o644); err != nil { + t.Fatalf("write file %s: %v", path, err) + } +} diff --git a/tests/integration/insert_flow_test.go b/tests/integration/insert_flow_test.go new file mode 100644 index 0000000..79cb5a1 --- /dev/null +++ b/tests/integration/insert_flow_test.go @@ -0,0 +1,69 @@ +package integration + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + renamercmd "github.com/rogeecn/renamer/cmd" +) + +func TestInsertCommandFlow(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + createInsertFile(t, filepath.Join(tmp, "holiday.jpg")) + createInsertFile(t, filepath.Join(tmp, "trip.jpg")) + + var previewOut bytes.Buffer + preview := renamercmd.NewRootCommand() + preview.SetOut(&previewOut) + preview.SetErr(&previewOut) + preview.SetArgs([]string{"insert", "3", "_tag", "--dry-run", "--path", tmp}) + + if err := preview.Execute(); err != nil { + t.Fatalf("preview command failed: %v\noutput: %s", err, previewOut.String()) + } + + if !contains(t, previewOut.String(), "hol_tagiday.jpg", "tri_tagp.jpg") { + t.Fatalf("preview output missing expected inserts: %s", previewOut.String()) + } + + var applyOut bytes.Buffer + apply := renamercmd.NewRootCommand() + apply.SetOut(&applyOut) + apply.SetErr(&applyOut) + apply.SetArgs([]string{"insert", "3", "_tag", "--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, "hol_tagiday.jpg")); err != nil { + t.Fatalf("expected renamed file: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "tri_tagp.jpg")); err != nil { + t.Fatalf("expected renamed file: %v", err) + } +} + +func contains(t *testing.T, haystack string, expected ...string) bool { + t.Helper() + for _, s := range expected { + if !bytes.Contains([]byte(haystack), []byte(s)) { + return false + } + } + return true +} + +func createInsertFile(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatalf("write file %s: %v", path, err) + } +} diff --git a/tests/integration/insert_undo_test.go b/tests/integration/insert_undo_test.go new file mode 100644 index 0000000..92fff7d --- /dev/null +++ b/tests/integration/insert_undo_test.go @@ -0,0 +1,60 @@ +package integration + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + renamercmd "github.com/rogeecn/renamer/cmd" +) + +func TestInsertAutomationUndo(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + createInsertAutomationFile(t, filepath.Join(tmp, "config.yaml")) + + var applyOut bytes.Buffer + apply := renamercmd.NewRootCommand() + apply.SetOut(&applyOut) + apply.SetErr(&applyOut) + apply.SetArgs([]string{"insert", "$", "_ARCHIVE", "--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, "config_ARCHIVE.yaml")); err != nil { + t.Fatalf("expected renamed file: %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, "config.yaml")); err != nil { + t.Fatalf("expected original file restored: %v", err) + } + + if !strings.Contains(undoOut.String(), "Inserted text") { + t.Fatalf("expected undo output to describe inserted text, got: %s", undoOut.String()) + } +} + +func createInsertAutomationFile(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("automation"), 0o644); err != nil { + t.Fatalf("write file %s: %v", path, err) + } +} diff --git a/tests/integration/insert_validation_test.go b/tests/integration/insert_validation_test.go new file mode 100644 index 0000000..0a3d7d2 --- /dev/null +++ b/tests/integration/insert_validation_test.go @@ -0,0 +1,55 @@ +package integration + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + renamercmd "github.com/rogeecn/renamer/cmd" +) + +func TestInsertValidationConflictsBlockApply(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + createInsertValidationFile(t, filepath.Join(tmp, "baseline.txt")) + createInsertValidationFile(t, filepath.Join(tmp, "baseline_MARKED.txt")) + + var out bytes.Buffer + cmd := renamercmd.NewRootCommand() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"insert", "$", "_MARKED", "--yes", "--path", tmp}) + + if err := cmd.Execute(); err == nil { + t.Fatalf("expected command to fail when conflicts present") + } +} + +func TestInsertValidationInvalidPosition(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + createInsertValidationFile(t, filepath.Join(tmp, "短.txt")) + + var out bytes.Buffer + cmd := renamercmd.NewRootCommand() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"insert", "50", "X", "--dry-run", "--path", tmp}) + + if err := cmd.Execute(); err == nil { + t.Fatalf("expected invalid position to produce error") + } +} + +func createInsertValidationFile(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("validation"), 0o644); err != nil { + t.Fatalf("write file %s: %v", path, err) + } +}