Add configurable sequence numbering command
This commit is contained in:
@@ -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 -->
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
158
cmd/sequence.go
Normal 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())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
113
internal/sequence/apply.go
Normal 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
2
internal/sequence/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package sequence implements the numbering rule used by the `renamer sequence` command.
|
||||
package sequence
|
||||
20
internal/sequence/format.go
Normal file
20
internal/sequence/format.go
Normal 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
|
||||
}
|
||||
95
internal/sequence/options.go
Normal file
95
internal/sequence/options.go
Normal 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
68
internal/sequence/plan.go
Normal 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
|
||||
}
|
||||
195
internal/sequence/preview.go
Normal file
195
internal/sequence/preview.go
Normal 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,
|
||||
})
|
||||
}
|
||||
92
internal/sequence/traversal.go
Normal file
92
internal/sequence/traversal.go
Normal 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
|
||||
}
|
||||
34
specs/001-sequence-numbering/checklists/requirements.md
Normal file
34
specs/001-sequence-numbering/checklists/requirements.md
Normal 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`
|
||||
241
specs/001-sequence-numbering/contracts/sequence.openapi.yaml
Normal file
241
specs/001-sequence-numbering/contracts/sequence.openapi.yaml
Normal 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
|
||||
68
specs/001-sequence-numbering/data-model.md
Normal file
68
specs/001-sequence-numbering/data-model.md
Normal 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.
|
||||
87
specs/001-sequence-numbering/plan.md
Normal file
87
specs/001-sequence-numbering/plan.md
Normal 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] |
|
||||
55
specs/001-sequence-numbering/quickstart.md
Normal file
55
specs/001-sequence-numbering/quickstart.md
Normal 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.
|
||||
26
specs/001-sequence-numbering/research.md
Normal file
26
specs/001-sequence-numbering/research.md
Normal 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).
|
||||
109
specs/001-sequence-numbering/spec.md
Normal file
109
specs/001-sequence-numbering/spec.md
Normal 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.
|
||||
115
specs/001-sequence-numbering/tasks.md
Normal file
115
specs/001-sequence-numbering/tasks.md
Normal 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 (T024–T026) can be executed in parallel after all story implementations stabilize.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
1. Deliver MVP by completing Phase 1–3 (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
1
testdata/sequence/basic/draft.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
alpha
|
||||
1
testdata/sequence/basic/notes.txt
vendored
Normal file
1
testdata/sequence/basic/notes.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
bravo
|
||||
1
testdata/sequence/basic/plan.txt
vendored
Normal file
1
testdata/sequence/basic/plan.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
charlie
|
||||
1
testdata/sequence/demo/alpha.txt
vendored
Normal file
1
testdata/sequence/demo/alpha.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
alpha demo
|
||||
1
testdata/sequence/demo/beta.txt
vendored
Normal file
1
testdata/sequence/demo/beta.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
beta demo
|
||||
1
testdata/sequence/demo/gamma.txt
vendored
Normal file
1
testdata/sequence/demo/gamma.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
gamma demo
|
||||
76
tests/contract/sequence_placement_test.go
Normal file
76
tests/contract/sequence_placement_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
53
tests/contract/sequence_preview_test.go
Normal file
53
tests/contract/sequence_preview_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
39
tests/contract/sequence_width_test.go
Normal file
39
tests/contract/sequence_width_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
70
tests/integration/sequence_flow_test.go
Normal file
70
tests/integration/sequence_flow_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
53
tests/integration/sequence_prefix_test.go
Normal file
53
tests/integration/sequence_prefix_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
65
tests/integration/sequence_start_test.go
Normal file
65
tests/integration/sequence_start_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
59
tests/integration/sequence_width_test.go
Normal file
59
tests/integration/sequence_width_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user