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

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
}