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)
|
||||
- Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/ledger packages (004-extension-rename)
|
||||
- Local filesystem + `.renamer` ledger files (004-extension-rename)
|
||||
- Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/history/output packages (005-add-insert-command)
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -40,9 +41,9 @@ tests/
|
||||
- Smoke: `scripts/smoke-test-replace.sh`, `scripts/smoke-test-remove.sh`
|
||||
|
||||
## Recent Changes
|
||||
- 005-add-insert-command: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/history/output packages
|
||||
- 004-extension-rename: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/ledger packages
|
||||
- 003-add-remove-command: Added sequential `renamer remove` subcommand, automation-friendly ledger metadata, and CLI warnings for duplicates/empty results
|
||||
- 002-add-replace-command: Added `renamer replace` command, ledger metadata, and automation docs.
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
90
cmd/insert.go
Normal file
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(NewRemoveCommand())
|
||||
cmd.AddCommand(NewExtensionCommand())
|
||||
cmd.AddCommand(newInsertCommand())
|
||||
cmd.AddCommand(newUndoCommand())
|
||||
|
||||
return cmd
|
||||
|
||||
15
cmd/undo.go
15
cmd/undo.go
@@ -29,13 +29,26 @@ func newUndoCommand() *cobra.Command {
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintf(out, "Undo applied: %d operations reversed\n", len(entry.Operations))
|
||||
|
||||
if entry.Command == "extension" && entry.Metadata != nil {
|
||||
if entry.Metadata != nil {
|
||||
switch entry.Command {
|
||||
case "extension":
|
||||
if target, ok := entry.Metadata["targetExtension"].(string); ok && target != "" {
|
||||
fmt.Fprintf(out, "Restored extensions to %s\n", target)
|
||||
}
|
||||
if sources, ok := entry.Metadata["sourceExtensions"].([]string); ok && len(sources) > 0 {
|
||||
fmt.Fprintf(out, "Previous sources: %s\n", strings.Join(sources, ", "))
|
||||
}
|
||||
case "insert":
|
||||
insertText, _ := entry.Metadata["insertText"].(string)
|
||||
positionToken, _ := entry.Metadata["positionToken"].(string)
|
||||
if insertText != "" {
|
||||
if positionToken != "" {
|
||||
fmt.Fprintf(out, "Inserted text %q removed from position %s\n", insertText, positionToken)
|
||||
} else {
|
||||
fmt.Fprintf(out, "Inserted text %q removed\n", insertText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -2,52 +2,40 @@
|
||||
|
||||
Renamer shares a consistent set of scope flags across every command that inspects or mutates the
|
||||
filesystem. Use these options at the root command level so they apply to all subcommands (`list`,
|
||||
`replace`, future `preview`/`rename`, etc.).
|
||||
`replace`, `insert`, `remove`, etc.).
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--path` | `.` | Working directory root for traversal. |
|
||||
| `-r`, `--recursive` | `false` | Traverse subdirectories depth-first. Symlinked directories are not followed. |
|
||||
| `-d`, `--include-dirs` | `false` | Limit results to directories only (files and symlinks are suppressed). Directory traversal still occurs even when the flag is absent. |
|
||||
| `-d`, `--include-dirs` | `false` | Include directories in results. |
|
||||
| `-e`, `--extensions` | *(none)* | Pipe-separated list of file extensions (e.g. `.jpg|.mov`). Tokens must start with a dot, are lowercased internally, and duplicates are ignored. |
|
||||
| `--hidden` | `false` | Include dot-prefixed files and directories. By default they are excluded from listings and rename previews. |
|
||||
| `--yes` | `false` | Apply changes without an interactive confirmation prompt (mutating commands only). |
|
||||
| `--yes` | `false` | Apply changes without interactive confirmation (mutating commands only). |
|
||||
| `--dry-run` | `false` | Force preview-only behavior even when `--yes` is supplied. |
|
||||
| `--format` | `table` | Command-specific output formatting option. For `list`, use `table` or `plain`. |
|
||||
|
||||
## Validation Rules
|
||||
|
||||
- Extension tokens that are empty or missing the leading `.` cause validation errors.
|
||||
- Filters that match zero entries result in a friendly message and exit code `0`.
|
||||
- Invalid flag combinations (e.g., unsupported `--format` values) cause the command to exit with a non-zero code.
|
||||
- Recursive traversal honor `--hidden` and skips unreadable directories while logging warnings.
|
||||
|
||||
Keep this document updated whenever a new command is introduced or the global scope behavior
|
||||
changes.
|
||||
|
||||
## Replace Command Quick Reference
|
||||
## Insert Command Quick Reference
|
||||
|
||||
```bash
|
||||
renamer replace <pattern1> [pattern2 ...] <replacement> [flags]
|
||||
renamer insert <position> <text> [flags]
|
||||
```
|
||||
|
||||
- The **final positional argument** is the replacement value; all preceding arguments are treated as
|
||||
literal patterns (quotes required when a pattern contains spaces).
|
||||
- Patterns are applied sequentially and replaced with the same value. Duplicate patterns are
|
||||
deduplicated automatically and surfaced in the preview summary.
|
||||
- Empty replacement strings are allowed (effectively deleting each pattern) but the preview warns
|
||||
before confirmation.
|
||||
- Combine with scope flags (`--path`, `-r`, `--include-dirs`, etc.) to target the desired set of
|
||||
files/directories.
|
||||
- Use `--dry-run` to preview in scripts, then `--yes` to apply once satisfied; combining both flags
|
||||
exits with an error to prevent accidental automation mistakes.
|
||||
- Position tokens:
|
||||
- `^` inserts at the beginning of the filename.
|
||||
- `$` inserts immediately before the extension dot (or end if no extension).
|
||||
- Positive integers (1-based) count forward from the start of the stem.
|
||||
- Negative integers count backward from the end of the stem (e.g., `-1` inserts before the last rune).
|
||||
- Text must be valid UTF-8 without path separators or control characters; Unicode characters are supported.
|
||||
- Scope flags (`--path`, `-r`, `-d`, `--hidden`, `--extensions`) limit the candidate set before insertion.
|
||||
- `--dry-run` previews the plan; rerun with `--yes` to apply the same operations.
|
||||
|
||||
### Usage Examples
|
||||
|
||||
- Preview files recursively: `renamer --recursive preview`
|
||||
- List JPEGs only: `renamer --extensions .jpg list`
|
||||
- Replace multiple patterns: `renamer replace draft Draft final --dry-run`
|
||||
- Include dotfiles: `renamer --hidden --extensions .env list`
|
||||
- Preview adding a prefix: `renamer insert ^ "[2025] " --dry-run`
|
||||
- Append before extension: `renamer insert $ _ARCHIVE --yes --path ./docs`
|
||||
- Insert after third character in stem: `renamer insert 3 _tag --path ./images --dry-run`
|
||||
- Combine with extension filter: `renamer insert ^ "v1_" --extensions .txt|.md`
|
||||
|
||||
## Remove Command Quick Reference
|
||||
|
||||
|
||||
85
internal/insert/apply.go
Normal file
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
|
||||
}
|
||||
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