Add insert command
This commit is contained in:
@@ -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)
|
- 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)
|
- Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/ledger packages (004-extension-rename)
|
||||||
- Local filesystem + `.renamer` ledger files (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
|
## Project Structure
|
||||||
|
|
||||||
@@ -40,9 +41,9 @@ tests/
|
|||||||
- Smoke: `scripts/smoke-test-replace.sh`, `scripts/smoke-test-remove.sh`
|
- Smoke: `scripts/smoke-test-replace.sh`, `scripts/smoke-test-remove.sh`
|
||||||
|
|
||||||
## Recent Changes
|
## 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
|
- 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
|
- 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 START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
90
cmd/insert.go
Normal file
90
cmd/insert.go
Normal 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())
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
cmd.AddCommand(NewReplaceCommand())
|
cmd.AddCommand(NewReplaceCommand())
|
||||||
cmd.AddCommand(NewRemoveCommand())
|
cmd.AddCommand(NewRemoveCommand())
|
||||||
cmd.AddCommand(NewExtensionCommand())
|
cmd.AddCommand(NewExtensionCommand())
|
||||||
|
cmd.AddCommand(newInsertCommand())
|
||||||
cmd.AddCommand(newUndoCommand())
|
cmd.AddCommand(newUndoCommand())
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|||||||
15
cmd/undo.go
15
cmd/undo.go
@@ -29,13 +29,26 @@ func newUndoCommand() *cobra.Command {
|
|||||||
out := cmd.OutOrStdout()
|
out := cmd.OutOrStdout()
|
||||||
fmt.Fprintf(out, "Undo applied: %d operations reversed\n", len(entry.Operations))
|
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 != "" {
|
if target, ok := entry.Metadata["targetExtension"].(string); ok && target != "" {
|
||||||
fmt.Fprintf(out, "Restored extensions to %s\n", target)
|
fmt.Fprintf(out, "Restored extensions to %s\n", target)
|
||||||
}
|
}
|
||||||
if sources, ok := entry.Metadata["sourceExtensions"].([]string); ok && len(sources) > 0 {
|
if sources, ok := entry.Metadata["sourceExtensions"].([]string); ok && len(sources) > 0 {
|
||||||
fmt.Fprintf(out, "Previous sources: %s\n", strings.Join(sources, ", "))
|
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
|
return nil
|
||||||
|
|||||||
@@ -2,52 +2,40 @@
|
|||||||
|
|
||||||
Renamer shares a consistent set of scope flags across every command that inspects or mutates the
|
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`,
|
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 |
|
| Flag | Default | Description |
|
||||||
|------|---------|-------------|
|
|------|---------|-------------|
|
||||||
| `--path` | `.` | Working directory root for traversal. |
|
| `--path` | `.` | Working directory root for traversal. |
|
||||||
| `-r`, `--recursive` | `false` | Traverse subdirectories depth-first. Symlinked directories are not followed. |
|
| `-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. |
|
| `-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. |
|
| `--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. |
|
| `--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`. |
|
| `--format` | `table` | Command-specific output formatting option. For `list`, use `table` or `plain`. |
|
||||||
|
|
||||||
## Validation Rules
|
## Insert Command Quick Reference
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
```bash
|
```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
|
- Position tokens:
|
||||||
literal patterns (quotes required when a pattern contains spaces).
|
- `^` inserts at the beginning of the filename.
|
||||||
- Patterns are applied sequentially and replaced with the same value. Duplicate patterns are
|
- `$` inserts immediately before the extension dot (or end if no extension).
|
||||||
deduplicated automatically and surfaced in the preview summary.
|
- Positive integers (1-based) count forward from the start of the stem.
|
||||||
- Empty replacement strings are allowed (effectively deleting each pattern) but the preview warns
|
- Negative integers count backward from the end of the stem (e.g., `-1` inserts before the last rune).
|
||||||
before confirmation.
|
- Text must be valid UTF-8 without path separators or control characters; Unicode characters are supported.
|
||||||
- Combine with scope flags (`--path`, `-r`, `--include-dirs`, etc.) to target the desired set of
|
- Scope flags (`--path`, `-r`, `-d`, `--hidden`, `--extensions`) limit the candidate set before insertion.
|
||||||
files/directories.
|
- `--dry-run` previews the plan; rerun with `--yes` to apply the same operations.
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Usage Examples
|
### Usage Examples
|
||||||
|
|
||||||
- Preview files recursively: `renamer --recursive preview`
|
- Preview adding a prefix: `renamer insert ^ "[2025] " --dry-run`
|
||||||
- List JPEGs only: `renamer --extensions .jpg list`
|
- Append before extension: `renamer insert $ _ARCHIVE --yes --path ./docs`
|
||||||
- Replace multiple patterns: `renamer replace draft Draft final --dry-run`
|
- Insert after third character in stem: `renamer insert 3 _tag --path ./images --dry-run`
|
||||||
- Include dotfiles: `renamer --hidden --extensions .env list`
|
- Combine with extension filter: `renamer insert ^ "v1_" --extensions .txt|.md`
|
||||||
|
|
||||||
## Remove Command Quick Reference
|
## Remove Command Quick Reference
|
||||||
|
|
||||||
|
|||||||
85
internal/insert/apply.go
Normal file
85
internal/insert/apply.go
Normal 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
|
||||||
|
}
|
||||||
32
internal/insert/conflicts.go
Normal file
32
internal/insert/conflicts.go
Normal 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
3
internal/insert/doc.go
Normal 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
187
internal/insert/engine.go
Normal 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
46
internal/insert/parser.go
Normal 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
|
||||||
|
}
|
||||||
74
internal/insert/positions.go
Normal file
74
internal/insert/positions.go
Normal 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
|
||||||
|
}
|
||||||
77
internal/insert/preview.go
Normal file
77
internal/insert/preview.go
Normal 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
|
||||||
|
}
|
||||||
86
internal/insert/request.go
Normal file
86
internal/insert/request.go
Normal 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
|
||||||
|
}
|
||||||
84
internal/insert/summary.go
Normal file
84
internal/insert/summary.go
Normal 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
|
||||||
|
}
|
||||||
1
main.go
1
main.go
@@ -1,6 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
|
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
|
||||||
|
|
||||||
*/
|
*/
|
||||||
package main
|
package main
|
||||||
|
|
||||||
|
|||||||
29
scripts/smoke-test-insert.sh
Executable file
29
scripts/smoke-test-insert.sh
Executable 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."
|
||||||
34
specs/005-add-insert-command/checklists/requirements.md
Normal file
34
specs/005-add-insert-command/checklists/requirements.md
Normal 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`
|
||||||
243
specs/005-add-insert-command/contracts/insert-command.yaml
Normal file
243
specs/005-add-insert-command/contracts/insert-command.yaml
Normal 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'
|
||||||
62
specs/005-add-insert-command/data-model.md
Normal file
62
specs/005-add-insert-command/data-model.md
Normal 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.
|
||||||
105
specs/005-add-insert-command/plan.md
Normal file
105
specs/005-add-insert-command/plan.md
Normal 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] |
|
||||||
28
specs/005-add-insert-command/quickstart.md
Normal file
28
specs/005-add-insert-command/quickstart.md
Normal 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.
|
||||||
17
specs/005-add-insert-command/research.md
Normal file
17
specs/005-add-insert-command/research.md
Normal 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**: 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.
|
||||||
101
specs/005-add-insert-command/spec.md
Normal file
101
specs/005-add-insert-command/spec.md
Normal 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 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.
|
||||||
158
specs/005-add-insert-command/tasks.md
Normal file
158
specs/005-add-insert-command/tasks.md
Normal 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 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.
|
||||||
1
testdata/insert/sample/.hiddenfile
vendored
Normal file
1
testdata/insert/sample/.hiddenfile
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hidden content
|
||||||
1
testdata/insert/sample/holiday.jpg
vendored
Normal file
1
testdata/insert/sample/holiday.jpg
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
binary-placeholder
|
||||||
1
testdata/insert/sample/nested/summary.md
vendored
Normal file
1
testdata/insert/sample/nested/summary.md
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
nested document
|
||||||
1
testdata/insert/sample/notes.txt
vendored
Normal file
1
testdata/insert/sample/notes.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
meeting notes content
|
||||||
1
testdata/insert/sample/项目A报告.docx
vendored
Normal file
1
testdata/insert/sample/项目A报告.docx
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
一份中文报告内容
|
||||||
91
tests/contract/insert_command_test.go
Normal file
91
tests/contract/insert_command_test.go
Normal 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
|
||||||
|
}
|
||||||
106
tests/contract/insert_ledger_test.go
Normal file
106
tests/contract/insert_ledger_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
tests/contract/insert_validation_test.go
Normal file
78
tests/contract/insert_validation_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
tests/integration/insert_flow_test.go
Normal file
69
tests/integration/insert_flow_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
tests/integration/insert_undo_test.go
Normal file
60
tests/integration/insert_undo_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
tests/integration/insert_validation_test.go
Normal file
55
tests/integration/insert_validation_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user