Add extension normalization command
This commit is contained in:
109
internal/extension/apply.go
Normal file
109
internal/extension/apply.go
Normal 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
|
||||
}
|
||||
58
internal/extension/conflicts.go
Normal file
58
internal/extension/conflicts.go
Normal 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
|
||||
}
|
||||
3
internal/extension/doc.go
Normal file
3
internal/extension/doc.go
Normal 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
|
||||
164
internal/extension/engine.go
Normal file
164
internal/extension/engine.go
Normal 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
|
||||
}
|
||||
59
internal/extension/normalize.go
Normal file
59
internal/extension/normalize.go
Normal 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
|
||||
}
|
||||
77
internal/extension/parser.go
Normal file
77
internal/extension/parser.go
Normal 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
|
||||
}
|
||||
92
internal/extension/preview.go
Normal file
92
internal/extension/preview.go
Normal 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
|
||||
}
|
||||
100
internal/extension/request.go
Normal file
100
internal/extension/request.go
Normal 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
|
||||
}
|
||||
92
internal/extension/summary.go
Normal file
92
internal/extension/summary.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user