Add configurable sequence numbering command

This commit is contained in:
2025-11-03 10:59:15 +08:00
parent 843c51e347
commit e6a5c6499b
34 changed files with 1920 additions and 1 deletions

View File

@@ -43,9 +43,9 @@ tests/
- Smoke: `scripts/smoke-test-replace.sh`, `scripts/smoke-test-remove.sh`
## Recent Changes
- 001-sequence-numbering: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, internal traversal/history/output packages
- 006-add-regex-command: Added Go 1.24 + `spf13/cobra`, `spf13/pflag`, Go `regexp` (RE2 engine), internal traversal/history/output packages
- 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
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@@ -49,6 +49,7 @@ All subcommands accept these persistent flags:
- `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 (`^`, `$`) offsets, count forward with numbers (`3` or `^3`), or backward with suffix tokens like `1$`.
- `renamer sequence [flags]` — Append or prepend zero-padded sequence numbers with configurable start, width, placement (default prefix), separator, and static number prefix/suffix options.
- `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

@@ -50,6 +50,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(NewExtensionCommand())
cmd.AddCommand(newInsertCommand())
cmd.AddCommand(newRegexCommand())
cmd.AddCommand(newSequenceCommand())
cmd.AddCommand(newUndoCommand())
return cmd

158
cmd/sequence.go Normal file
View File

@@ -0,0 +1,158 @@
package cmd
import (
"errors"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/rogeecn/renamer/internal/listing"
"github.com/rogeecn/renamer/internal/sequence"
)
func newSequenceCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "sequence",
Short: "Append or prepend sequential numbers to filenames",
Long: `Preview and apply numbered renames across the active scope. Sequence numbers
respect deterministic traversal order, support configurable start offsets, and record every
batch in the .renamer ledger for undo.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
scope, err := listing.ScopeFromCmd(cmd)
if err != nil {
return err
}
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")
}
start, err := cmd.Flags().GetInt("start")
if err != nil {
return err
}
width, err := cmd.Flags().GetInt("width")
if err != nil {
return err
}
placement, err := cmd.Flags().GetString("placement")
if err != nil {
return err
}
separator, err := cmd.Flags().GetString("separator")
if err != nil {
return err
}
numberPrefix, err := cmd.Flags().GetString("number-prefix")
if err != nil {
return err
}
numberSuffix, err := cmd.Flags().GetString("number-suffix")
if err != nil {
return err
}
opts := sequence.DefaultOptions()
opts.WorkingDir = scope.WorkingDir
opts.IncludeDirectories = scope.IncludeDirectories
opts.IncludeHidden = scope.IncludeHidden
opts.Recursive = scope.Recursive
opts.Extensions = append([]string(nil), scope.Extensions...)
opts.DryRun = dryRun
opts.AutoApply = autoApply
if start != 0 {
opts.Start = start
}
if cmd.Flags().Changed("width") {
opts.Width = width
opts.WidthSet = true
}
if placement != "" {
opts.Placement = sequence.Placement(strings.ToLower(placement))
}
if separator != "" {
opts.Separator = separator
}
opts.NumberPrefix = numberPrefix
opts.NumberSuffix = numberSuffix
out := cmd.OutOrStdout()
plan, err := sequence.Preview(cmd.Context(), opts, out)
if err != nil {
return err
}
for _, candidate := range plan.Candidates {
status := "UNCHANGED"
switch candidate.Status {
case sequence.CandidatePending:
status = "CHANGE"
case sequence.CandidateSkipped:
status = "SKIP"
}
fmt.Fprintf(out, "%s: %s -> %s\n", status, candidate.OriginalPath, candidate.ProposedPath)
}
for _, conflict := range plan.SkippedConflicts {
fmt.Fprintf(out, "Warning: %s skipped due to %s (target %s)\n", conflict.OriginalPath, conflict.Reason, conflict.ConflictingPath)
}
for _, warning := range plan.Summary.Warnings {
fmt.Fprintf(out, "Warning: %s\n", warning)
}
if plan.Summary.TotalCandidates == 0 {
fmt.Fprintln(out, "No candidates found.")
return nil
}
fmt.Fprintf(out, "Preview: %d candidates, %d renames, %d skipped (width %d).\n",
plan.Summary.TotalCandidates,
plan.Summary.RenamedCount,
plan.Summary.SkippedCount,
plan.Summary.AppliedWidth,
)
if dryRun || !autoApply {
if !autoApply {
fmt.Fprintln(out, "Preview complete. Re-run with --yes to apply.")
}
return nil
}
entry, err := sequence.Apply(cmd.Context(), opts, plan)
if err != nil {
return err
}
fmt.Fprintf(out, "Applied %d sequence updates. Ledger updated.\n", len(entry.Operations))
if plan.Summary.SkippedCount > 0 {
fmt.Fprintf(out, "%d candidates were skipped due to conflicts.\n", plan.Summary.SkippedCount)
}
return nil
},
}
cmd.Flags().Int("start", 1, "Starting sequence value (>=1)")
cmd.Flags().Int("width", 0, "Minimum digit width for zero padding (defaults to 3 digits, auto-expands as needed)")
cmd.Flags().String("placement", string(sequence.PlacementPrefix), "Placement for the sequence number: prefix or suffix")
cmd.Flags().String("separator", "_", "Separator between the filename and sequence label and the original name")
cmd.Flags().String("number-prefix", "", "Static text placed immediately before the sequence digits")
cmd.Flags().String("number-suffix", "", "Static text placed immediately after the sequence digits")
return cmd
}
func init() {
rootCmd.AddCommand(newSequenceCommand())
}

View File

@@ -2,6 +2,7 @@
## Unreleased
- Add `renamer sequence` subcommand with configurable numbering (start, width, placement—default prefix—separator, number prefix/suffix) and ledger-backed apply/undo flows.
- Add `renamer remove` subcommand with sequential multi-token deletions, empty-name safeguards, and ledger-backed undo.
- Document remove command ordering semantics, duplicate warnings, and automation guidance.
- Add `renamer replace` subcommand supporting multi-pattern replacements, preview/apply/undo, and scope flags.

View File

@@ -53,6 +53,23 @@ renamer insert <position> <text> [flags]
- Insert after third character in stem: `renamer insert 3 _tag --path ./images --dry-run`
- Combine with extension filter: `renamer insert ^ "v1_" --extensions .txt|.md`
## Sequence Command Quick Reference
```bash
renamer sequence [flags]
```
- Applies deterministic numbering to filenames using the active scope filters; preview-first by default.
- Default behavior prepends a three-digit number using an underscore separator (e.g. `001_name.ext`).
- Flags:
- `--start` (default `1`) sets the initial sequence value (must be ≥1).
- `--width` (optional) enforces minimum digit width with zero padding; the command auto-expands and warns when more digits are required.
- `--placement` (`suffix` default, `prefix` alternative) controls whether numbers prepend or append the stem.
- `--separator` customizes the string placed between the stem and number; path separators are rejected.
- `--number-prefix` / `--number-suffix` add static text directly before or after the digits (use with `--placement prefix` for labelled sequences such as `seq001-file.ext`).
- Set `--separator ""` to remove the underscore separator when prefixing numbers (e.g. `seq001file.ext`).
- Conflicting targets are skipped with warnings while remaining files continue numbering; directories included via `--include-dirs` are listed but unchanged.
## Remove Command Quick Reference
```bash

113
internal/sequence/apply.go Normal file
View File

@@ -0,0 +1,113 @@
package sequence
import (
"context"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"github.com/rogeecn/renamer/internal/history"
)
// Apply executes the planned numbering operations and records them in the ledger.
func Apply(ctx context.Context, opts Options, plan Plan) (history.Entry, error) {
merged := mergeOptions(opts)
if err := validateOptions(&merged); err != nil {
return history.Entry{}, err
}
entry := history.Entry{Command: "sequence"}
type renameOp struct {
fromAbs string
toAbs string
fromRel string
toRel string
depth int
}
ops := make([]renameOp, 0, len(plan.Candidates))
for _, candidate := range plan.Candidates {
if candidate.Status != CandidatePending {
continue
}
ops = append(ops, renameOp{
fromAbs: filepath.Join(merged.WorkingDir, filepath.FromSlash(candidate.OriginalPath)),
toAbs: filepath.Join(merged.WorkingDir, filepath.FromSlash(candidate.ProposedPath)),
fromRel: candidate.OriginalPath,
toRel: candidate.ProposedPath,
depth: strings.Count(candidate.OriginalPath, "/"),
})
}
if len(ops) == 0 {
return entry, nil
}
sort.SliceStable(ops, func(i, j int) bool {
return ops[i].depth > ops[j].depth
})
done := make([]history.Operation, 0, len(ops))
revert := func() error {
for i := len(done) - 1; i >= 0; i-- {
op := done[i]
source := filepath.Join(merged.WorkingDir, filepath.FromSlash(op.To))
destination := filepath.Join(merged.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 ops {
if err := ctx.Err(); err != nil {
_ = revert()
return history.Entry{}, err
}
if op.fromAbs == op.toAbs {
continue
}
if err := os.Rename(op.fromAbs, op.toAbs); err != nil {
_ = revert()
return history.Entry{}, err
}
done = append(done, history.Operation{
From: filepath.ToSlash(op.fromRel),
To: filepath.ToSlash(op.toRel),
})
}
if len(done) == 0 {
return entry, nil
}
entry.Operations = done
entry.Metadata = map[string]any{
"sequence": map[string]any{
"start": plan.Config.Start,
"width": plan.Summary.AppliedWidth,
"placement": string(plan.Config.Placement),
"separator": plan.Config.Separator,
"prefix": plan.Config.NumberPrefix,
"suffix": plan.Config.NumberSuffix,
},
"totalCandidates": plan.Summary.TotalCandidates,
"renamed": plan.Summary.RenamedCount,
"skipped": plan.Summary.SkippedCount,
}
if err := history.Append(merged.WorkingDir, entry); err != nil {
_ = revert()
return history.Entry{}, err
}
return entry, nil
}

2
internal/sequence/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package sequence implements the numbering rule used by the `renamer sequence` command.
package sequence

View File

@@ -0,0 +1,20 @@
package sequence
import (
"fmt"
"strconv"
)
// formatNumber zero-pads the provided value using the requested width and returns
// both the padded number string and the width that was ultimately used.
func formatNumber(value, requestedWidth int) (string, int) {
if value < 0 {
value = 0
}
digitCount := len(strconv.Itoa(value))
width := requestedWidth
if width <= 0 || width < digitCount {
width = digitCount
}
return fmt.Sprintf("%0*d", width, value), width
}

View File

@@ -0,0 +1,95 @@
package sequence
import (
"errors"
"fmt"
"path/filepath"
"strings"
)
// Placement controls where a sequence number is inserted.
type Placement string
const (
// PlacementSuffix appends the sequence number after the filename stem.
PlacementSuffix Placement = "suffix"
// PlacementPrefix inserts the sequence number before the filename stem.
PlacementPrefix Placement = "prefix"
)
// Options captures configuration for numbering operations.
type Options struct {
WorkingDir string
Start int
Width int
WidthSet bool
NumberPrefix string
NumberSuffix string
Placement Placement
Separator string
IncludeHidden bool
IncludeDirectories bool
Recursive bool
Extensions []string
DryRun bool
AutoApply bool
}
// DefaultOptions returns a copy of the default configuration.
func DefaultOptions() Options {
return Options{
Start: 1,
Width: 3,
Placement: PlacementPrefix,
Separator: "_",
}
}
func validateOptions(opts *Options) error {
if opts == nil {
return errors.New("options cannot be nil")
}
if opts.WorkingDir == "" {
return errors.New("working directory must be provided")
}
abs, err := filepath.Abs(opts.WorkingDir)
if err != nil {
return fmt.Errorf("resolve working directory: %w", err)
}
opts.WorkingDir = abs
if opts.Start < 1 {
return errors.New("start must be >= 1")
}
if opts.Width < 0 {
return errors.New("width cannot be negative")
}
if opts.WidthSet && opts.Width < 1 {
return errors.New("width must be >= 1 when specified")
}
switch opts.Placement {
case PlacementPrefix, PlacementSuffix:
// ok
case "":
opts.Placement = PlacementSuffix
default:
return fmt.Errorf("unsupported placement %q", opts.Placement)
}
if strings.ContainsAny(opts.Separator, "/\\") {
return errors.New("separator cannot contain path separators")
}
if strings.ContainsAny(opts.NumberPrefix, "/\\") {
return errors.New("number prefix cannot contain path separators")
}
if strings.ContainsAny(opts.NumberSuffix, "/\\") {
return errors.New("number suffix cannot contain path separators")
}
return nil
}

68
internal/sequence/plan.go Normal file
View File

@@ -0,0 +1,68 @@
package sequence
// Plan represents the ordered numbering proposal produced during preview.
type Plan struct {
Candidates []Candidate
SkippedConflicts []Conflict
Summary Summary
Config Config
}
// Candidate describes a single file considered for numbering.
type Candidate struct {
OriginalPath string
ProposedPath string
Index int
IsDir bool
Status CandidateStatus
}
// CandidateStatus indicates how a candidate was handled during preview.
type CandidateStatus string
const (
// CandidatePending means the candidate will be renamed when applied.
CandidatePending CandidateStatus = "pending"
// CandidateSkipped indicates the candidate was skipped (e.g., conflict).
CandidateSkipped CandidateStatus = "skipped"
// CandidateUnchanged indicates the candidate already matches the target name.
CandidateUnchanged CandidateStatus = "unchanged"
)
// Conflict captures a skipped item and the reason it could not be renamed.
type Conflict struct {
OriginalPath string
ConflictingPath string
Reason ConflictReason
}
// ConflictReason enumerates known conflict types.
type ConflictReason string
const (
// ConflictExistingTarget indicates the proposed name collides with an existing file.
ConflictExistingTarget ConflictReason = "existing_target"
// ConflictInvalidSeparator indicates the proposed separator produced an invalid path.
ConflictInvalidSeparator ConflictReason = "invalid_separator"
// ConflictWidthOverflow indicates numbering exceeded a fixed width.
ConflictWidthOverflow ConflictReason = "width_overflow"
)
// Summary aggregates totals surfaced during preview.
type Summary struct {
TotalCandidates int
RenamedCount int
SkippedCount int
Warnings []string
AppliedWidth int
}
// Config snapshots the numbering configuration for preview/apply/ledger.
type Config struct {
Start int
Width int
Placement Placement
Separator string
NumberPrefix string
NumberSuffix string
}

View File

@@ -0,0 +1,195 @@
package sequence
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// Preview computes the numbering plan for the provided options, returning the
// plan without mutating the filesystem. The writer is reserved for future
// preview output integration and may be nil.
func Preview(ctx context.Context, opts Options, w io.Writer) (Plan, error) {
merged := mergeOptions(opts)
if err := validateOptions(&merged); err != nil {
return Plan{}, err
}
traversalCandidates, err := collectTraversalCandidates(ctx, merged)
if err != nil {
return Plan{}, err
}
plan := Plan{
Candidates: make([]Candidate, 0, len(traversalCandidates)),
Config: Config{
Start: merged.Start,
Width: merged.Width,
Placement: merged.Placement,
Separator: merged.Separator,
NumberPrefix: merged.NumberPrefix,
NumberSuffix: merged.NumberSuffix,
},
}
plannedTargets := make(map[string]string)
plannedTargetsFold := make(map[string]string)
widthUsed := merged.Width
widthWarned := false
nextValue := merged.Start
sequenceIndex := 0
for _, entry := range traversalCandidates {
if entry.IsDir {
// Directories remain untouched for numbering purposes.
continue
}
plan.Summary.TotalCandidates++
number, appliedWidth := formatNumber(nextValue, merged.Width)
if merged.WidthSet && appliedWidth > merged.Width && !widthWarned {
plan.Summary.Warnings = append(plan.Summary.Warnings, fmt.Sprintf("requested width %d expanded to %d for %s", merged.Width, appliedWidth, entry.RelativePath))
widthWarned = true
}
if appliedWidth > widthUsed {
widthUsed = appliedWidth
}
formattedNumber := merged.NumberPrefix + number + merged.NumberSuffix
proposed := buildProposedPath(entry, merged, formattedNumber)
candidate := Candidate{
OriginalPath: entry.RelativePath,
ProposedPath: proposed,
Index: sequenceIndex,
Status: CandidatePending,
IsDir: entry.IsDir,
}
if proposed == entry.RelativePath {
candidate.Status = CandidateUnchanged
plan.Candidates = append(plan.Candidates, candidate)
sequenceIndex++
nextValue++
continue
}
if existing, ok := plannedTargets[proposed]; ok && existing != entry.RelativePath {
plan.appendConflict(entry.RelativePath, proposed, ConflictExistingTarget)
plan.Summary.SkippedCount++
candidate.Status = CandidateSkipped
plan.Candidates = append(plan.Candidates, candidate)
sequenceIndex++
nextValue++
continue
}
lowerKey := strings.ToLower(proposed)
if existing, ok := plannedTargetsFold[lowerKey]; ok && existing != entry.RelativePath {
plan.appendConflict(entry.RelativePath, proposed, ConflictExistingTarget)
plan.Summary.SkippedCount++
candidate.Status = CandidateSkipped
plan.Candidates = append(plan.Candidates, candidate)
sequenceIndex++
nextValue++
continue
}
targetAbs := filepath.Join(merged.WorkingDir, filepath.FromSlash(proposed))
if info, statErr := os.Stat(targetAbs); statErr == nil {
origInfo, origErr := os.Stat(entry.AbsolutePath)
if origErr != nil || !os.SameFile(info, origInfo) {
plan.appendConflict(entry.RelativePath, proposed, ConflictExistingTarget)
plan.Summary.SkippedCount++
candidate.Status = CandidateSkipped
plan.Candidates = append(plan.Candidates, candidate)
sequenceIndex++
nextValue++
continue
}
} else if !errors.Is(statErr, os.ErrNotExist) {
return Plan{}, statErr
}
plan.Candidates = append(plan.Candidates, candidate)
plan.Summary.RenamedCount++
plannedTargets[proposed] = entry.RelativePath
plannedTargetsFold[lowerKey] = entry.RelativePath
sequenceIndex++
nextValue++
}
plan.Summary.AppliedWidth = widthUsed
return plan, nil
}
func mergeOptions(opts Options) Options {
merged := DefaultOptions()
if opts.Start != 0 {
merged.Start = opts.Start
}
merged.Width = opts.Width
merged.WidthSet = opts.WidthSet
if opts.Placement != "" {
merged.Placement = opts.Placement
}
if opts.Separator != "" {
merged.Separator = opts.Separator
}
merged.NumberPrefix = opts.NumberPrefix
merged.NumberSuffix = opts.NumberSuffix
merged.WorkingDir = opts.WorkingDir
merged.IncludeDirectories = opts.IncludeDirectories
merged.IncludeHidden = opts.IncludeHidden
merged.Recursive = opts.Recursive
merged.Extensions = append([]string(nil), opts.Extensions...)
merged.DryRun = opts.DryRun
merged.AutoApply = opts.AutoApply
return merged
}
func buildProposedPath(entry traversalCandidate, opts Options, formattedNumber string) string {
dir := filepath.Dir(entry.RelativePath)
if dir == "." {
dir = ""
}
stem := entry.Stem
if opts.Placement == PlacementPrefix {
stem = formattedNumber + joinIfNeeded(opts.Separator, stem)
} else {
stem = stem + joinIfNeeded(opts.Separator, formattedNumber)
}
if !entry.IsDir && entry.Extension != "" {
stem += entry.Extension
}
if dir == "" {
return filepath.ToSlash(stem)
}
return filepath.ToSlash(filepath.Join(dir, stem))
}
func joinIfNeeded(separator, value string) string {
if separator == "" {
return value
}
return separator + value
}
func (p *Plan) appendConflict(original, proposed string, reason ConflictReason) {
p.SkippedConflicts = append(p.SkippedConflicts, Conflict{
OriginalPath: original,
ConflictingPath: proposed,
Reason: reason,
})
}

View File

@@ -0,0 +1,92 @@
package sequence
import (
"context"
"errors"
"io/fs"
"path/filepath"
"strings"
"github.com/rogeecn/renamer/internal/traversal"
)
type traversalCandidate struct {
RelativePath string
AbsolutePath string
Stem string
Extension string
IsDir bool
Depth int
}
func collectTraversalCandidates(ctx context.Context, opts Options) ([]traversalCandidate, error) {
if opts.WorkingDir == "" {
return nil, errors.New("working directory must be provided")
}
absRoot, err := filepath.Abs(opts.WorkingDir)
if err != nil {
return nil, err
}
walker := traversal.NewWalker()
allowedExts := make(map[string]struct{}, len(opts.Extensions))
for _, ext := range opts.Extensions {
lower := strings.ToLower(ext)
allowedExts[lower] = struct{}{}
}
candidates := make([]traversalCandidate, 0)
emit := func(relPath string, entry fs.DirEntry, depth int) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Skip the root placeholder emitted by the walker when include dirs is true.
if relPath == "." {
return nil
}
relSlash := filepath.ToSlash(relPath)
absolute := filepath.Join(absRoot, relPath)
candidate := traversalCandidate{
RelativePath: relSlash,
AbsolutePath: absolute,
IsDir: entry.IsDir(),
Depth: depth,
}
if !entry.IsDir() {
rawExt := filepath.Ext(entry.Name())
lowerExt := strings.ToLower(rawExt)
if len(allowedExts) > 0 {
if _, ok := allowedExts[lowerExt]; !ok {
return nil
}
}
candidate.Extension = rawExt
stem := entry.Name()
if rawExt != "" {
stem = strings.TrimSuffix(stem, rawExt)
}
candidate.Stem = stem
} else {
candidate.Stem = entry.Name()
}
candidates = append(candidates, candidate)
return nil
}
err = walker.Walk(absRoot, opts.Recursive, opts.IncludeDirectories, opts.IncludeHidden, 0, emit)
if err != nil {
return nil, err
}
return candidates, nil
}

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Sequence Numbering Command
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2025-10-31
**Feature**: [Sequence Numbering Command](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`

View File

@@ -0,0 +1,241 @@
openapi: 3.1.0
info:
title: Renamer Sequence Command Contract
version: 0.1.0
description: >
Conceptual REST contract for the `renamer sequence` CLI behavior, used to
formalize preview/apply expectations for testing and documentation.
servers:
- url: cli://local
paths:
/sequence/preview:
post:
summary: Generate a deterministic sequence numbering preview.
operationId: previewSequence
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SequenceRequest'
responses:
'200':
description: Preview generated successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/SequencePlan'
'400':
description: Invalid configuration (e.g., width <= 0, invalid separator).
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/sequence/apply:
post:
summary: Apply sequence numbering to previously previewed candidates.
operationId: applySequence
requestBody:
required: true
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/SequenceRequest'
- type: object
properties:
confirm:
type: boolean
const: true
responses:
'200':
description: Sequence numbering applied.
content:
application/json:
schema:
$ref: '#/components/schemas/SequenceApplyResult'
'207':
description: Applied with skipped conflicts.
content:
application/json:
schema:
$ref: '#/components/schemas/SequenceApplyResult'
'400':
description: Invalid configuration or missing confirmation.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: Scope filters yielded zero candidates.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
schemas:
SequenceRequest:
type: object
required:
- start
- placement
- separator
properties:
path:
type: string
description: Root directory for traversal.
recursive:
type: boolean
includeDirs:
type: boolean
description: Directories remain untouched even when included.
hidden:
type: boolean
extensions:
type: array
items:
type: string
pattern: '^\\.[^./]+$'
dryRun:
type: boolean
yes:
type: boolean
start:
type: integer
minimum: 1
width:
type: integer
minimum: 1
placement:
type: string
enum: [prefix, suffix]
separator:
type: string
minLength: 1
pattern: '^[^/\\\\]+$'
SequencePlan:
type: object
required:
- candidates
- config
- summary
properties:
candidates:
type: array
items:
$ref: '#/components/schemas/SequenceCandidate'
skippedConflicts:
type: array
items:
$ref: '#/components/schemas/SequenceConflict'
summary:
$ref: '#/components/schemas/SequenceSummary'
config:
$ref: '#/components/schemas/SequenceConfig'
SequenceCandidate:
type: object
required:
- originalPath
- proposedPath
- index
properties:
originalPath:
type: string
proposedPath:
type: string
index:
type: integer
minimum: 0
SequenceConflict:
type: object
required:
- originalPath
- conflictingPath
- reason
properties:
originalPath:
type: string
conflictingPath:
type: string
reason:
type: string
enum: [existing_target, invalid_separator, width_overflow]
SequenceSummary:
type: object
required:
- totalCandidates
- renamedCount
- skippedCount
properties:
totalCandidates:
type: integer
renamedCount:
type: integer
skippedCount:
type: integer
warnings:
type: array
items:
type: string
SequenceConfig:
type: object
required:
- start
- placement
- separator
properties:
start:
type: integer
width:
type: integer
placement:
type: string
enum: [prefix, suffix]
separator:
type: string
SequenceApplyResult:
type: object
required:
- plan
- ledgerEntry
properties:
plan:
$ref: '#/components/schemas/SequencePlan'
ledgerEntry:
$ref: '#/components/schemas/SequenceLedgerEntry'
SequenceLedgerEntry:
type: object
required:
- rule
- config
- operations
properties:
timestamp:
type: string
format: date-time
rule:
type: string
const: sequence
config:
$ref: '#/components/schemas/SequenceConfig'
operations:
type: array
items:
$ref: '#/components/schemas/SequenceOperation'
SequenceOperation:
type: object
required:
- from
- to
properties:
from:
type: string
to:
type: string
ErrorResponse:
type: object
required:
- message
properties:
message:
type: string

View File

@@ -0,0 +1,68 @@
# Data Model: Sequence Numbering Command
## SequenceRequest
- **Fields**
- `Path` (string): Root path for traversal (defaults to current directory); must exist and be accessible.
- `Recursive` (bool): Includes subdirectories when true.
- `IncludeDirs` (bool): Includes directories in traversal results without numbering.
- `Hidden` (bool): Includes hidden files when true.
- `Extensions` ([]string): Optional `.`-prefixed extension filter; deduplicated and validated.
- `DryRun` (bool): Indicates preview-only execution.
- `Yes` (bool): Confirmation flag for apply mode.
- `Start` (int): First sequence value; must be ≥1.
- `Width` (int): Minimum digits for zero padding; must be ≥1 when provided.
- `Placement` (enum: `prefix` | `suffix`): Determines where the sequence number is inserted; default `suffix`.
- `Separator` (string): Separator between number and filename; defaults to `_`; must comply with filesystem rules (no path separators, non-empty).
- **Relationships**: Consumed by traversal service to produce candidates and by sequence rule to generate `SequencePlan`.
- **Validations**: Numeric fields validated before preview; separator sanitized; conflicting flags (dry-run vs yes) rejected.
## SequencePlan
- **Fields**
- `Candidates` ([]SequenceCandidate): Ordered list of files considered for numbering.
- `SkippedConflicts` ([]SequenceConflict): Files skipped due to target path collisions.
- `Summary` (SequenceSummary): Counts for total candidates, renamed files, and skipped items.
- `Config` (SequenceConfig): Snapshot of sequence settings (start, width, placement, separator).
- **Relationships**: Passed to output package for preview rendering and to history package for ledger persistence.
- **Validations**: Candidate ordering must match traversal ordering; conflicts identified before apply.
### SequenceCandidate
- **Fields**
- `OriginalPath` (string)
- `ProposedPath` (string)
- `Index` (int): Zero-based position used to derive padded number.
- **Constraints**: Proposed path must differ from original to be considered a rename; duplicates flagged as conflicts.
### SequenceConflict
- **Fields**
- `OriginalPath` (string)
- `ConflictingPath` (string)
- `Reason` (string enum: `existing_target`, `invalid_separator`, `width_overflow`)
### SequenceSummary
- **Fields**
- `TotalCandidates` (int)
- `RenamedCount` (int)
- `SkippedCount` (int)
- `Warnings` ([]string)
## SequenceLedgerEntry
- **Fields**
- `Timestamp` (time.Time)
- `Rule` (string): Fixed value `sequence`.
- `Config` (SequenceConfig): Stored to support undo.
- `Operations` ([]SequenceOperation): Each captures `From` and `To` paths actually renamed.
- **Relationships**: Append-only entry written by history package; consumed by undo command.
- **Validations**: Only include successful renames (skipped conflicts omitted). Undo must verify files still exist before attempting reversal.
### SequenceOperation
- **Fields**
- `From` (string)
- `To` (string)
## SequenceConfig
- **Fields**
- `Start` (int)
- `Width` (int)
- `Placement` (string)
- `Separator` (string)
- **Usage**: Embedded in plan summaries, ledger entries, and undo operations to ensure consistent behavior across preview and apply.

View File

@@ -0,0 +1,87 @@
# Implementation Plan: Sequence Numbering Command
**Branch**: `001-sequence-numbering` | **Date**: 2025-11-03 | **Spec**: `specs/001-sequence-numbering/spec.md`
**Input**: Feature specification from `/specs/001-sequence-numbering/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 new `renamer sequence` command that appends deterministic sequence numbers to file candidates following the preview-first workflow. The command respects existing scope flags, supports configuration for start value, width, placement, and separator, records batches in the `.renamer` ledger for undo, and skips conflicting filesystem entries while warning the user.
## 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/`
**Target Platform**: Cross-platform CLI (Linux/macOS/Windows shells)
**Project Type**: CLI application (single Go project)
**Performance Goals**: Preview + apply 500 files in ≤120s; preview/apply parity ≥95%
**Constraints**: Deterministic ordering, atomic ledger writes, skip conflicts while warning
**Scale/Scope**: Operates on batches up to hundreds of files per invocation
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Preview flow will extend existing preview pipeline to list original → numbered name mappings and highlight skipped conflicts before requiring `--yes`.
- Undo strategy leverages ledger entries capturing sequence parameters (start, width, placement) and per-file mappings, ensuring reversal mirrors numbering order.
- Sequence rule will be implemented as a composable transformation module declaring inputs (scope candidates + sequence config), validations, and outputs, reusable across preview/apply.
- Scope handling continues to consume existing traversal services, honoring `-d`, `-r`, `--extensions`, and leaving directories untouched per clarified requirement while preventing scope escape.
- CLI UX will wire flags via Cobra, update help text, and add tests covering flag validation, preview output, warning messaging, and undo flow consistency.
**Post-Design Review:** Research and design artifacts confirm all principles remain satisfied; no constitution waivers required.
## 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/
├── renamer/ # Cobra CLI entrypoints and command wiring
internal/
├── traversal/ # Scope resolution and ordering services
├── history/ # Ledger and undo utilities
├── output/ # Preview/summary formatting
└── sequence/ # [to be added] sequence rule implementation
tests/
├── contract/ # CLI contract tests
├── integration/ # Multi-command flow tests
└── smoke/ # Smoke scripts under scripts/
```
**Structure Decision**: Extend existing single Go CLI project; new logic lives under `internal/sequence`, with command wiring in `cmd/renamer`.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |

View File

@@ -0,0 +1,55 @@
# Quickstart: Sequence Numbering Command
## Prerequisites
- Go 1.24 toolchain installed.
- `renamer` repository cloned and bootstrapped (`go mod tidy` already satisfied in repo).
- Test fixtures available under `tests/` for validation runs.
## Build & Install
```bash
go build -o bin/renamer ./cmd/renamer
```
## Preview Sequence Numbering
```bash
bin/renamer sequence \
--path ./fixtures/sample-batch \
--dry-run
```
Outputs a preview table showing `001_`, `002_`, … prefixes based on alphabetical order.
## Customize Formatting
```bash
bin/renamer sequence \
--path ./fixtures/sample-batch \
--start 10 \
--width 4 \
--number-prefix seq \
--separator "" \
--dry-run
```
Produces names such as `seq0010file.ext`. Errors if width/start are invalid.
## Apply Changes
```bash
bin/renamer sequence \
--path ./fixtures/sample-batch \
--yes
```
Writes rename results to the `.renamer` ledger while skipping conflicting targets and warning the user.
## Undo Sequence Batch
```bash
bin/renamer undo --path ./fixtures/sample-batch
```
Restores filenames using the most recent ledger entry.
## Run Automated Tests
```bash
go test ./...
tests/integration/remove_flow_test.go # existing suites ensure regressions are caught
```
## Troubleshooting
- Conflict warnings indicate existing files with the same numbered name; resolve manually or adjust flags.
- Zero candidates cause a 409-style error; adjust scope flags to include desired files.

View File

@@ -0,0 +1,26 @@
# Research Log
## Cobra Flag Validation For Sequence Command
- **Decision**: Validate `--start`, `--width`, `--placement`, and `--separator` flags using Cobra command `PreRunE` with shared helpers, returning errors for invalid inputs before preview executes.
- **Rationale**: Cobra documentation and community guides recommend using `RunE`/`PreRunE` to surface validation errors with non-zero exit codes, ensuring CLI consistency and enabling tests to cover messaging.
- **Alternatives considered**: Inline validation inside business logic (rejected—mixes CLI parsing with domain rules and complicates contract tests); custom flag types (rejected—adds complexity without additional value).
## Sequence Ordering And Determinism
- **Decision**: Reuse the existing traversal service to produce a stable, path-sorted candidate list and derive sequence numbers from index positions in the preview plan.
- **Rationale**: Internal traversal package already guarantees deterministic ordering and filtering; leveraging it avoids duplicating scope logic and satisfies Preview-First and Scope-Aware principles.
- **Alternatives considered**: Implement ad-hoc sorting inside sequence rule (rejected—risk of diverging from other commands); rely on filesystem iteration order (rejected—non-deterministic across platforms).
## Ledger Metadata Capture
- **Decision**: Extend history ledger entries with a new sequence record type storing sequence parameters and per-file mappings, ensuring undo can skip missing files but restore others.
- **Rationale**: Existing ledger pattern (as used by replace/remove commands) stores rule metadata for undo; following same structure keeps undo consistent and auditable.
- **Alternatives considered**: Store only file rename pairs without parameters (rejected—undo would lack context if future migrations require differentiation); create a separate ledger file (rejected—breaks append-only guarantee).
## Conflict Handling Strategy
- **Decision**: During apply, skip conflicting file targets, log a warning via output package, and continue numbering remaining candidates; conflicts remain in preview so users can resolve them beforehand.
- **Rationale**: Aligns with clarified requirements and minimizes partial ledger entries while informing users; consistent with existing warning infrastructure used by other commands.
- **Alternatives considered**: Abort entire batch on conflict (rejected—user explicitly requested skip behavior); auto-adjust numbers (rejected—violates preview/apply parity).
## Directory Inclusion Policy
- **Decision**: Filter traversal results so directories included via `--include-dirs` are reported but not renamed; numbering applies only to file candidates.
- **Rationale**: Keeps command behavior predictable, avoids confusing two numbering schemes, and respects clarified requirement without altering traversal contract tests.
- **Alternatives considered**: Separate numbering sequences for files vs directories (rejected—adds complexity with little user need); rename directories by default (rejected—breaks clarified guidance).

View File

@@ -0,0 +1,109 @@
# Feature Specification: Sequence Numbering Command
**Feature Branch**: `001-sequence-numbering`
**Created**: 2025-10-31
**Status**: Draft
**Input**: User description: "添加 sequence 功能,为重命名文件添加序号,可以定义序列号长度 使用0左填充可以指定序列号开始序号"
## Clarifications
### Session 2025-11-03
- Q: How should the command behave when the generated filename already exists outside the current batch (e.g., `file_001.txt`)? → A: Skip conflicting files, continue, and warn.
- Q: How are sequence numbers applied when new files appear between preview and apply, potentially altering traversal order? → A: Ignore new files and rename only the previewed set.
- Q: What validation and messaging occur when the starting number, width, or separator arguments are invalid (negative numbers, zero width, multi-character separator conflicting with filesystem rules)? → A: Hard error with non-zero exit and no preview output.
- Q: How is numbering handled for directories when `--include-dirs` is used alongside files within the same traversal scope? → A: Do not rename directories; sequence applies to files only.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Add Sequential Indices to Batch (Priority: P1)
As a content manager preparing assets for delivery, I want to append an auto-incrementing number to each filename within my selected scope so that downstream systems receive files in a predictable order.
**Why this priority**: Sequencing multiple files is the primary value of the feature and removes the need for external renaming tools for common workflows such as media preparation or document packaging.
**Independent Test**: Place three files in a working directory, run `renamer sequence --path <dir> --dry-run`, and verify the preview shows `001_`, `002_`, `003_` prefixes in deterministic order. Re-run with `--yes` and confirm the ledger captures the batch.
**Acceptance Scenarios**:
1. **Given** a directory containing `draft.txt`, `notes.txt`, and `plan.txt`, **When** the user runs `renamer sequence --dry-run --path <dir>`, **Then** the preview lists each file renamed with a `001_` prefix in alphabetical order and reports the candidate totals.
2. **Given** the same directory and preview, **When** the user re-executes the command with `--yes`, **Then** the CLI reports three files updated and the `.renamer` ledger stores the sequence configuration.
---
### User Story 2 - Control Number Formatting (Priority: P2)
As an archivist following strict naming standards, I want to define the sequence width and zero padding so the filenames meet fixed-length requirements without additional scripts.
**Why this priority**: Formatting options broaden adoption by matching industry conventions (e.g., four-digit reels) and avoid manual corrections after renaming.
**Independent Test**: Run `renamer sequence --width 4 --path <dir> --dry-run` and confirm every previewed filename contains a four-digit, zero-padded sequence value.
**Acceptance Scenarios**:
1. **Given** files `cutA.mov` and `cutB.mov`, **When** the user runs `renamer sequence --width 4 --dry-run`, **Then** the preview shows `0001_` and `0002_` prefixes despite having only two files.
2. **Given** the same files, **When** the user omits an explicit width, **Then** the preview pads only as needed to accommodate the highest sequence number (e.g., `1_`, `2_`, `10_`).
---
### User Story 3 - Configure Starting Number and Placement (Priority: P3)
As a production coordinator resuming interrupted work, I want to choose the starting sequence value and whether the number appears as a prefix or suffix so I can continue existing numbering schemes without renaming older assets.
**Why this priority**: Starting offsets and placement control reduce rework when numbering must align with partner systems or previously delivered batches.
**Independent Test**: Run `renamer sequence --start 10 --dry-run` and confirm the preview begins at `010_` and inserts the number before the filename stem with the configured separator.
**Acceptance Scenarios**:
1. **Given** files `shotA.exr` and `shotB.exr`, **When** the user runs `renamer sequence --start 10 --dry-run`, **Then** the preview numbers the files starting at `010_` and `011_`.
2. **Given** files `cover.png` and `index.png`, **When** the user runs `renamer sequence --placement prefix --separator "-" --dry-run`, **Then** the preview shows names such as `001-cover.png` and `002-index.png` with the separator preserved.
---
### Edge Cases
- Generated filename conflicts with an existing filesystem entry outside the batch: skip the conflicting candidate, continue with the rest, and warn with conflict details.
- Requested width smaller than digits required is automatically expanded with a warning so numbering completes without truncation.
- New files encountered between preview and apply are ignored; only the previewed candidates are renamed.
- Invalid starting number, width, or separator arguments produce a hard error with non-zero exit status; no preview or apply runs until corrected.
- Directories included via `--include-dirs` are left unchanged; numbering applies exclusively to files.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The CLI MUST expose a `sequence` command that applies an ordered numbering rule to all candidates within the current scope while preserving the preview-first workflow used by other renamer commands.
- **FR-002**: The command MUST use a deterministic ordering strategy (default: path-sorted traversal after scope filters) so preview and apply yield identical sequences.
- **FR-003**: Users MUST be able to configure the sequence starting value via a `--start` flag (default `1`) accepting positive integers only, with validation errors for invalid input.
- **FR-004**: Users MUST be able to configure the minimum digit width via a `--width` flag (default determined by total candidates) and the tool MUST zero-pad numbers to match the requested width.
- **FR-005**: Users MUST be able to choose number placement (`prefix` or `suffix`, default prefix) and optionally set a separator string plus static number prefix/suffix tokens while preserving file extensions and directory structure.
- **FR-006**: Preview output MUST display original and proposed names, total candidates, total changed, and warnings when numbering would exceed the requested width or create conflicts.
- **FR-007**: Apply MUST record the numbering rule (start, width, placement, separator, ordering) in the `.renamer` ledger, alongside per-file operations, so that undo can faithfully restore original names.
- **FR-008**: Undo MUST revert sequence-based renames in reverse order even if additional files have been added since the apply step, skipping only those already removed.
- **FR-009**: The `sequence` command MUST respect existing scope flags (`--path`, `--recursive`, `--include-dirs`, `--hidden`, `--extensions`, `--dry-run`, `--yes`) with identical semantics to other commands.
- **FR-010**: When numbering would collide with an existing filesystem entry, the CLI MUST skip the conflicting candidate, continue numbering the remaining files, and emit a warning that lists the skipped items; apply MUST still abort if scope filters yield zero candidates.
- **FR-011**: Invalid formatting arguments (negative start, zero/negative width, unsupported separator) MUST trigger a human-readable error, exit with non-zero status, and prevent preview/apply execution.
- **FR-012**: Directories included in scope via `--include-dirs` MUST be preserved without numbering; only files receive sequence numbers while directories remain untouched.
### Key Entities *(include if feature involves data)*
- **SequenceRequest**: Captures user-supplied configuration (start value, width, placement, separator, scope flags, execution mode).
- **SequencePlan**: Represents the ordered list of candidate files with assigned sequence numbers, proposed names, conflicts, and summary counts.
- **SequenceLedgerEntry**: Stores metadata required for undo, including request parameters, execution timestamp, and file rename mappings.
### Assumptions
- Ordering follows the preview list sorted by relative path unless future features introduce additional ordering controls.
- If numbering exceeds the requested width, the command extends the width automatically, surfaces a warning, and continues rather than failing the batch.
- Default placement is prefix with an underscore separator (e.g., `001_name.ext`) unless overridden by flags.
- Scope and ledger behavior mirror existing rename commands; no new traversal modes are introduced.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can number 500 files (preview + apply) in under 120 seconds on a representative workstation without manual intervention.
- **SC-002**: At least 95% of sampled previews match their subsequent apply results exactly during beta testing (no ordering drift or mismatched numbering).
- **SC-003**: 90% of beta participants report that numbering settings (start value, width, placement) meet their formatting needs without external tools.
- **SC-004**: Support requests related to manual numbering workflows decrease by 40% within one release cycle after launch.

View File

@@ -0,0 +1,115 @@
# Tasks: Sequence Numbering Command
**Input**: Design documents from `/specs/001-sequence-numbering/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Establish scaffolding required by all user stories.
- [X] T001 Create sequence package documentation stub in `internal/sequence/doc.go`
- [X] T002 Seed sample fixtures for numbering scenarios in `testdata/sequence/basic/`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared components that every sequence story depends on.
- [X] T003 Define sequence options struct with default values in `internal/sequence/options.go`
- [X] T004 Implement zero-padding formatter helper in `internal/sequence/format.go`
- [X] T005 Introduce plan and summary data structures in `internal/sequence/plan.go`
**Checkpoint**: Base package compiles with shared types ready for story work.
---
## Phase 3: User Story 1 - Add Sequential Indices to Batch (Priority: P1) 🎯 MVP
**Goal**: Append auto-incremented suffixes (e.g., `_001`) to scoped files with deterministic ordering and ledger persistence.
**Independent Test**: `renamer sequence --dry-run --path <dir>` on three files shows `_001`, `_002`, `_003`; rerun with `--yes` updates ledger.
### Tests for User Story 1
- [X] T006 [P] [US1] Add preview contract test covering default numbering in `tests/contract/sequence_preview_test.go`
- [X] T007 [P] [US1] Add integration flow test verifying preview/apply parity in `tests/integration/sequence_flow_test.go`
### Implementation for User Story 1
- [X] T008 [US1] Implement candidate traversal adapter using listing scope in `internal/sequence/traversal.go`
- [X] T009 [US1] Generate preview plan with conflict detection in `internal/sequence/preview.go`
- [X] T010 [US1] Apply renames and record sequence metadata in `internal/sequence/apply.go`
- [X] T011 [US1] Wire Cobra sequence command execution in `cmd/sequence.go`
- [X] T012 [US1] Register sequence command on the root command in `cmd/root.go`
**Checkpoint**: Sequence preview/apply for default suffix behavior is fully testable and undoable.
---
## Phase 4: User Story 2 - Control Number Formatting (Priority: P2)
**Goal**: Allow explicit width flag with zero padding and warning when auto-expanding.
**Independent Test**: `renamer sequence --width 4 --dry-run` shows `_0001` suffixes; omitting width auto-expands on demand.
### Tests for User Story 2
- [X] T013 [P] [US2] Add contract test for explicit width padding in `tests/contract/sequence_width_test.go`
- [X] T014 [P] [US2] Add integration test validating width flag and warnings in `tests/integration/sequence_width_test.go`
### Implementation for User Story 2
- [X] T015 [US2] Extend options validation to handle width flag rules in `internal/sequence/options.go`
- [X] T016 [US2] Update preview planner to enforce configured width and warnings in `internal/sequence/preview.go`
- [X] T017 [US2] Parse and bind `--width` flag within Cobra command in `cmd/sequence.go`
**Checkpoint**: Users can control sequence width with deterministic zero-padding.
---
## Phase 5: User Story 3 - Configure Starting Number and Placement (Priority: P3)
**Goal**: Support custom start offsets plus prefix/suffix placement with configurable separator.
**Independent Test**: `renamer sequence --start 10 --placement prefix --separator "-" --dry-run` produces `0010-file.ext` entries.
### Tests for User Story 3
- [X] T018 [P] [US3] Add contract test for start and placement variants in `tests/contract/sequence_placement_test.go`
- [X] T019 [P] [US3] Add integration test for start offset with undo coverage in `tests/integration/sequence_start_test.go`
### Implementation for User Story 3
- [X] T020 [US3] Validate start, placement, and separator flags in `internal/sequence/options.go`
- [X] T021 [US3] Update preview generation to honor prefix/suffix placement and separators in `internal/sequence/preview.go`
- [X] T022 [US3] Persist placement and separator metadata during apply in `internal/sequence/apply.go`
- [X] T023 [US3] Wire `--start`, `--placement`, and `--separator` flags in `cmd/sequence.go`
**Checkpoint**: Placement and numbering customization scenarios fully supported with ledger fidelity.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T024 [P] Document sequence command flags in `docs/cli-flags.md`
- [X] T025 [P] Log sequence feature addition in `docs/CHANGELOG.md`
- [X] T026 [P] Update command overview with sequence entry in `README.md`
---
## Dependencies
- Setup (Phase 1) → Foundational (Phase 2) → US1 (Phase 3) → US2 (Phase 4) → US3 (Phase 5) → Polish (Phase 6)
- User Story dependencies: `US1` completion unlocks `US2`; `US2` completion unlocks `US3`.
## Parallel Execution Opportunities
- Contract and integration test authoring tasks (T006, T007, T013, T014, T018, T019) can run concurrently with implementation once shared scaffolding is ready.
- Documentation polish tasks (T024T026) can be executed in parallel after all story implementations stabilize.
## Implementation Strategy
1. Deliver MVP by completing Phase 13 (US1), enabling default sequence numbering with undo.
2. Iterate with formatting controls (Phase 4) to broaden usability while maintaining preview/apply parity.
3. Finish with placement customization (Phase 5) and polish tasks (Phase 6) before release.

1
testdata/sequence/basic/draft.txt vendored Normal file
View File

@@ -0,0 +1 @@
alpha

1
testdata/sequence/basic/notes.txt vendored Normal file
View File

@@ -0,0 +1 @@
bravo

1
testdata/sequence/basic/plan.txt vendored Normal file
View File

@@ -0,0 +1 @@
charlie

1
testdata/sequence/demo/alpha.txt vendored Normal file
View File

@@ -0,0 +1 @@
alpha demo

1
testdata/sequence/demo/beta.txt vendored Normal file
View File

@@ -0,0 +1 @@
beta demo

1
testdata/sequence/demo/gamma.txt vendored Normal file
View File

@@ -0,0 +1 @@
gamma demo

View File

@@ -0,0 +1,76 @@
package contract
import (
"context"
"path/filepath"
"testing"
"github.com/rogeecn/renamer/internal/sequence"
)
func TestSequencePreviewWithPrefixPlacement(t *testing.T) {
tmp := t.TempDir()
createFile(t, filepath.Join(tmp, "cover.png"))
createFile(t, filepath.Join(tmp, "index.png"))
opts := sequence.DefaultOptions()
opts.WorkingDir = tmp
opts.Start = 10
opts.Placement = sequence.PlacementPrefix
opts.Separator = "-"
plan, err := sequence.Preview(context.Background(), opts, nil)
if err != nil {
t.Fatalf("preview error: %v", err)
}
expected := []string{"010-cover.png", "011-index.png"}
if len(plan.Candidates) != len(expected) {
t.Fatalf("expected %d candidates, got %d", len(expected), len(plan.Candidates))
}
for i, candidate := range plan.Candidates {
if candidate.ProposedPath != expected[i] {
t.Fatalf("candidate %d proposed %s, expected %s", i, candidate.ProposedPath, expected[i])
}
}
if plan.Config.Start != 10 {
t.Fatalf("expected config start 10, got %d", plan.Config.Start)
}
if plan.Config.Placement != sequence.PlacementPrefix {
t.Fatalf("expected prefix placement in config")
}
}
func TestSequencePreviewWithNumberPrefixLabel(t *testing.T) {
tmp := t.TempDir()
createFile(t, filepath.Join(tmp, "cover.png"))
createFile(t, filepath.Join(tmp, "index.png"))
ops := sequence.DefaultOptions()
ops.WorkingDir = tmp
ops.Placement = sequence.PlacementPrefix
ops.Separator = "-"
ops.NumberPrefix = "seq"
plan, err := sequence.Preview(context.Background(), ops, nil)
if err != nil {
t.Fatalf("preview error: %v", err)
}
expected := []string{"seq001-cover.png", "seq002-index.png"}
if len(plan.Candidates) != len(expected) {
t.Fatalf("expected %d candidates, got %d", len(expected), len(plan.Candidates))
}
for i, candidate := range plan.Candidates {
if candidate.ProposedPath != expected[i] {
t.Fatalf("candidate %d proposed %s, expected %s", i, candidate.ProposedPath, expected[i])
}
}
if warnings := plan.Summary.Warnings; len(warnings) != 0 {
t.Fatalf("expected no warnings, got %#v", warnings)
}
}

View File

@@ -0,0 +1,53 @@
package contract
import (
"context"
"path/filepath"
"testing"
"github.com/rogeecn/renamer/internal/sequence"
)
func TestSequencePreviewDefaultNumbering(t *testing.T) {
tmp := t.TempDir()
createFile(t, filepath.Join(tmp, "draft.txt"))
createFile(t, filepath.Join(tmp, "notes.txt"))
createFile(t, filepath.Join(tmp, "plan.txt"))
opts := sequence.DefaultOptions()
opts.WorkingDir = tmp
plan, err := sequence.Preview(context.Background(), opts, nil)
if err != nil {
t.Fatalf("preview error: %v", err)
}
if plan.Summary.TotalCandidates != 3 {
t.Fatalf("expected 3 candidates, got %d", plan.Summary.TotalCandidates)
}
if plan.Summary.RenamedCount != 3 {
t.Fatalf("expected 3 renamed entries, got %d", plan.Summary.RenamedCount)
}
expected := []string{"001_draft.txt", "002_notes.txt", "003_plan.txt"}
if len(plan.Candidates) != 3 {
t.Fatalf("expected 3 planned candidates, got %d", len(plan.Candidates))
}
for i, candidate := range plan.Candidates {
if candidate.ProposedPath != expected[i] {
t.Fatalf("candidate %d proposed %s, expected %s", i, candidate.ProposedPath, expected[i])
}
}
if plan.Summary.AppliedWidth != 3 {
t.Fatalf("expected applied width 3, got %d", plan.Summary.AppliedWidth)
}
if plan.Config.Start != 1 {
t.Fatalf("expected start 1, got %d", plan.Config.Start)
}
if plan.Config.Placement != sequence.PlacementPrefix {
t.Fatalf("expected prefix placement, got %s", plan.Config.Placement)
}
}

View File

@@ -0,0 +1,39 @@
package contract
import (
"context"
"path/filepath"
"testing"
"github.com/rogeecn/renamer/internal/sequence"
)
func TestSequencePreviewWithExplicitWidth(t *testing.T) {
tmp := t.TempDir()
createFile(t, filepath.Join(tmp, "cutA.mov"))
createFile(t, filepath.Join(tmp, "cutB.mov"))
opts := sequence.DefaultOptions()
opts.WorkingDir = tmp
opts.Width = 4
plan, err := sequence.Preview(context.Background(), opts, nil)
if err != nil {
t.Fatalf("preview error: %v", err)
}
expected := []string{"0001_cutA.mov", "0002_cutB.mov"}
if len(plan.Candidates) != len(expected) {
t.Fatalf("expected %d candidates, got %d", len(expected), len(plan.Candidates))
}
for i, candidate := range plan.Candidates {
if candidate.ProposedPath != expected[i] {
t.Fatalf("candidate %d proposed %s, expected %s", i, candidate.ProposedPath, expected[i])
}
}
if plan.Summary.AppliedWidth != 4 {
t.Fatalf("expected applied width 4, got %d", plan.Summary.AppliedWidth)
}
}

View File

@@ -0,0 +1,70 @@
package integration
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/rogeecn/renamer/internal/history"
"github.com/rogeecn/renamer/internal/sequence"
)
func TestSequenceApplyAndUndo(t *testing.T) {
tmp := t.TempDir()
createIntegrationFile(t, filepath.Join(tmp, "draft.txt"))
createIntegrationFile(t, filepath.Join(tmp, "notes.txt"))
opts := sequence.DefaultOptions()
opts.WorkingDir = tmp
plan, err := sequence.Preview(context.Background(), opts, nil)
if err != nil {
t.Fatalf("preview error: %v", err)
}
if plan.Summary.RenamedCount != 2 {
t.Fatalf("expected 2 planned renames, got %d", plan.Summary.RenamedCount)
}
entry, err := sequence.Apply(context.Background(), opts, plan)
if err != nil {
t.Fatalf("apply error: %v", err)
}
if len(entry.Operations) != 2 {
t.Fatalf("expected 2 recorded operations, got %d", len(entry.Operations))
}
if entry.Command != "sequence" {
t.Fatalf("expected ledger command 'sequence', got %s", entry.Command)
}
if _, err := os.Stat(filepath.Join(tmp, "001_draft.txt")); err != nil {
t.Fatalf("expected renamed file exists: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "002_notes.txt")); err != nil {
t.Fatalf("expected renamed file exists: %v", err)
}
if _, err := history.Undo(tmp); err != nil {
t.Fatalf("undo error: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "draft.txt")); err != nil {
t.Fatalf("expected original file after undo: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "notes.txt")); err != nil {
t.Fatalf("expected original file after undo: %v", err)
}
}
func createIntegrationFile(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("data"), 0o644); err != nil {
t.Fatalf("write file %s: %v", path, err)
}
}

View File

@@ -0,0 +1,53 @@
package integration
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/rogeecn/renamer/internal/history"
"github.com/rogeecn/renamer/internal/sequence"
)
func TestSequenceApplyWithNumberPrefix(t *testing.T) {
tmp := t.TempDir()
createIntegrationFile(t, filepath.Join(tmp, "cover.png"))
createIntegrationFile(t, filepath.Join(tmp, "index.png"))
opts := sequence.DefaultOptions()
opts.WorkingDir = tmp
opts.Placement = sequence.PlacementPrefix
opts.Separator = "-"
opts.NumberPrefix = "seq"
plan, err := sequence.Preview(context.Background(), opts, nil)
if err != nil {
t.Fatalf("preview error: %v", err)
}
entry, err := sequence.Apply(context.Background(), opts, plan)
if err != nil {
t.Fatalf("apply error: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "seq001-cover.png")); err != nil {
t.Fatalf("expected renamed file exists: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "seq002-index.png")); err != nil {
t.Fatalf("expected renamed file exists: %v", err)
}
meta, ok := entry.Metadata["sequence"].(map[string]any)
if !ok {
t.Fatalf("sequence metadata missing")
}
if prefix, ok := meta["prefix"].(string); !ok || prefix != "seq" {
t.Fatalf("expected metadata prefix 'seq', got %#v", meta["prefix"])
}
if _, err := history.Undo(tmp); err != nil {
t.Fatalf("undo error: %v", err)
}
}

View File

@@ -0,0 +1,65 @@
package integration
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/rogeecn/renamer/internal/history"
"github.com/rogeecn/renamer/internal/sequence"
)
func TestSequenceApplyWithStartOffset(t *testing.T) {
tmp := t.TempDir()
createIntegrationFile(t, filepath.Join(tmp, "shotA.exr"))
createIntegrationFile(t, filepath.Join(tmp, "shotB.exr"))
opts := sequence.DefaultOptions()
opts.WorkingDir = tmp
opts.Start = 10
plan, err := sequence.Preview(context.Background(), opts, nil)
if err != nil {
t.Fatalf("preview error: %v", err)
}
expected := []string{"010_shotA.exr", "011_shotB.exr"}
if len(plan.Candidates) != len(expected) {
t.Fatalf("expected %d candidates, got %d", len(expected), len(plan.Candidates))
}
for i, candidate := range plan.Candidates {
if candidate.ProposedPath != expected[i] {
t.Fatalf("candidate %d proposed %s, expected %s", i, candidate.ProposedPath, expected[i])
}
}
entry, err := sequence.Apply(context.Background(), opts, plan)
if err != nil {
t.Fatalf("apply error: %v", err)
}
if len(entry.Operations) != 2 {
t.Fatalf("expected 2 operations, got %d", len(entry.Operations))
}
if _, err := os.Stat(filepath.Join(tmp, "010_shotA.exr")); err != nil {
t.Fatalf("expected renamed file exists: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "011_shotB.exr")); err != nil {
t.Fatalf("expected renamed file exists: %v", err)
}
meta, ok := entry.Metadata["sequence"].(map[string]any)
if !ok {
t.Fatalf("sequence metadata missing")
}
if start, ok := meta["start"].(int); !ok || start != 10 {
t.Fatalf("expected metadata start 10, got %#v", meta["start"])
}
if _, err := history.Undo(tmp); err != nil {
t.Fatalf("undo error: %v", err)
}
}

View File

@@ -0,0 +1,59 @@
package integration
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/rogeecn/renamer/internal/history"
"github.com/rogeecn/renamer/internal/sequence"
)
func TestSequenceApplyWithExplicitWidth(t *testing.T) {
tmp := t.TempDir()
createIntegrationFile(t, filepath.Join(tmp, "cutA.mov"))
createIntegrationFile(t, filepath.Join(tmp, "cutB.mov"))
opts := sequence.DefaultOptions()
opts.WorkingDir = tmp
opts.Width = 4
plan, err := sequence.Preview(context.Background(), opts, nil)
if err != nil {
t.Fatalf("preview error: %v", err)
}
if plan.Summary.AppliedWidth != 4 {
t.Fatalf("expected applied width 4, got %d", plan.Summary.AppliedWidth)
}
entry, err := sequence.Apply(context.Background(), opts, plan)
if err != nil {
t.Fatalf("apply error: %v", err)
}
if len(entry.Operations) != 2 {
t.Fatalf("expected 2 operations, got %d", len(entry.Operations))
}
if _, err := os.Stat(filepath.Join(tmp, "0001_cutA.mov")); err != nil {
t.Fatalf("expected renamed file exists: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "0002_cutB.mov")); err != nil {
t.Fatalf("expected renamed file exists: %v", err)
}
meta, ok := entry.Metadata["sequence"].(map[string]any)
if !ok {
t.Fatalf("sequence metadata missing")
}
if width, ok := meta["width"].(int); !ok || width != 4 {
t.Fatalf("expected metadata width 4, got %#v", meta["width"])
}
if _, err := history.Undo(tmp); err != nil {
t.Fatalf("undo error: %v", err)
}
}