188 lines
4.6 KiB
Go
188 lines
4.6 KiB
Go
package insert
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/rogeecn/renamer/internal/traversal"
|
|
)
|
|
|
|
// PlannedOperation captures a filesystem rename to be applied.
|
|
type PlannedOperation struct {
|
|
OriginalRelative string
|
|
OriginalAbsolute string
|
|
ProposedRelative string
|
|
ProposedAbsolute string
|
|
InsertedText string
|
|
IsDir bool
|
|
Depth int
|
|
}
|
|
|
|
// BuildPlan enumerates candidates, computes preview entries, and prepares filesystem operations.
|
|
func BuildPlan(ctx context.Context, req *Request) (*Summary, []PlannedOperation, error) {
|
|
if req == nil {
|
|
return nil, nil, errors.New("insert request cannot be nil")
|
|
}
|
|
if err := req.Normalize(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
summary := NewSummary()
|
|
operations := make([]PlannedOperation, 0)
|
|
detector := NewConflictDetector()
|
|
|
|
filterSet := make(map[string]struct{}, len(req.ExtensionFilter))
|
|
for _, ext := range req.ExtensionFilter {
|
|
filterSet[strings.ToLower(ext)] = 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
|
|
}
|
|
|
|
relative := filepath.ToSlash(relPath)
|
|
name := entry.Name()
|
|
|
|
ext := ""
|
|
stem := name
|
|
if !isDir {
|
|
ext = filepath.Ext(name)
|
|
stem = strings.TrimSuffix(name, ext)
|
|
}
|
|
|
|
if !isDir && len(filterSet) > 0 {
|
|
lowerExt := strings.ToLower(ext)
|
|
if _, ok := filterSet[lowerExt]; !ok {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
stemRunes := []rune(stem)
|
|
if err := ParseInputs(req.PositionToken, req.InsertText, len(stemRunes)); err != nil {
|
|
return err
|
|
}
|
|
|
|
position, err := ResolvePosition(req.PositionToken, len(stemRunes))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
status := StatusChanged
|
|
proposedRelative := relative
|
|
proposedAbsolute := filepath.Join(req.WorkingDir, filepath.FromSlash(relative))
|
|
|
|
var builder strings.Builder
|
|
builder.WriteString(string(stemRunes[:position.Index]))
|
|
builder.WriteString(req.InsertText)
|
|
builder.WriteString(string(stemRunes[position.Index:]))
|
|
proposedName := builder.String() + ext
|
|
|
|
dir := filepath.Dir(relative)
|
|
if dir == "." {
|
|
dir = ""
|
|
}
|
|
if dir == "" {
|
|
proposedRelative = filepath.ToSlash(proposedName)
|
|
} else {
|
|
proposedRelative = filepath.ToSlash(filepath.Join(dir, proposedName))
|
|
}
|
|
proposedAbsolute = filepath.Join(req.WorkingDir, filepath.FromSlash(proposedRelative))
|
|
|
|
if proposedRelative == relative {
|
|
status = StatusNoChange
|
|
}
|
|
|
|
if status == StatusChanged {
|
|
if reason, ok := detector.Register(relative, proposedRelative); !ok {
|
|
summary.AddConflict(Conflict{
|
|
OriginalPath: relative,
|
|
ProposedPath: proposedRelative,
|
|
Reason: "duplicate_target",
|
|
})
|
|
summary.AddWarning(reason)
|
|
status = StatusSkipped
|
|
} else if info, err := os.Stat(proposedAbsolute); err == nil {
|
|
origInfo, origErr := os.Stat(filepath.Join(req.WorkingDir, filepath.FromSlash(relative)))
|
|
if origErr != nil {
|
|
return origErr
|
|
}
|
|
if !os.SameFile(info, origInfo) {
|
|
reason := "existing_file"
|
|
if info.IsDir() {
|
|
reason = "existing_directory"
|
|
}
|
|
summary.AddConflict(Conflict{
|
|
OriginalPath: relative,
|
|
ProposedPath: proposedRelative,
|
|
Reason: reason,
|
|
})
|
|
summary.AddWarning("target already exists")
|
|
detector.Forget(proposedRelative)
|
|
status = StatusSkipped
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
|
|
if status == StatusChanged {
|
|
operations = append(operations, PlannedOperation{
|
|
OriginalRelative: relative,
|
|
OriginalAbsolute: filepath.Join(req.WorkingDir, filepath.FromSlash(relative)),
|
|
ProposedRelative: proposedRelative,
|
|
ProposedAbsolute: proposedAbsolute,
|
|
InsertedText: req.InsertText,
|
|
IsDir: isDir,
|
|
Depth: depth,
|
|
})
|
|
}
|
|
}
|
|
|
|
entrySummary := PreviewEntry{
|
|
OriginalPath: relative,
|
|
ProposedPath: proposedRelative,
|
|
Status: status,
|
|
}
|
|
if status == StatusChanged {
|
|
entrySummary.InsertedText = req.InsertText
|
|
}
|
|
|
|
summary.RecordEntry(entrySummary)
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
sort.SliceStable(summary.Entries, func(i, j int) bool {
|
|
return summary.Entries[i].OriginalPath < summary.Entries[j].OriginalPath
|
|
})
|
|
|
|
return summary, operations, nil
|
|
}
|