aboutsummaryrefslogtreecommitdiff
path: root/internal/plan/chain.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 12:58:14 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 12:58:14 +0200
commit24a84671ace373ae331fa83a1ff484990f4dff0e (patch)
treea6b6e3949d7dd241f1d13e079dfb982d758c89a2 /internal/plan/chain.go
parent3b36a48b7ce5a53a9366f3b31f94311f178e2553 (diff)
downloadkrino-24a84671ace373ae331fa83a1ff484990f4dff0e.tar.gz
krino-24a84671ace373ae331fa83a1ff484990f4dff0e.zip
krino: planning — chains, placeholders, conflicts, JSON
Diffstat (limited to 'internal/plan/chain.go')
-rw-r--r--internal/plan/chain.go222
1 files changed, 222 insertions, 0 deletions
diff --git a/internal/plan/chain.go b/internal/plan/chain.go
new file mode 100644
index 0000000..bfa2484
--- /dev/null
+++ b/internal/plan/chain.go
@@ -0,0 +1,222 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package plan
+
+import (
+ "fmt"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "krino/internal/config"
+ "krino/internal/scan"
+ "krino/internal/xdg"
+)
+
+// Claims is the set of destination paths already spoken for, shared across
+// every directory planned in one run (A3): two directories competing for
+// one destination must resolve the collision at planning time, which needs
+// one Claims threaded through every Build call of that run, not a fresh one
+// per call.
+type Claims struct {
+ taken claimed
+}
+
+// NewClaims returns an empty Claims, ready to pass to Build.
+func NewClaims() *Claims {
+ return &Claims{taken: claimed{}}
+}
+
+// Input is one file and the rules that matched it, in match order.
+type Input struct {
+ File scan.File
+ Rules []RuleMatch
+}
+
+// Build turns each file's matching rules into a chain. root is the
+// directory's absolute root; now is the start of the run; d is consulted to
+// resolve destination conflicts (§7.4). claims is the run-scoped claim set
+// (A3): pass the same *Claims to every Build call of one run (every
+// directory included) so two directories claiming one destination resolve
+// the collision instead of both silently landing on it. claims must not be
+// nil - a caller with nothing to share yet still calls NewClaims() itself,
+// so an accidentally-unshared claim set can never happen by simply
+// forgetting the argument. Files whose chain has no steps are returned with
+// an empty Steps slice, so the caller can tell "matched a rule that does
+// nothing" from "not matched".
+//
+// Conflicts are resolved as each step is created, and a claimed destination
+// is shared across every file's chain, not just its own: two files
+// competing for one name must resolve the same way on every run, so
+// resolution proceeds in File.Rel order regardless of the order in which
+// in is given. The returned slice still lines up with in, position for
+// position.
+func Build(root string, in []Input, now time.Time, d Disk, claims *Claims) []Chain {
+ if claims == nil {
+ panic("plan: Build requires a non-nil Claims (see NewClaims)")
+ }
+ order := make([]int, len(in))
+ for i := range order {
+ order[i] = i
+ }
+ sort.SliceStable(order, func(i, j int) bool {
+ return in[order[i]].File.Rel < in[order[j]].File.Rel
+ })
+
+ chains := make([]Chain, len(in))
+ for _, i := range order {
+ chains[i] = buildOne(root, in[i], now, d, claims.taken)
+ }
+ return chains
+}
+
+// buildOne builds the chain for a single file. claim is shared with every
+// other file processed by the same Build call.
+func buildOne(root string, in Input, now time.Time, d Disk, claim claimed) Chain {
+ c := Chain{File: in.File}
+ cur := in.File.Path
+
+ var deletedBy string
+ moves := 0
+ warnedMove := false
+
+ for _, rule := range in.Rules {
+ reason := strings.Join(rule.Reasons, ", ")
+ for _, a := range rule.Actions {
+ kind := stepKind(a.Kind)
+
+ if deletedBy != "" {
+ c.Steps = append(c.Steps, Step{
+ Kind: kind,
+ Rule: rule.Name,
+ Src: cur,
+ Reason: reason,
+ Skip: "deleted by rule " + deletedBy,
+ Conflict: rule.Settings.OnConflict,
+ })
+ continue
+ }
+
+ facts := Facts{
+ Name: filepath.Base(cur),
+ Captures: rule.Captures,
+ ModTime: in.File.ModTime,
+ Now: now,
+ }
+
+ step := Step{Kind: kind, Rule: rule.Name, Src: cur, Reason: reason, Conflict: rule.Settings.OnConflict}
+
+ switch a.Kind {
+ case config.Copy, config.Move:
+ // D9: a placeholder failure (below) leaves step.Dst empty -
+ // there was never a destination to compute at all - while a
+ // conflict-policy skip (via resolveConflict, right after)
+ // always sets step.Dst: even when the step will not run,
+ // its would-be destination is a real, already-resolved
+ // path worth showing.
+ dest, err := expandDir(a.Arg, facts, root)
+ if err != nil {
+ step.Skip = err.Error()
+ break
+ }
+ dst := filepath.Join(dest, filepath.Base(cur))
+ resolved, skip, displaces := resolveConflict(kind, rule.Settings.OnConflict, cur, dst, d, claim)
+ step.Dst = resolved
+ step.Skip = skip
+ step.Displaces = displaces
+ if skip == "" {
+ // C4: the path cur is about to be vacated from (on a
+ // move) enters neither claim nor any "freed" set, so
+ // Disk.Exists still reports it occupied for the rest of
+ // this plan and a later file wanting that exact name
+ // gets a gratuitous _1. This errs safe - it never lets
+ // a name be claimed before its file has actually
+ // vacated it - and stays; do not "fix" it by weakening
+ // the disk check.
+ claim[resolved] = true
+ if a.Kind == config.Move {
+ cur = resolved
+ moves++
+ if moves > 1 && !warnedMove {
+ c.Warnings = append(c.Warnings, "moved more than once; a (stop) is probably missing")
+ warnedMove = true
+ }
+ }
+ }
+
+ case config.Rename:
+ name, err := Expand(a.Arg, facts)
+ if err != nil {
+ step.Skip = err.Error()
+ break
+ }
+ if strings.ContainsRune(name, '/') {
+ step.Skip = `rename produced a name containing "/"`
+ break
+ }
+ dst := filepath.Join(filepath.Dir(cur), name)
+ resolved, skip, displaces := resolveConflict(kind, rule.Settings.OnConflict, cur, dst, d, claim)
+ step.Dst = resolved
+ step.Skip = skip
+ step.Displaces = displaces
+ if skip == "" {
+ cur = resolved
+ claim[resolved] = true
+ }
+
+ case config.Delete, config.DeletePermanent:
+ deletedBy = rule.Name
+ }
+
+ c.Steps = append(c.Steps, step)
+ }
+ }
+
+ return c
+}
+
+// stepKind maps a config.ActionKind onto its plan.Kind, exhaustively
+// (D4): an unrecognised ActionKind panics rather than silently reading as
+// config.Delete, matching config.ActionKind.String()'s own exhaustive style
+// with an explicit fallback.
+func stepKind(k config.ActionKind) Kind {
+ switch k {
+ case config.Copy:
+ return Copy
+ case config.Move:
+ return Move
+ case config.Rename:
+ return Rename
+ case config.DeletePermanent:
+ return DeletePermanent
+ case config.Delete:
+ return Trash
+ }
+ panic(fmt.Sprintf("plan: unknown config.ActionKind %d", int(k)))
+}
+
+// expandDir expands raw (a DEST argument) against facts, then resolves it
+// the same way the engine resolves an extra directory: ~ expands, a
+// relative path joins root, and the result is cleaned.
+func expandDir(raw string, facts Facts, root string) (string, error) {
+ expanded, err := Expand(raw, facts)
+ if err != nil {
+ return "", err
+ }
+ return ResolveDir(expanded, root), nil
+}
+
+// ResolveDir expands a leading ~ and joins a relative directory to root,
+// cleaned. C1: this is the one place that decides where a rule's
+// destination resolves to; internal/engine calls it too (its own directory
+// walk needs to agree on the same paths), rather than keeping a second,
+// separately-maintained copy - plan is the lower layer (engine imports
+// plan, so plan must never import engine), so the decision belongs here.
+func ResolveDir(raw, root string) string {
+ p := xdg.Expand(raw)
+ if !filepath.IsAbs(p) {
+ p = filepath.Join(root, p)
+ }
+ return filepath.Clean(p)
+}