Files
renamer/internal/sequence/preview.go

196 lines
5.2 KiB
Go

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,
})
}