feat: 更新插入命令以支持尾部偏移标记和相关文档
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user