Add extension normalization command

This commit is contained in:
Rogee
2025-10-30 10:31:53 +08:00
parent f66c59fd57
commit 6a353b5086
35 changed files with 2306 additions and 2 deletions

109
internal/extension/apply.go Normal file
View File

@@ -0,0 +1,109 @@
package extension
import (
"context"
"errors"
"os"
"path/filepath"
"sort"
"github.com/rogeecn/renamer/internal/history"
)
// Apply executes planned renames and records the operations in the ledger.
func Apply(ctx context.Context, req *ExtensionRequest, planned []PlannedRename, summary *ExtensionSummary) (history.Entry, error) {
entry := history.Entry{Command: "extension"}
if len(planned) == 0 {
return entry, nil
}
sort.SliceStable(planned, func(i, j int) bool {
return planned[i].Depth > planned[j].Depth
})
done := make([]history.Operation, 0, len(planned))
revert := func() error {
for i := len(done) - 1; i >= 0; i-- {
op := done[i]
source := filepath.Join(req.WorkingDir, filepath.FromSlash(op.To))
destination := filepath.Join(req.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 planned {
if err := ctx.Err(); err != nil {
_ = revert()
return history.Entry{}, err
}
if op.OriginalAbsolute == op.ProposedAbsolute {
continue
}
if err := os.Rename(op.OriginalAbsolute, op.ProposedAbsolute); err != nil {
_ = revert()
return history.Entry{}, err
}
done = append(done, history.Operation{
From: filepath.ToSlash(op.OriginalRelative),
To: filepath.ToSlash(op.ProposedRelative),
})
}
if len(done) == 0 {
return entry, nil
}
entry.Operations = done
if summary != nil {
meta := make(map[string]any)
if len(req.DisplaySourceExtensions) > 0 {
meta["sourceExtensions"] = append([]string(nil), req.DisplaySourceExtensions...)
}
if req.TargetExtension != "" {
meta["targetExtension"] = req.TargetExtension
}
meta["totalCandidates"] = summary.TotalCandidates
meta["totalChanged"] = summary.TotalChanged
meta["noChange"] = summary.NoChange
if len(summary.PerExtensionCounts) > 0 {
counts := make(map[string]int, len(summary.PerExtensionCounts))
for ext, count := range summary.PerExtensionCounts {
counts[ext] = count
}
meta["perExtensionCounts"] = counts
}
scope := map[string]any{
"includeDirs": req.IncludeDirs,
"recursive": req.Recursive,
"includeHidden": req.IncludeHidden,
}
if len(req.ExtensionFilter) > 0 {
scope["extensionFilter"] = append([]string(nil), req.ExtensionFilter...)
}
meta["scope"] = scope
if len(summary.Warnings) > 0 {
meta["warnings"] = append([]string(nil), summary.Warnings...)
}
entry.Metadata = meta
}
if err := history.Append(req.WorkingDir, entry); err != nil {
_ = revert()
return history.Entry{}, err
}
return entry, nil
}

View File

@@ -0,0 +1,58 @@
package extension
import (
"errors"
"fmt"
"os"
)
type conflictDetector struct {
planned map[string]string
}
func newConflictDetector() *conflictDetector {
return &conflictDetector{planned: make(map[string]string)}
}
// evaluateTarget inspects plan collisions and filesystem conflicts. It returns true when the
// rename can proceed, false when it must be skipped, propagating any hard errors encountered.
func (d *conflictDetector) evaluateTarget(summary *ExtensionSummary, candidateRel, targetRel, originalAbs, targetAbs string) (bool, error) {
if existing, ok := d.planned[targetRel]; ok && existing != candidateRel {
summary.AddConflict(Conflict{
OriginalPath: candidateRel,
ProposedPath: targetRel,
Reason: "duplicate_target",
})
summary.AddWarning(fmt.Sprintf("skipped %s because %s also maps to %s", candidateRel, existing, targetRel))
return false, nil
}
origInfo, origErr := os.Stat(originalAbs)
if origErr != nil {
return false, origErr
}
if info, err := os.Stat(targetAbs); err == nil {
if os.SameFile(info, origInfo) {
d.planned[targetRel] = candidateRel
return true, nil
}
reason := "existing_file"
if info.IsDir() {
reason = "existing_directory"
}
summary.AddConflict(Conflict{
OriginalPath: candidateRel,
ProposedPath: targetRel,
Reason: reason,
})
summary.AddWarning(fmt.Sprintf("skipped %s because %s already exists", candidateRel, targetRel))
return false, nil
} else if !errors.Is(err, os.ErrNotExist) {
return false, err
}
d.planned[targetRel] = candidateRel
return true, nil
}

View File

@@ -0,0 +1,3 @@
// Package extension provides the engine and utilities for normalizing file
// extensions using preview-first workflows shared across renamer commands.
package extension

View File

@@ -0,0 +1,164 @@
package extension
import (
"context"
"errors"
"io/fs"
"path/filepath"
"strings"
"github.com/rogeecn/renamer/internal/traversal"
)
// PlannedRename describes a filesystem rename operation produced during planning.
type PlannedRename struct {
OriginalRelative string
OriginalAbsolute string
ProposedRelative string
ProposedAbsolute string
SourceExtension string
IsDir bool
Depth int
}
// PlanResult captures the preview summary and concrete operations required for apply.
type PlanResult struct {
Summary *ExtensionSummary
Operations []PlannedRename
}
// BuildPlan walks the scoped filesystem, collecting preview entries and rename operations.
func BuildPlan(ctx context.Context, req *ExtensionRequest) (*PlanResult, error) {
if req == nil {
return nil, errors.New("extension request cannot be nil")
}
if err := req.Normalize(); err != nil {
return nil, err
}
summary := NewSummary()
operations := make([]PlannedRename, 0)
detector := newConflictDetector()
targetExt := NormalizeTargetExtension(req.TargetExtension)
targetCanonical := CanonicalExtension(targetExt)
sourceSet := make(map[string]struct{}, len(req.SourceExtensions))
for _, source := range req.SourceExtensions {
sourceSet[CanonicalExtension(source)] = struct{}{}
}
// Include the target extension so preview surfaces existing matches as no-ops.
matchable := make(map[string]struct{}, len(sourceSet)+1)
for ext := range sourceSet {
matchable[ext] = struct{}{}
}
matchable[targetCanonical] = struct{}{}
filterSet := make(map[string]struct{}, len(req.ExtensionFilter))
for _, filter := range req.ExtensionFilter {
filterSet[CanonicalExtension(filter)] = struct{}{}
}
walker := traversal.NewWalker()
err := walker.Walk(
req.WorkingDir,
req.Recursive,
req.IncludeDirs,
req.IncludeHidden,
0,
func(relPath string, entry fs.DirEntry, depth int) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if relPath == "." {
return nil
}
isDir := entry.IsDir()
if isDir && !req.IncludeDirs {
return nil
}
name := entry.Name()
rawExt := strings.TrimSpace(filepath.Ext(name))
canonicalExt := CanonicalExtension(rawExt)
if !isDir && len(filterSet) > 0 {
if _, ok := filterSet[canonicalExt]; !ok {
return nil
}
}
if _, ok := matchable[canonicalExt]; !ok {
return nil
}
relative := filepath.ToSlash(relPath)
originalAbsolute := filepath.Join(req.WorkingDir, filepath.FromSlash(relative))
status := PreviewStatusChanged
targetRelative := relative
targetAbsolute := originalAbsolute
if canonicalExt == targetCanonical && rawExt == targetExt {
status = PreviewStatusNoChange
}
if status == PreviewStatusChanged {
base := strings.TrimSuffix(name, rawExt)
targetName := base + targetExt
dir := filepath.Dir(relative)
if dir == "." {
dir = ""
}
if dir == "" {
targetRelative = filepath.ToSlash(targetName)
} else {
targetRelative = filepath.ToSlash(filepath.Join(dir, targetName))
}
targetAbsolute = filepath.Join(req.WorkingDir, filepath.FromSlash(targetRelative))
allowed, err := detector.evaluateTarget(summary, relative, targetRelative, originalAbsolute, targetAbsolute)
if err != nil {
return err
}
if allowed {
operations = append(operations, PlannedRename{
OriginalRelative: relative,
OriginalAbsolute: originalAbsolute,
ProposedRelative: targetRelative,
ProposedAbsolute: targetAbsolute,
SourceExtension: rawExt,
IsDir: isDir,
Depth: depth,
})
} else {
status = PreviewStatusSkipped
}
}
entrySummary := PreviewEntry{
OriginalPath: relative,
ProposedPath: targetRelative,
Status: status,
SourceExtension: rawExt,
}
summary.RecordEntry(entrySummary)
return nil
},
)
if err != nil {
return nil, err
}
return &PlanResult{
Summary: summary,
Operations: operations,
}, nil
}

View File

@@ -0,0 +1,59 @@
package extension
import "strings"
// NormalizeSourceExtensions returns case-insensitive unique source extensions while preserving
// the first-seen display token for each canonical value. Duplicate entries are surfaced for warnings.
func NormalizeSourceExtensions(inputs []string) (canonical []string, display []string, duplicates []string) {
seen := make(map[string]struct{})
for _, raw := range inputs {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
continue
}
canon := CanonicalExtension(trimmed)
if _, exists := seen[canon]; exists {
duplicates = append(duplicates, trimmed)
continue
}
seen[canon] = struct{}{}
canonical = append(canonical, canon)
display = append(display, trimmed)
}
return canonical, display, duplicates
}
// NormalizeTargetExtension trims surrounding whitespace but preserves caller-provided casing.
func NormalizeTargetExtension(target string) string {
return strings.TrimSpace(target)
}
// CanonicalExtension is the shared case-folded representation used for comparisons and maps.
func CanonicalExtension(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
// ExtensionsEqual reports true when two extensions match case-insensitively.
func ExtensionsEqual(a, b string) bool {
return CanonicalExtension(a) == CanonicalExtension(b)
}
// IdentifyNoOpSources returns the original tokens that would be no-ops against the target extension.
func IdentifyNoOpSources(original []string, target string) []string {
if len(original) == 0 {
return nil
}
canonTarget := CanonicalExtension(target)
var noOps []string
for _, token := range original {
if CanonicalExtension(token) == canonTarget {
noOps = append(noOps, token)
}
}
return noOps
}

View File

@@ -0,0 +1,77 @@
package extension
import (
"errors"
"fmt"
"strings"
)
// ParseResult captures normalized CLI arguments for the extension command.
type ParseResult struct {
SourcesCanonical []string
SourcesDisplay []string
Duplicates []string
NoOps []string
Target string
}
// ParseArgs validates extension command arguments and returns normalized tokens.
func ParseArgs(args []string) (ParseResult, error) {
if len(args) < 2 {
return ParseResult{}, errors.New("at least one source extension and a target extension are required")
}
target := NormalizeTargetExtension(args[len(args)-1])
if target == "" {
return ParseResult{}, errors.New("target extension cannot be empty")
}
if !strings.HasPrefix(target, ".") {
return ParseResult{}, fmt.Errorf("target extension %q must start with '.'", target)
}
if len(target) == 1 {
return ParseResult{}, fmt.Errorf("target extension %q must include characters after '.'", target)
}
rawSources := args[:len(args)-1]
trimmedSources := make([]string, len(rawSources))
for i, src := range rawSources {
trimmed := strings.TrimSpace(src)
if trimmed == "" {
return ParseResult{}, errors.New("source extensions cannot be empty")
}
if !strings.HasPrefix(trimmed, ".") {
return ParseResult{}, fmt.Errorf("source extension %q must start with '.'", trimmed)
}
if len(trimmed) == 1 {
return ParseResult{}, fmt.Errorf("source extension %q must include characters after '.'", trimmed)
}
trimmedSources[i] = trimmed
}
canonical, display, duplicates := NormalizeSourceExtensions(trimmedSources)
targetCanonical := CanonicalExtension(target)
filteredCanonical := make([]string, 0, len(canonical))
filteredDisplay := make([]string, 0, len(display))
noOps := make([]string, 0)
for i, canon := range canonical {
if canon == targetCanonical {
noOps = append(noOps, display[i])
continue
}
filteredCanonical = append(filteredCanonical, canon)
filteredDisplay = append(filteredDisplay, display[i])
}
if len(filteredCanonical) == 0 {
return ParseResult{}, errors.New("all source extensions match the target extension; provide at least one distinct source extension")
}
return ParseResult{
SourcesCanonical: filteredCanonical,
SourcesDisplay: filteredDisplay,
Duplicates: duplicates,
NoOps: noOps,
Target: target,
}, nil
}

View File

@@ -0,0 +1,92 @@
package extension
import (
"context"
"errors"
"fmt"
"io"
"sort"
"strings"
)
// Preview generates a summary and planned operations for an extension normalization run.
func Preview(ctx context.Context, req *ExtensionRequest, out io.Writer) (*ExtensionSummary, []PlannedRename, error) {
if req == nil {
return nil, nil, errors.New("extension request cannot be nil")
}
plan, err := BuildPlan(ctx, req)
if err != nil {
return nil, nil, err
}
summary := plan.Summary
for _, dup := range req.DuplicateSources {
summary.AddWarning(fmt.Sprintf("duplicate source extension ignored: %s", dup))
}
for _, noop := range req.NoOpSources {
summary.AddWarning(fmt.Sprintf("source extension matches target and is skipped: %s", noop))
}
if summary.TotalCandidates == 0 {
summary.AddWarning("no candidates found for provided extensions")
}
if out != nil {
// Ensure deterministic ordering for preview output.
sort.SliceStable(summary.Entries, func(i, j int) bool {
if summary.Entries[i].OriginalPath == summary.Entries[j].OriginalPath {
return summary.Entries[i].ProposedPath < summary.Entries[j].ProposedPath
}
return summary.Entries[i].OriginalPath < summary.Entries[j].OriginalPath
})
conflictReasons := make(map[string]string, len(summary.Conflicts))
for _, conflict := range summary.Conflicts {
key := fmt.Sprintf("%s->%s", conflict.OriginalPath, conflict.ProposedPath)
conflictReasons[key] = conflict.Reason
}
for _, entry := range summary.Entries {
switch entry.Status {
case PreviewStatusChanged:
if entry.ProposedPath == entry.OriginalPath {
fmt.Fprintf(out, "%s (pending extension update)\n", entry.OriginalPath)
} else {
fmt.Fprintf(out, "%s -> %s\n", entry.OriginalPath, entry.ProposedPath)
}
case PreviewStatusNoChange:
fmt.Fprintf(out, "%s (no change)\n", entry.OriginalPath)
case PreviewStatusSkipped:
reason := conflictReasons[fmt.Sprintf("%s->%s", entry.OriginalPath, entry.ProposedPath)]
if reason == "" {
reason = "skipped"
}
fmt.Fprintf(out, "%s -> %s (skipped: %s)\n", entry.OriginalPath, entry.ProposedPath, reason)
default:
fmt.Fprintf(out, "%s (status: %s)\n", entry.OriginalPath, entry.Status)
}
}
if summary.TotalCandidates > 0 {
fmt.Fprintf(out, "\nSummary: %d candidates, %d will change, %d already target extension\n",
summary.TotalCandidates, summary.TotalChanged, summary.NoChange)
} else {
fmt.Fprintln(out, "No candidates found.")
}
if len(summary.Warnings) > 0 {
fmt.Fprintln(out)
for _, warning := range summary.Warnings {
if !strings.HasPrefix(strings.ToLower(warning), "warning:") {
fmt.Fprintf(out, "Warning: %s\n", warning)
} else {
fmt.Fprintln(out, warning)
}
}
}
}
return summary, plan.Operations, nil
}

View File

@@ -0,0 +1,100 @@
package extension
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/rogeecn/renamer/internal/listing"
)
// ExtensionRequest captures all inputs required to evaluate an extension normalization run.
type ExtensionRequest struct {
WorkingDir string
SourceExtensions []string
DisplaySourceExtensions []string
TargetExtension string
DuplicateSources []string
NoOpSources []string
IncludeDirs bool
Recursive bool
IncludeHidden bool
ExtensionFilter []string
DryRun bool
AutoConfirm bool
Timestamp time.Time
}
// NewRequest seeds an ExtensionRequest using the shared listing scope settings.
func NewRequest(scope *listing.ListingRequest) *ExtensionRequest {
if scope == nil {
return &ExtensionRequest{}
}
filterCopy := append([]string(nil), scope.Extensions...)
return &ExtensionRequest{
WorkingDir: scope.WorkingDir,
IncludeDirs: scope.IncludeDirectories,
Recursive: scope.Recursive,
IncludeHidden: scope.IncludeHidden,
ExtensionFilter: filterCopy,
}
}
// SetExecutionMode records dry-run/auto-apply preferences inherited from CLI flags.
func (r *ExtensionRequest) SetExecutionMode(dryRun, autoConfirm bool) {
r.DryRun = dryRun
r.AutoConfirm = autoConfirm
}
// SetExtensions stores source and target extensions before normalization.
func (r *ExtensionRequest) SetExtensions(canonical []string, display []string, target string) {
r.SourceExtensions = append(r.SourceExtensions[:0], canonical...)
r.DisplaySourceExtensions = append(r.DisplaySourceExtensions[:0], display...)
r.TargetExtension = target
}
// SetWarnings captures duplicate or no-op tokens for later surfacing in preview output.
func (r *ExtensionRequest) SetWarnings(dupes, noOps []string) {
r.DuplicateSources = append(r.DuplicateSources[:0], dupes...)
r.NoOpSources = append(r.NoOpSources[:0], noOps...)
}
// Normalize ensures working directory and timestamp fields are ready for execution.
func (r *ExtensionRequest) Normalize() error {
if r.WorkingDir == "" {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("determine working directory: %w", err)
}
r.WorkingDir = cwd
}
if !filepath.IsAbs(r.WorkingDir) {
abs, err := filepath.Abs(r.WorkingDir)
if err != nil {
return fmt.Errorf("resolve working directory: %w", err)
}
r.WorkingDir = abs
}
info, err := os.Stat(r.WorkingDir)
if err != nil {
return fmt.Errorf("stat working directory: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("working directory %q is not a directory", r.WorkingDir)
}
if r.Timestamp.IsZero() {
r.Timestamp = time.Now().UTC()
}
return nil
}

View File

@@ -0,0 +1,92 @@
package extension
import (
"strings"
)
// PreviewStatus represents the outcome for a single preview entry.
type PreviewStatus string
const (
PreviewStatusChanged PreviewStatus = "changed"
PreviewStatusNoChange PreviewStatus = "no_change"
PreviewStatusSkipped PreviewStatus = "skipped"
)
// PreviewEntry captures a single original → proposed path mapping for preview output.
type PreviewEntry struct {
OriginalPath string
ProposedPath string
Status PreviewStatus
SourceExtension string
}
// Conflict describes a proposed rename that cannot be applied safely.
type Conflict struct {
OriginalPath string
ProposedPath string
Reason string
}
// ExtensionSummary aggregates counts, conflicts, warnings, and ledger metadata.
type ExtensionSummary struct {
TotalCandidates int
TotalChanged int
NoChange int
PerExtensionCounts map[string]int
Conflicts []Conflict
Warnings []string
Entries []PreviewEntry
LedgerMetadata map[string]any
}
// NewSummary constructs an empty ExtensionSummary with initialized maps.
func NewSummary() *ExtensionSummary {
return &ExtensionSummary{
PerExtensionCounts: make(map[string]int),
LedgerMetadata: make(map[string]any),
}
}
// RecordEntry appends a preview entry and updates aggregate counters.
func (s *ExtensionSummary) RecordEntry(entry PreviewEntry) {
s.Entries = append(s.Entries, entry)
s.TotalCandidates++
switch entry.Status {
case PreviewStatusChanged:
s.TotalChanged++
case PreviewStatusNoChange:
s.NoChange++
}
if entry.SourceExtension != "" {
key := strings.ToLower(entry.SourceExtension)
s.PerExtensionCounts[key]++
}
}
// AddConflict registers a new conflict encountered during planning.
func (s *ExtensionSummary) AddConflict(conflict Conflict) {
s.Conflicts = append(s.Conflicts, conflict)
}
// AddWarning ensures warning messages are collected without duplication.
func (s *ExtensionSummary) AddWarning(message string) {
if message == "" {
return
}
for _, existing := range s.Warnings {
if existing == message {
return
}
}
s.Warnings = append(s.Warnings, message)
}
// HasConflicts reports whether any blocking conflicts were recorded.
func (s *ExtensionSummary) HasConflicts() bool {
return len(s.Conflicts) > 0
}