Add insert command
This commit is contained in:
85
internal/insert/apply.go
Normal file
85
internal/insert/apply.go
Normal 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
|
||||
}
|
||||
32
internal/insert/conflicts.go
Normal file
32
internal/insert/conflicts.go
Normal 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
3
internal/insert/doc.go
Normal 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
187
internal/insert/engine.go
Normal 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
46
internal/insert/parser.go
Normal 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
|
||||
}
|
||||
74
internal/insert/positions.go
Normal file
74
internal/insert/positions.go
Normal 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
|
||||
}
|
||||
77
internal/insert/preview.go
Normal file
77
internal/insert/preview.go
Normal 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
|
||||
}
|
||||
86
internal/insert/request.go
Normal file
86
internal/insert/request.go
Normal 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
|
||||
}
|
||||
84
internal/insert/summary.go
Normal file
84
internal/insert/summary.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user