Add insert command

This commit is contained in:
Rogee
2025-10-30 15:15:16 +08:00
parent 6a353b5086
commit a0d7084c28
35 changed files with 2044 additions and 37 deletions

85
internal/insert/apply.go Normal file
View File

@@ -0,0 +1,85 @@
package insert
import (
"context"
"errors"
"os"
"path/filepath"
"sort"
"github.com/rogeecn/renamer/internal/history"
)
// Apply performs planned insert operations and records them in the ledger.
func Apply(ctx context.Context, req *Request, planned []PlannedOperation, summary *Summary) (history.Entry, error) {
entry := history.Entry{Command: "insert"}
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: op.OriginalRelative,
To: op.ProposedRelative,
})
}
if len(done) == 0 {
return entry, nil
}
entry.Operations = done
if summary != nil {
meta := make(map[string]any, len(summary.LedgerMetadata))
for k, v := range summary.LedgerMetadata {
meta[k] = v
}
meta["totalCandidates"] = summary.TotalCandidates
meta["totalChanged"] = summary.TotalChanged
meta["noChange"] = summary.NoChange
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,32 @@
package insert
import (
"fmt"
)
// ConflictDetector tracks proposed targets to detect duplicates.
type ConflictDetector struct {
planned map[string]string // proposedRelative -> originalRelative
}
// NewConflictDetector creates an empty detector.
func NewConflictDetector() *ConflictDetector {
return &ConflictDetector{planned: make(map[string]string)}
}
// Register validates the proposed target and returns an error string if conflict occurred.
func (d *ConflictDetector) Register(original, proposed string) (string, bool) {
if proposed == "" {
return "", false
}
if existing, ok := d.planned[proposed]; ok && existing != original {
return fmt.Sprintf("duplicate target with %s", existing), false
}
d.planned[proposed] = original
return "", true
}
// Forget removes a planned target (used if operation skipped).
func (d *ConflictDetector) Forget(proposed string) {
delete(d.planned, proposed)
}

3
internal/insert/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package insert contains the engine, preview helpers, and validation logic for
// inserting text into file and directory names using positional directives.
package insert

187
internal/insert/engine.go Normal file
View File

@@ -0,0 +1,187 @@
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
}

46
internal/insert/parser.go Normal file
View File

@@ -0,0 +1,46 @@
package insert
import (
"errors"
"fmt"
"unicode/utf8"
)
// ParseInputs validates the position token and insert text before preview/apply.
func ParseInputs(positionToken, insertText string, stemLength int) error {
if positionToken == "" {
return errors.New("position token cannot be empty")
}
if insertText == "" {
return errors.New("insert text cannot be empty")
}
if !utf8.ValidString(insertText) {
return errors.New("insert text must be valid UTF-8")
}
for _, r := range insertText {
if r == '/' || r == '\\' {
return errors.New("insert text must not contain path separators")
}
if r < 0x20 {
return errors.New("insert text must not contain control characters")
}
}
if stemLength >= 0 {
if _, err := ResolvePosition(positionToken, stemLength); err != nil {
return err
}
}
return nil
}
// ResolvePositionWithValidation wraps ResolvePosition with explicit range checks.
func ResolvePositionWithValidation(positionToken string, stemLength int) (Position, error) {
pos, err := ResolvePosition(positionToken, stemLength)
if err != nil {
return Position{}, err
}
if pos.Index < 0 || pos.Index > stemLength {
return Position{}, fmt.Errorf("position %s out of range for %d-character stem", positionToken, stemLength)
}
return pos, nil
}

View File

@@ -0,0 +1,74 @@
package insert
import (
"errors"
"fmt"
"unicode/utf8"
)
// Position represents a resolved rune index relative to the filename stem.
type Position struct {
Index int // zero-based index where insertion should occur
}
// ResolvePosition interprets a position token (`^`, `$`, positive, negative) against the stem length.
func ResolvePosition(token string, stemLength int) (Position, error) {
switch token {
case "^":
return Position{Index: 0}, nil
case "$":
return Position{Index: stemLength}, nil
}
if token == "" {
return Position{}, errors.New("position token cannot be empty")
}
// Try parsing as integer (positive or negative).
idx, err := parseInt(token)
if err != nil {
return Position{}, fmt.Errorf("invalid position token %q: %w", token, err)
}
if idx > 0 {
if idx > stemLength {
return Position{}, fmt.Errorf("position %d out of range for %d-character stem", idx, stemLength)
}
return Position{Index: idx}, nil
}
// Negative index counts backward from end (e.g., -1 inserts before last rune).
offset := stemLength + idx
if offset < 0 || offset > stemLength {
return Position{}, fmt.Errorf("position %d out of range for %d-character stem", idx, stemLength)
}
return Position{Index: offset}, nil
}
// CountRunes returns the number of Unicode code points in name.
func CountRunes(name string) int {
return utf8.RuneCountInString(name)
}
// parseInt is declared separately for test stubbing.
var parseInt = func(token string) (int, error) {
var sign int = 1
switch token[0] {
case '+':
token = token[1:]
case '-':
sign = -1
token = token[1:]
}
if token == "" {
return 0, errors.New("missing digits")
}
var value int
for _, r := range token {
if r < '0' || r > '9' {
return 0, errors.New("non-numeric character in token")
}
value = value*10 + int(r-'0')
}
return sign * value, nil
}

View File

@@ -0,0 +1,77 @@
package insert
import (
"context"
"errors"
"fmt"
"io"
"sort"
)
// Preview computes planned insert operations, renders preview output, and returns the summary.
func Preview(ctx context.Context, req *Request, out io.Writer) (*Summary, []PlannedOperation, error) {
if req == nil {
return nil, nil, errors.New("insert request cannot be nil")
}
summary, operations, err := BuildPlan(ctx, req)
if err != nil {
return nil, nil, err
}
summary.LedgerMetadata["positionToken"] = req.PositionToken
summary.LedgerMetadata["insertText"] = req.InsertText
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...)
}
summary.LedgerMetadata["scope"] = scope
if out != nil {
conflictReasons := make(map[string]string, len(summary.Conflicts))
for _, conflict := range summary.Conflicts {
key := conflict.OriginalPath + "->" + conflict.ProposedPath
conflictReasons[key] = conflict.Reason
}
entries := append([]PreviewEntry(nil), summary.Entries...)
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].OriginalPath < entries[j].OriginalPath
})
for _, entry := range entries {
switch entry.Status {
case StatusChanged:
fmt.Fprintf(out, "%s -> %s\n", entry.OriginalPath, entry.ProposedPath)
case StatusNoChange:
fmt.Fprintf(out, "%s (no change)\n", entry.OriginalPath)
case StatusSkipped:
reason := conflictReasons[entry.OriginalPath+"->"+entry.ProposedPath]
if reason == "" {
reason = "skipped"
}
fmt.Fprintf(out, "%s -> %s (skipped: %s)\n", entry.OriginalPath, entry.ProposedPath, reason)
}
}
if summary.TotalCandidates > 0 {
fmt.Fprintf(out, "\nSummary: %d candidates, %d will change, %d already target position\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 {
fmt.Fprintf(out, "Warning: %s\n", warning)
}
}
}
return summary, operations, nil
}

View File

@@ -0,0 +1,86 @@
package insert
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/rogeecn/renamer/internal/listing"
)
// Request encapsulates the inputs required to run an insert operation.
type Request struct {
WorkingDir string
PositionToken string
InsertText string
IncludeDirs bool
Recursive bool
IncludeHidden bool
ExtensionFilter []string
DryRun bool
AutoConfirm bool
Timestamp time.Time
}
// NewRequest constructs a Request from shared listing scope.
func NewRequest(scope *listing.ListingRequest) *Request {
if scope == nil {
return &Request{}
}
extensions := append([]string(nil), scope.Extensions...)
return &Request{
WorkingDir: scope.WorkingDir,
IncludeDirs: scope.IncludeDirectories,
Recursive: scope.Recursive,
IncludeHidden: scope.IncludeHidden,
ExtensionFilter: extensions,
}
}
// SetExecutionMode updates dry-run and auto-apply preferences.
func (r *Request) SetExecutionMode(dryRun, autoConfirm bool) {
r.DryRun = dryRun
r.AutoConfirm = autoConfirm
}
// SetPositionAndText stores the user-supplied position token and insert text.
func (r *Request) SetPositionAndText(positionToken, insertText string) {
r.PositionToken = positionToken
r.InsertText = insertText
}
// Normalize ensures working directory and timestamp fields are ready for execution.
func (r *Request) 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,84 @@
package insert
// Status represents the preview outcome for a candidate entry.
type Status string
const (
StatusChanged Status = "changed"
StatusNoChange Status = "no_change"
StatusSkipped Status = "skipped"
)
// PreviewEntry describes a single original → proposed mapping.
type PreviewEntry struct {
OriginalPath string
ProposedPath string
Status Status
InsertedText string
}
// Conflict captures a conflicting rename outcome.
type Conflict struct {
OriginalPath string
ProposedPath string
Reason string
}
// Summary aggregates counts, warnings, conflicts, and ledger metadata for insert operations.
type Summary struct {
TotalCandidates int
TotalChanged int
NoChange int
Entries []PreviewEntry
Conflicts []Conflict
Warnings []string
LedgerMetadata map[string]any
}
// NewSummary constructs an empty summary with initialized maps.
func NewSummary() *Summary {
return &Summary{
Entries: make([]PreviewEntry, 0),
Conflicts: make([]Conflict, 0),
Warnings: make([]string, 0),
LedgerMetadata: make(map[string]any),
}
}
// RecordEntry appends a preview entry and updates aggregate counts.
func (s *Summary) RecordEntry(entry PreviewEntry) {
s.Entries = append(s.Entries, entry)
s.TotalCandidates++
switch entry.Status {
case StatusChanged:
s.TotalChanged++
case StatusNoChange:
s.NoChange++
}
}
// AddConflict records a blocking conflict.
func (s *Summary) AddConflict(conflict Conflict) {
s.Conflicts = append(s.Conflicts, conflict)
}
// AddWarning adds a warning if not already present.
func (s *Summary) AddWarning(msg string) {
if msg == "" {
return
}
for _, existing := range s.Warnings {
if existing == msg {
return
}
}
s.Warnings = append(s.Warnings, msg)
}
// HasConflicts indicates whether apply should be blocked.
func (s *Summary) HasConflicts() bool {
return len(s.Conflicts) > 0
}