Files
renamer/internal/regex/preview.go
2025-10-31 10:12:02 +08:00

191 lines
5.1 KiB
Go

package regex
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// PlannedRename represents a proposed rename resulting from preview.
type PlannedRename struct {
SourceRelative string
SourceAbsolute string
TargetRelative string
TargetAbsolute string
MatchGroups []string
Depth int
}
// Preview evaluates the regex rename request and returns a summary plus the planned operations.
func Preview(ctx context.Context, req Request, out io.Writer) (Summary, []PlannedRename, error) {
reqCopy := req
if err := reqCopy.Validate(); err != nil {
return Summary{}, nil, err
}
engine, err := NewEngine(reqCopy.Pattern, reqCopy.Template)
if err != nil {
return Summary{}, nil, err
}
summary := Summary{
LedgerMetadata: map[string]any{
"pattern": reqCopy.Pattern,
"template": reqCopy.Template,
},
Entries: make([]PreviewEntry, 0),
}
planned := make([]PlannedRename, 0)
plannedTargets := make(map[string]string)
plannedTargetsFold := make(map[string]string)
err = TraverseCandidates(ctx, &reqCopy, func(candidate Candidate) error {
summary.TotalCandidates++
rendered, groups, matched, err := engine.Apply(candidate.Stem)
if err != nil {
summary.Warnings = append(summary.Warnings, err.Error())
summary.Skipped++
summary.Entries = append(summary.Entries, PreviewEntry{
OriginalPath: candidate.RelativePath,
ProposedPath: candidate.RelativePath,
Status: EntrySkipped,
})
return nil
}
if !matched {
summary.Skipped++
summary.Entries = append(summary.Entries, PreviewEntry{
OriginalPath: candidate.RelativePath,
ProposedPath: candidate.RelativePath,
Status: EntrySkipped,
})
return nil
}
summary.Matched++
proposedName := rendered
if !candidate.IsDir && candidate.Extension != "" {
proposedName += candidate.Extension
}
dir := filepath.Dir(candidate.RelativePath)
if dir == "." {
dir = ""
}
var proposedRelative string
if dir != "" {
proposedRelative = filepath.ToSlash(filepath.Join(dir, proposedName))
} else {
proposedRelative = filepath.ToSlash(proposedName)
}
matchEntry := PreviewEntry{
OriginalPath: candidate.RelativePath,
ProposedPath: proposedRelative,
MatchGroups: groups,
}
if proposedRelative == candidate.RelativePath {
summary.Entries = append(summary.Entries, PreviewEntry{
OriginalPath: candidate.RelativePath,
ProposedPath: candidate.RelativePath,
Status: EntryNoChange,
MatchGroups: groups,
})
return nil
}
if proposedName == "" || proposedRelative == "" {
summary.Conflicts = append(summary.Conflicts, Conflict{
OriginalPath: candidate.RelativePath,
ProposedPath: proposedRelative,
Reason: ConflictInvalidTemplate,
})
summary.Skipped++
matchEntry.Status = EntrySkipped
summary.Entries = append(summary.Entries, matchEntry)
return nil
}
if existing, ok := plannedTargets[proposedRelative]; ok && existing != candidate.RelativePath {
summary.Conflicts = append(summary.Conflicts, Conflict{
OriginalPath: candidate.RelativePath,
ProposedPath: proposedRelative,
Reason: ConflictDuplicateTarget,
})
summary.Skipped++
matchEntry.Status = EntrySkipped
summary.Entries = append(summary.Entries, matchEntry)
return nil
}
casefoldKey := strings.ToLower(proposedRelative)
if existing, ok := plannedTargetsFold[casefoldKey]; ok && existing != candidate.RelativePath {
summary.Conflicts = append(summary.Conflicts, Conflict{
OriginalPath: candidate.RelativePath,
ProposedPath: proposedRelative,
Reason: ConflictDuplicateTarget,
})
summary.Skipped++
matchEntry.Status = EntrySkipped
summary.Entries = append(summary.Entries, matchEntry)
return nil
}
targetAbsolute := filepath.Join(reqCopy.WorkingDir, filepath.FromSlash(proposedRelative))
if info, statErr := os.Stat(targetAbsolute); statErr == nil {
origInfo, origErr := os.Stat(candidate.OriginalPath)
if origErr != nil || !os.SameFile(info, origInfo) {
reason := ConflictExistingFile
if info.IsDir() {
reason = ConflictExistingDir
}
summary.Conflicts = append(summary.Conflicts, Conflict{
OriginalPath: candidate.RelativePath,
ProposedPath: proposedRelative,
Reason: reason,
})
summary.Skipped++
matchEntry.Status = EntrySkipped
summary.Entries = append(summary.Entries, matchEntry)
return nil
}
}
plannedTargets[proposedRelative] = candidate.RelativePath
plannedTargetsFold[casefoldKey] = candidate.RelativePath
matchEntry.Status = EntryChanged
summary.Entries = append(summary.Entries, matchEntry)
summary.Changed++
planned = append(planned, PlannedRename{
SourceRelative: candidate.RelativePath,
SourceAbsolute: candidate.OriginalPath,
TargetRelative: proposedRelative,
TargetAbsolute: targetAbsolute,
MatchGroups: groups,
Depth: candidate.Depth,
})
if out != nil {
fmt.Fprintf(out, "%s -> %s\n", candidate.RelativePath, proposedRelative)
}
return nil
})
if err != nil {
return Summary{}, nil, err
}
return summary, planned, nil
}