Add insert command

This commit is contained in:
Rogee
2025-10-30 15:15:16 +08:00
parent 6a353b5086
commit a0d7084c28
35 changed files with 2044 additions and 37 deletions

View File

@@ -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.
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

90
cmd/insert.go Normal file
View File

@@ -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 <position> <text>",
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())
}

View File

@@ -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

View File

@@ -29,13 +29,26 @@ 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 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)
}
}
}
}
return nil

View File

@@ -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 <pattern1> [pattern2 ...] <replacement> [flags]
renamer insert <position> <text> [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

85
internal/insert/apply.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

3
internal/insert/doc.go Normal file
View File

@@ -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

187
internal/insert/engine.go Normal file
View File

@@ -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
}

46
internal/insert/parser.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -1,6 +1,5 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package main

29
scripts/smoke-test-insert.sh Executable file
View File

@@ -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."

View File

@@ -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`

View File

@@ -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 <position> <text> --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 <position> <text> --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'

View File

@@ -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.

View File

@@ -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
<!--
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/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)
<!--
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
├── 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] |

View File

@@ -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.

View File

@@ -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**: Gos 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.

View File

@@ -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> <string>, 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 runtimes 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 runtimes 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.

View File

@@ -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 T003T005.
- T009, T010 depend on T008.
- T011 depends on T008T010.
- T014 depends on T010.
- T015 depends on T014.
- T019 depends on T003, T005.
- T020 depends on T008, T009.
- T021 depends on T019T020.
---
## 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 T014T016 are implemented.
- Within US3, contract vs integration tests (T017/T018) can proceed concurrently after T021 adjustments.
---
## Implementation Strategy
### MVP (US1)
1. Complete Phases 12 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.

1
testdata/insert/sample/.hiddenfile vendored Normal file
View File

@@ -0,0 +1 @@
hidden content

1
testdata/insert/sample/holiday.jpg vendored Normal file
View File

@@ -0,0 +1 @@
binary-placeholder

View File

@@ -0,0 +1 @@
nested document

1
testdata/insert/sample/notes.txt vendored Normal file
View File

@@ -0,0 +1 @@
meeting notes content

View File

@@ -0,0 +1 @@
一份中文报告内容

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}