Add configurable sequence numbering command
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user