feat: 更新插入命令以支持尾部偏移标记和相关文档

This commit is contained in:
2025-10-31 15:29:12 +08:00
parent f0414ec32b
commit 843c51e347
9 changed files with 178 additions and 16 deletions

View File

@@ -48,7 +48,7 @@ All subcommands accept these persistent flags:
- `renamer replace <pattern...> <replacement>` — Replace multiple literal tokens in sequence. Shows duplicates and conflict warnings, then applies when `--yes` is present.
- `renamer remove <pattern...>` — Strip ordered substrings from names with empty-name protection and duplicate detection.
- `renamer extension <source-ext...> <target-ext>` — Normalize heterogeneous extensions to a single target while keeping a ledger entry for undo.
- `renamer insert <position> <text>` — Insert text at symbolic (`^`, `$`) or numeric offsets (positive or negative).
- `renamer insert <position> <text>` — Insert text at symbolic (`^`, `$`) offsets, count forward with numbers (`3` or `^3`), or backward with suffix tokens like `1$`.
- `renamer regex <pattern> <template>` — Rename via RE2 capture groups using placeholders like `@1`, `@2`, `@0`, or escape literal `@` as `@@`.
- `renamer undo` — Revert the most recent mutating command recorded in the ledger.

View File

@@ -17,7 +17,7 @@ func newInsertCommand() *cobra.Command {
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.`,
and suffix offsets like "3$" counting backward from the end.`,
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
scope, err := listing.ScopeFromCmd(cmd)
@@ -80,7 +80,7 @@ negative indexes counting back from the end.`,
}
cmd.Example = ` renamer insert ^ "[2025] " --dry-run
renamer insert -1 _FINAL --yes --path ./reports`
renamer insert 1$ _FINAL --yes --path ./reports`
return cmd
}

View File

@@ -39,10 +39,8 @@ renamer insert <position> <text> [flags]
```
- 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).
- `^` inserts at the beginning of the filename. Append a number (`^3` or just `3`) to insert after the third rune of the stem.
- `$` inserts immediately before the extension dot (or end if no extension). Append a number (`1$`) to count backward from the end of the stem (e.g., `1$` inserts before the final 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.
@@ -51,6 +49,7 @@ renamer insert <position> <text> [flags]
- Preview adding a prefix: `renamer insert ^ "[2025] " --dry-run`
- Append before extension: `renamer insert $ _ARCHIVE --yes --path ./docs`
- Insert before the final character: `renamer insert 1$ _TAIL --path ./images --dry-run`
- Insert after third character in stem: `renamer insert 3 _tag --path ./images --dry-run`
- Combine with extension filter: `renamer insert ^ "v1_" --extensions .txt|.md`

View File

@@ -3,6 +3,7 @@ package insert
import (
"errors"
"fmt"
"strings"
"unicode/utf8"
)
@@ -11,8 +12,12 @@ type Position struct {
Index int // zero-based index where insertion should occur
}
// ResolvePosition interprets a position token (`^`, `$`, positive, negative) against the stem length.
// ResolvePosition interprets a position token (`^`, `$`, forward indexes, suffix offsets like `N$`, or legacy negative values) against the stem length.
func ResolvePosition(token string, stemLength int) (Position, error) {
if token == "" {
return Position{}, errors.New("position token cannot be empty")
}
switch token {
case "^":
return Position{Index: 0}, nil
@@ -20,8 +25,40 @@ func ResolvePosition(token string, stemLength int) (Position, error) {
return Position{Index: stemLength}, nil
}
if token == "" {
return Position{}, errors.New("position token cannot be empty")
if strings.HasPrefix(token, "^") {
trimmed := token[1:]
if trimmed == "" {
return Position{Index: 0}, nil
}
value, err := parseInt(trimmed)
if err != nil {
return Position{}, fmt.Errorf("invalid position token %q: %w", token, err)
}
if value < 0 {
return Position{}, fmt.Errorf("invalid position token %q: cannot be negative", token)
}
if value > stemLength {
return Position{}, fmt.Errorf("position %d out of range for %d-character stem", value, stemLength)
}
return Position{Index: value}, nil
}
if strings.HasSuffix(token, "$") {
trimmed := token[:len(token)-1]
if trimmed == "" {
return Position{Index: stemLength}, nil
}
value, err := parseInt(trimmed)
if err != nil {
return Position{}, fmt.Errorf("invalid position token %q: %w", token, err)
}
if value < 0 {
return Position{}, fmt.Errorf("invalid position token %q: cannot be negative", token)
}
if value > stemLength {
return Position{}, fmt.Errorf("position %d out of range for %d-character stem", value, stemLength)
}
return Position{Index: stemLength - value}, nil
}
// Try parsing as integer (positive or negative).

View File

@@ -9,9 +9,9 @@
2. **Insert text near the end while preserving extensions.**
```bash
renamer insert -1 "_FINAL" --path ./reports --dry-run
renamer insert 1$ "_FINAL" --path ./reports --dry-run
```
- `-1` places the string before the last character of the stem.
- `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.**

View File

@@ -3,7 +3,7 @@
**Feature Branch**: `005-add-insert-command`
**Created**: 2025-10-30
**Status**: Draft
**Input**: User description: "实现插入Insert字符支持在文件名指定位置插入指定字符串数据示例 renamer insert <position> <string>, position:可以包含 ^:开头、$:结尾、 正数:字符位置、负数:倒数字符位置。!!重要:需要考虑中文字符"
**Input**: User description: "实现插入Insert字符支持在文件名指定位置插入指定字符串数据示例 renamer insert <position> <string>, position:可以包含 ^:开头、$:结尾、 正数:字符位置、使用 N$ 表示倒数第 N 个字符。!!重要:需要考虑中文字符"
## User Scenarios & Testing *(mandatory)*
@@ -18,7 +18,7 @@ As a power user organizing media files, I want to insert a label into filenames
**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.
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.
---
@@ -65,13 +65,13 @@ As a user preparing filenames with multilingual characters, I want validation an
### 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-001**: CLI MUST provide a dedicated `insert` subcommand accepting a positional argument (`^`, `$`, forward indexes like `3`/`^3`, or backward indexes like `1$`) 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-007**: Help output MUST describe position semantics (`^`, `$`, forward indexes, backward suffix tokens such as `N$`), 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

View File

@@ -71,6 +71,62 @@ func TestInsertPreviewAndApply(t *testing.T) {
}
}
func TestInsertTailOffsetToken(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
writeInsertFile(t, filepath.Join(tmp, "code.txt"))
scope := &listing.ListingRequest{
WorkingDir: tmp,
IncludeDirectories: false,
Recursive: false,
IncludeHidden: false,
Extensions: nil,
Format: listing.FormatTable,
}
if err := scope.Validate(); err != nil {
t.Fatalf("validate scope: %v", err)
}
req := insert.NewRequest(scope)
req.SetExecutionMode(true, false)
req.SetPositionAndText("1$", "_TAIL")
summary, planned, err := insert.Preview(context.Background(), req, nil)
if err != nil {
t.Fatalf("preview error: %v", err)
}
if summary.TotalCandidates != 1 {
t.Fatalf("expected 1 candidate, got %d", summary.TotalCandidates)
}
if summary.TotalChanged != 1 {
t.Fatalf("expected 1 change, got %d", summary.TotalChanged)
}
if len(planned) != 1 {
t.Fatalf("expected 1 planned operation, got %d", len(planned))
}
expected := filepath.ToSlash("cod_TAILe.txt")
if planned[0].ProposedRelative != expected {
t.Fatalf("expected proposed path %s, got %s", expected, planned[0].ProposedRelative)
}
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) != 1 {
t.Fatalf("expected 1 ledger entry, got %d", len(entry.Operations))
}
if _, err := os.Stat(filepath.Join(tmp, "cod_TAILe.txt")); err != nil {
t.Fatalf("expected renamed file cod_TAILe.txt: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "code.txt")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected original file to be renamed, err=%v", err)
}
}
func writeInsertFile(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {

View File

@@ -95,6 +95,52 @@ func TestInsertZeroMatchExitsSuccessfully(t *testing.T) {
}
}
func TestInsertLedgerCapturesTailOffsetToken(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
writeInsertLedgerFile(t, filepath.Join(tmp, "ab.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("1$", "_SUFFIX")
summary, planned, err := insert.Preview(context.Background(), req, nil)
if err != nil {
t.Fatalf("preview error: %v", err)
}
if len(planned) != 1 {
t.Fatalf("expected 1 planned operation, got %d", len(planned))
}
entry, err := insert.Apply(context.Background(), req, planned, summary)
if err != nil {
t.Fatalf("apply error: %v", err)
}
if entry.Metadata["positionToken"] != "1$" {
t.Fatalf("expected position token metadata 1$, got %v", entry.Metadata["positionToken"])
}
if entry.Metadata["insertText"] != "_SUFFIX" {
t.Fatalf("expected insertText metadata _SUFFIX, got %v", entry.Metadata["insertText"])
}
if _, err := os.Stat(filepath.Join(tmp, "a_SUFFIXb.txt")); err != nil {
t.Fatalf("expected renamed file a_SUFFIXb.txt: %v", err)
}
}
func writeInsertLedgerFile(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {

View File

@@ -48,6 +48,30 @@ func TestInsertCommandFlow(t *testing.T) {
}
}
func TestInsertCommandTailOffsetToken(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
createInsertFile(t, filepath.Join(tmp, "demo.txt"))
var out bytes.Buffer
cmd := renamercmd.NewRootCommand()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"insert", "1$", "_TAIL", "--yes", "--path", tmp})
if err := cmd.Execute(); err != nil {
t.Fatalf("insert command failed: %v\noutput: %s", err, out.String())
}
if _, err := os.Stat(filepath.Join(tmp, "dem_TAILo.txt")); err != nil {
t.Fatalf("expected dem_TAILo.txt after apply: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "demo.txt")); !os.IsNotExist(err) {
t.Fatalf("expected original demo.txt to be renamed, err=%v", err)
}
}
func contains(t *testing.T, haystack string, expected ...string) bool {
t.Helper()
for _, s := range expected {