summaryrefslogtreecommitdiff
path: root/internal/plan
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
parent3b36a48b7ce5a53a9366f3b31f94311f178e2553 (diff)
downloadkrino-24a84671ace373ae331fa83a1ff484990f4dff0e.tar.gz
krino-24a84671ace373ae331fa83a1ff484990f4dff0e.zip
krino: planning — chains, placeholders, conflicts, JSON
Diffstat (limited to 'internal/plan')
-rw-r--r--internal/plan/chain.go222
-rw-r--r--internal/plan/chain_test.go143
-rw-r--r--internal/plan/conflict.go134
-rw-r--r--internal/plan/conflict_test.go224
-rw-r--r--internal/plan/index.go44
-rw-r--r--internal/plan/index_test.go28
-rw-r--r--internal/plan/json.go97
-rw-r--r--internal/plan/json_test.go87
-rw-r--r--internal/plan/placeholder.go155
-rw-r--r--internal/plan/placeholder_test.go94
-rw-r--r--internal/plan/step.go69
11 files changed, 1297 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)
+}
diff --git a/internal/plan/chain_test.go b/internal/plan/chain_test.go
new file mode 100644
index 0000000..b6e3dfe
--- /dev/null
+++ b/internal/plan/chain_test.go
@@ -0,0 +1,143 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package plan
+
+import (
+ "path/filepath"
+ "testing"
+ "time"
+
+ "krino/internal/config"
+ "krino/internal/scan"
+)
+
+func file(root, rel string) scan.File {
+ return scan.File{
+ Path: filepath.Join(root, rel),
+ Rel: rel,
+ Name: filepath.Base(rel),
+ Size: 10,
+ ModTime: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC),
+ }
+}
+
+func act(k config.ActionKind, arg string) config.Action { return config.Action{Kind: k, Arg: arg} }
+
+func TestBuildChain(t *testing.T) {
+ root := "/r"
+ now := time.Date(2026, 9, 12, 0, 0, 0, 0, time.UTC)
+ in := []Input{{
+ File: file(root, "inv1.pdf"),
+ Rules: []RuleMatch{
+ {Name: "backup", Actions: []config.Action{act(config.Copy, "/backup/{mtime:%Y}")}},
+ {Name: "acme", Actions: []config.Action{
+ act(config.Rename, "{mtime:%Y-%m-%d}_{name}"),
+ act(config.Move, "Work/Acme/{mtime:%Y}"),
+ }},
+ },
+ }}
+ got := Build(root, in, now, NoDisk{}, NewClaims())
+ if len(got) != 1 || len(got[0].Steps) != 3 {
+ t.Fatalf("got %d chains / %d steps", len(got), len(got[0].Steps))
+ }
+ want := []Step{
+ {Kind: Copy, Rule: "backup", Src: "/r/inv1.pdf", Dst: "/backup/2026/inv1.pdf"},
+ {Kind: Rename, Rule: "acme", Src: "/r/inv1.pdf", Dst: "/r/2026-08-15_inv1.pdf"},
+ {Kind: Move, Rule: "acme", Src: "/r/2026-08-15_inv1.pdf", Dst: "/r/Work/Acme/2026/2026-08-15_inv1.pdf"},
+ }
+ for i, w := range want {
+ g := got[0].Steps[i]
+ if g.Kind != w.Kind || g.Rule != w.Rule || g.Src != w.Src || g.Dst != w.Dst || g.Skip != "" {
+ t.Errorf("step %d = %+v; want %+v", i, g, w)
+ }
+ }
+ if len(got[0].Warnings) != 0 {
+ t.Errorf("unexpected warnings: %v", got[0].Warnings)
+ }
+}
+
+func TestBuildDeleteEndsChain(t *testing.T) {
+ in := []Input{{
+ File: file("/r", "old.iso"),
+ Rules: []RuleMatch{
+ {Name: "dups", Actions: []config.Action{act(config.Delete, "")}},
+ {Name: "archive", Actions: []config.Action{act(config.Move, "Archive")}},
+ },
+ }}
+ steps := Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0].Steps
+ if len(steps) != 2 || steps[0].Kind != Trash || steps[0].Dst != "" {
+ t.Fatalf("steps = %+v", steps)
+ }
+ if steps[1].Skip != "deleted by rule dups" {
+ t.Errorf("step after delete: Skip = %q", steps[1].Skip)
+ }
+}
+
+func TestBuildWarnsOnTwoMoves(t *testing.T) {
+ in := []Input{{
+ File: file("/r", "x.pdf"),
+ Rules: []RuleMatch{
+ {Name: "a", Actions: []config.Action{act(config.Move, "A")}},
+ {Name: "b", Actions: []config.Action{act(config.Move, "B")}},
+ },
+ }}
+ c := Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0]
+ if len(c.Warnings) != 1 || c.Warnings[0] != "moved more than once; a (stop) is probably missing" {
+ t.Errorf("warnings = %v", c.Warnings)
+ }
+ if c.Steps[1].Src != "/r/A/x.pdf" {
+ t.Errorf("second move reads from %q, want the path after the first move", c.Steps[1].Src)
+ }
+}
+
+func TestBuildBadPlaceholderSkipsOneStep(t *testing.T) {
+ in := []Input{{
+ File: file("/r", "x.pdf"),
+ Rules: []RuleMatch{{Name: "a", Actions: []config.Action{
+ act(config.Move, "Work/{1}"),
+ act(config.Rename, "ok-{name}"),
+ }}},
+ }}
+ c := Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0]
+ if c.Steps[0].Skip == "" {
+ t.Errorf("step with {1} and no captures should be skipped: %+v", c.Steps[0])
+ }
+ if c.Steps[1].Skip != "" || c.Steps[1].Dst != "/r/ok-x.pdf" {
+ t.Errorf("chain should continue from the unchanged path: %+v", c.Steps[1])
+ }
+}
+
+func TestBuildRenameWithSlash(t *testing.T) {
+ in := []Input{{
+ File: file("/r", "x.pdf"),
+ Rules: []RuleMatch{{Name: "a", Actions: []config.Action{act(config.Rename, "sub/{name}")}}},
+ }}
+ c := Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0]
+ if c.Steps[0].Skip != `rename produced a name containing "/"` {
+ t.Errorf("Skip = %q", c.Steps[0].Skip)
+ }
+}
+
+// TestBuildKeepsSteplessChains is D6: Build returns one Chain per Input even
+// when a file's rules contribute no actions, so a caller can tell "matched a
+// rule that does nothing" (an exclusion) from "not matched at all". Plan 3's
+// "to act on" count and the JSON document's empty steps array both rest on
+// this.
+func TestBuildKeepsSteplessChains(t *testing.T) {
+ in := []Input{
+ {File: file("/r", "excluded.txt"), Rules: []RuleMatch{{Name: "only-stop"}}},
+ {File: file("/r", "untouched.txt")},
+ }
+ chains := Build("/r", in, time.Now(), NoDisk{}, NewClaims())
+ if len(chains) != 2 {
+ t.Fatalf("got %d chains, want one per Input", len(chains))
+ }
+ for i, c := range chains {
+ if len(c.Steps) != 0 {
+ t.Errorf("chain %d: got %d steps, want none", i, len(c.Steps))
+ }
+ if c.File.Rel != in[i].File.Rel {
+ t.Errorf("chain %d: File.Rel = %q, want %q (positional alignment)", i, c.File.Rel, in[i].File.Rel)
+ }
+ }
+}
diff --git a/internal/plan/conflict.go b/internal/plan/conflict.go
new file mode 100644
index 0000000..8747669
--- /dev/null
+++ b/internal/plan/conflict.go
@@ -0,0 +1,134 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package plan
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "krino/internal/config"
+ "krino/internal/dup"
+)
+
+// Disk is what Build needs from the filesystem to resolve conflicts, so
+// tests can supply a stub and Build stays pure otherwise.
+type Disk interface {
+ Exists(path string) bool
+ SameContent(a, b string) (bool, error)
+}
+
+// OS is the real filesystem.
+type OS struct{}
+
+// Exists reports whether path names an existing file or directory, without
+// following a symlink at path itself: a dangling or otherwise unwanted
+// symlink still counts as "something is there".
+func (OS) Exists(path string) bool {
+ _, err := os.Lstat(path)
+ return err == nil
+}
+
+// SameContent delegates to internal/dup, the one place content identity is
+// decided.
+func (OS) SameContent(a, b string) (bool, error) {
+ return dup.SameContent(a, b)
+}
+
+// NoDisk is the empty filesystem: nothing exists, and nothing is ever the
+// same content. For tests that are not about conflicts.
+type NoDisk struct{}
+
+func (NoDisk) Exists(string) bool { return false }
+func (NoDisk) SameContent(string, string) (bool, error) { return false, nil }
+
+// claimed is the set of destination paths already spoken for by an earlier
+// step of this plan.
+type claimed map[string]bool
+
+// resolveConflict decides what a step whose destination is contested does,
+// per spec §7.4. src is the step's source (its current path, before this
+// step runs); dst is the target the action computed. It returns the
+// resolved destination (possibly unchanged), a Skip reason (non-empty when
+// the step must not run) and Displaces (non-empty only for overwrite of a
+// file that exists on disk).
+func resolveConflict(kind Kind, policy config.Conflict, src, dst string, d Disk, c claimed) (resolved, skip, displaces string) {
+ // A1/A2: the file is already where this step would put it, so its own
+ // existence must not read as a conflict with itself. Without this guard a
+ // move or rename plans a rename to stem_1 and every later run adds
+ // another generation; under (on-conflict overwrite) the step records the
+ // file as its own Displaces, which plan 4 would trash before moving from
+ // a path that no longer exists. Checked before the policy switch, so
+ // overwrite never reaches its own branch.
+ if dst == src {
+ return dst, "already there", ""
+ }
+
+ onDisk := d.Exists(dst)
+
+ if kind == Copy && onDisk {
+ // A SameContent error (the source vanished, a permission problem,
+ // ...) is treated the same as "different content": the step falls
+ // through to the ordinary conflict policy below instead of failing
+ // outright. A wrong "different" verdict costs at worst an
+ // unnecessary suffixed copy, never data loss, so resolving the
+ // conflict anyway is an acceptable trade-off here (D8) - a caller
+ // that wants the failure itself visible would need it surfaced as
+ // a chain warning instead.
+ if same, err := d.SameContent(src, dst); err == nil && same {
+ return dst, "already there", ""
+ }
+ }
+
+ if !onDisk && !c[dst] {
+ return dst, "", ""
+ }
+
+ switch policy {
+ case config.ConflictSkip:
+ return dst, "target exists", ""
+ case config.ConflictOverwrite:
+ if onDisk && !c[dst] {
+ // The existing file is trashed first (plan 4). Only the first
+ // step to reach this path may displace it: once another step
+ // in this same plan has already claimed dst, that path will
+ // hold that step's own output by the time this one runs, so
+ // displacing it again would destroy it.
+ return dst, "", dst
+ }
+ // Either claimed in-plan only (nothing on disk to displace — an
+ // in-plan claim is never displaced), or on disk but already
+ // claimed by an earlier step of this plan (displacing it again
+ // would destroy that step's output): either way the two chains
+ // cannot share one destination, so fall back to a free name,
+ // exactly as suffix would. A step that takes a free name
+ // displaces nothing.
+ resolved, skip := suffixed(dst, d, c)
+ return resolved, skip, ""
+ default: // config.ConflictSuffix
+ resolved, skip := suffixed(dst, d, c)
+ return resolved, skip, ""
+ }
+}
+
+// maxSuffixAttempts bounds suffixed(): it is unbounded by design and
+// terminates on a real filesystem, but C2 - without a cap, a Disk that
+// always reports existence (or a directory A1 had been filling before its
+// fix) turns planning quadratic instead of failing fast.
+const maxSuffixAttempts = 10000
+
+// suffixed finds the first stem_N.ext (N starting at 1) that is free:
+// neither on disk nor already claimed by an earlier step in this plan. It
+// gives up after maxSuffixAttempts, returning a Skip reason and no path
+// (C2).
+func suffixed(dst string, d Disk, c claimed) (resolved, skip string) {
+ dir, base := filepath.Split(dst)
+ stem, ext := splitExt(base)
+ for n := 1; n <= maxSuffixAttempts; n++ {
+ candidate := filepath.Join(dir, fmt.Sprintf("%s_%d%s", stem, n, ext))
+ if !d.Exists(candidate) && !c[candidate] {
+ return candidate, ""
+ }
+ }
+ return "", "too many conflicting names"
+}
diff --git a/internal/plan/conflict_test.go b/internal/plan/conflict_test.go
new file mode 100644
index 0000000..bdc37af
--- /dev/null
+++ b/internal/plan/conflict_test.go
@@ -0,0 +1,224 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package plan
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "krino/internal/config"
+)
+
+// fakeDisk reports the paths it was given as existing, and equality of
+// content by exact string match on a separate map.
+type fakeDisk struct {
+ exists map[string]bool
+ same map[[2]string]bool
+}
+
+func (f fakeDisk) Exists(p string) bool { return f.exists[p] }
+func (f fakeDisk) SameContent(a, b string) (bool, error) {
+ return f.same[[2]string{a, b}], nil
+}
+
+// TestConflictAlreadyThereMove is A1: a move whose destination resolves to
+// the directory the file is already sitting in must be a no-op, not a
+// rename - probe: "(rule "byyear" (when (type txt)) (move "{mtime:%Y}"))"
+// over "dl/2026/a.txt" planned "a.txt -> a_1.txt" on run 1 and, on run 2,
+// both "a.txt -> a_2.txt" and "a_1.txt -> a_1_1.txt": every run renamed the
+// whole destination tree and added a generation, because the file's own
+// existence at dst read as a conflict with itself.
+func TestConflictAlreadyThereMove(t *testing.T) {
+ d := fakeDisk{exists: map[string]bool{"/r/2026/a.txt": true}}
+ in := []Input{{File: file("/r", "2026/a.txt"), Rules: []RuleMatch{
+ {Name: "byyear", Actions: []config.Action{act(config.Move, "2026")}}}}}
+ s := Build("/r", in, time.Now(), d, NewClaims())[0].Steps[0]
+ if s.Dst != "/r/2026/a.txt" || s.Skip != "already there" || s.Displaces != "" {
+ t.Errorf("step = %+v; want a no-op at the file's own path", s)
+ }
+}
+
+// TestConflictAlreadyThereRename is A1's rename counterpart:
+// (rename "{name}") over a file already named that must also be a no-op.
+func TestConflictAlreadyThereRename(t *testing.T) {
+ d := fakeDisk{exists: map[string]bool{"/r/2026/a.txt": true}}
+ in := []Input{{File: file("/r", "2026/a.txt"), Rules: []RuleMatch{
+ {Name: "byyear", Actions: []config.Action{act(config.Rename, "{name}")}}}}}
+ s := Build("/r", in, time.Now(), d, NewClaims())[0].Steps[0]
+ if s.Dst != "/r/2026/a.txt" || s.Skip != "already there" || s.Displaces != "" {
+ t.Errorf("step = %+v; want a no-op at the file's own path", s)
+ }
+}
+
+// TestConflictAlreadyThereOverwriteNoDisplace is A2: the same already-there
+// case under (on-conflict overwrite) must not record the file as its own
+// Displaces - spec §7.4's overwrite moves the existing target to Trash
+// first, then proceeds; applied literally here that trashes the user's file
+// and then moves from a path that no longer exists, so the file survives
+// only in Trash. A1's guard (dst == src, checked before the policy switch)
+// fixes this too, since it runs before overwrite's own branch is ever
+// reached.
+func TestConflictAlreadyThereOverwriteNoDisplace(t *testing.T) {
+ d := fakeDisk{exists: map[string]bool{"/r/2026/a.txt": true}}
+ over := config.ConflictOverwrite
+ in := []Input{{File: file("/r", "2026/a.txt"), Rules: []RuleMatch{
+ {Name: "byyear", Settings: config.Resolved{OnConflict: over}, Actions: []config.Action{act(config.Move, "2026")}}}}}
+ s := Build("/r", in, time.Now(), d, NewClaims())[0].Steps[0]
+ if s.Skip != "already there" || s.Displaces != "" {
+ t.Errorf("step = %+v; want Skip \"already there\" and empty Displaces", s)
+ }
+}
+
+func TestConflictSuffix(t *testing.T) {
+ d := fakeDisk{exists: map[string]bool{"/r/Work/x.pdf": true, "/r/Work/x_1.pdf": true}}
+ in := []Input{{File: file("/r", "x.pdf"), Rules: []RuleMatch{
+ {Name: "a", Actions: []config.Action{act(config.Move, "Work")}}}}}
+ s := Build("/r", in, time.Now(), d, NewClaims())[0].Steps[0]
+ if s.Dst != "/r/Work/x_2.pdf" || s.Skip != "" {
+ t.Errorf("step = %+v; want Dst /r/Work/x_2.pdf", s)
+ }
+}
+
+func TestConflictSkipKeepsCurrentPath(t *testing.T) {
+ d := fakeDisk{exists: map[string]bool{"/r/Work/x.pdf": true}}
+ skip := config.ConflictSkip
+ in := []Input{{File: file("/r", "x.pdf"), Rules: []RuleMatch{
+ {Name: "a", Settings: config.Resolved{OnConflict: skip}, Actions: []config.Action{
+ act(config.Move, "Work"),
+ act(config.Rename, "later-{name}"),
+ }}}}}
+ steps := Build("/r", in, time.Now(), d, NewClaims())[0].Steps
+ if steps[0].Skip != "target exists" {
+ t.Errorf("skip step = %+v", steps[0])
+ }
+ if steps[1].Src != "/r/x.pdf" || steps[1].Dst != "/r/later-x.pdf" {
+ t.Errorf("chain must continue from the unchanged path: %+v", steps[1])
+ }
+}
+
+func TestConflictOverwriteRecordsDisplaced(t *testing.T) {
+ d := fakeDisk{exists: map[string]bool{"/r/Work/x.pdf": true}}
+ over := config.ConflictOverwrite
+ in := []Input{{File: file("/r", "x.pdf"), Rules: []RuleMatch{
+ {Name: "a", Settings: config.Resolved{OnConflict: over}, Actions: []config.Action{act(config.Move, "Work")}}}}}
+ s := Build("/r", in, time.Now(), d, NewClaims())[0].Steps[0]
+ if s.Dst != "/r/Work/x.pdf" || s.Displaces != "/r/Work/x.pdf" {
+ t.Errorf("step = %+v", s)
+ }
+}
+
+// TestConflictOverwriteTwoFilesOnDisk: two files collide on one path that
+// already exists on disk, both under overwrite. Only the first may displace
+// the pre-existing file; the second must take a free name and displace
+// nothing, or applying both later would trash the first file's own output.
+func TestConflictOverwriteTwoFilesOnDisk(t *testing.T) {
+ d := fakeDisk{exists: map[string]bool{"/r/Work/x.pdf": true}}
+ over := config.ConflictOverwrite
+ in := []Input{
+ {File: file("/r", "a/x.pdf"), Rules: []RuleMatch{
+ {Name: "r", Settings: config.Resolved{OnConflict: over}, Actions: []config.Action{act(config.Move, "Work")}}}},
+ {File: file("/r", "b/x.pdf"), Rules: []RuleMatch{
+ {Name: "r", Settings: config.Resolved{OnConflict: over}, Actions: []config.Action{act(config.Move, "Work")}}}},
+ }
+ chains := Build("/r", in, time.Now(), d, NewClaims())
+ first, second := chains[0].Steps[0], chains[1].Steps[0]
+ if first.Dst != "/r/Work/x.pdf" || first.Displaces != "/r/Work/x.pdf" {
+ t.Errorf("first step = %+v; want Dst and Displaces /r/Work/x.pdf", first)
+ }
+ if second.Dst != "/r/Work/x_1.pdf" || second.Displaces != "" {
+ t.Errorf("second step = %+v; want Dst /r/Work/x_1.pdf and empty Displaces", second)
+ }
+}
+
+// TestConflictOverwriteTwoFilesClaimedOnly: same collision, but nothing is
+// on disk — the two files claim the same name only in-plan. Neither may
+// displace (an in-plan claim is never displaced); the second must fall back
+// to a free name with no Displaces recorded.
+func TestConflictOverwriteTwoFilesClaimedOnly(t *testing.T) {
+ over := config.ConflictOverwrite
+ in := []Input{
+ {File: file("/r", "a/x.pdf"), Rules: []RuleMatch{
+ {Name: "r", Settings: config.Resolved{OnConflict: over}, Actions: []config.Action{act(config.Move, "Work")}}}},
+ {File: file("/r", "b/x.pdf"), Rules: []RuleMatch{
+ {Name: "r", Settings: config.Resolved{OnConflict: over}, Actions: []config.Action{act(config.Move, "Work")}}}},
+ }
+ chains := Build("/r", in, time.Now(), NoDisk{}, NewClaims())
+ first, second := chains[0].Steps[0], chains[1].Steps[0]
+ if first.Dst != "/r/Work/x.pdf" || first.Displaces != "" {
+ t.Errorf("first step = %+v; want Dst /r/Work/x.pdf and empty Displaces", first)
+ }
+ if second.Dst != "/r/Work/x_1.pdf" || second.Displaces != "" {
+ t.Errorf("second step = %+v; want Dst /r/Work/x_1.pdf and empty Displaces", second)
+ }
+}
+
+func TestCopyAlreadyThere(t *testing.T) {
+ d := fakeDisk{
+ exists: map[string]bool{"/backup/x.pdf": true},
+ same: map[[2]string]bool{{"/r/x.pdf", "/backup/x.pdf"}: true},
+ }
+ in := []Input{{File: file("/r", "x.pdf"), Rules: []RuleMatch{
+ {Name: "b", Actions: []config.Action{act(config.Copy, "/backup")}}}}}
+ s := Build("/r", in, time.Now(), d, NewClaims())[0].Steps[0]
+ if s.Skip != "already there" {
+ t.Errorf("step = %+v; want Skip \"already there\"", s)
+ }
+}
+
+func TestTwoFilesOneTarget(t *testing.T) {
+ in := []Input{
+ {File: file("/r", "a/x.pdf"), Rules: []RuleMatch{
+ {Name: "r", Actions: []config.Action{act(config.Move, "Work")}}}},
+ {File: file("/r", "b/x.pdf"), Rules: []RuleMatch{
+ {Name: "r", Actions: []config.Action{act(config.Move, "Work")}}}},
+ }
+ chains := Build("/r", in, time.Now(), NoDisk{}, NewClaims())
+ if chains[0].Steps[0].Dst != "/r/Work/x.pdf" || chains[1].Steps[0].Dst != "/r/Work/x_1.pdf" {
+ t.Errorf("in-plan collision: %q and %q", chains[0].Steps[0].Dst, chains[1].Steps[0].Dst)
+ }
+}
+
+func TestSameContentReal(t *testing.T) {
+ dir := t.TempDir()
+ a := filepath.Join(dir, "a")
+ b := filepath.Join(dir, "b")
+ c := filepath.Join(dir, "c")
+ if err := os.WriteFile(a, []byte("same bytes"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(b, []byte("same bytes"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(c, []byte("other bytes"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if ok, err := (OS{}).SameContent(a, b); err != nil || !ok {
+ t.Errorf("identical files: %v %v", ok, err)
+ }
+ if ok, _ := (OS{}).SameContent(a, c); ok {
+ t.Error("different files reported identical")
+ }
+}
+
+// stubAlwaysExists is C2's stub Disk: every path reports as existing (and
+// nothing is ever the same content), so suffixed() can never find a free
+// name and must give up instead of spinning forever.
+type stubAlwaysExists struct{}
+
+func (stubAlwaysExists) Exists(string) bool { return true }
+func (stubAlwaysExists) SameContent(string, string) (bool, error) { return false, nil }
+
+// TestSuffixedCapsAttempts is C2: suffixed() gives up after
+// maxSuffixAttempts, reporting Skip "too many conflicting names" and no
+// Dst, rather than looping forever against a Disk that never reports a free
+// name.
+func TestSuffixedCapsAttempts(t *testing.T) {
+ in := []Input{{File: file("/r", "x.pdf"), Rules: []RuleMatch{
+ {Name: "a", Actions: []config.Action{act(config.Move, "Work")}}}}}
+ s := Build("/r", in, time.Now(), stubAlwaysExists{}, NewClaims())[0].Steps[0]
+ if s.Skip != "too many conflicting names" || s.Dst != "" {
+ t.Errorf("step = %+v; want Skip \"too many conflicting names\" and empty Dst", s)
+ }
+}
diff --git a/internal/plan/index.go b/internal/plan/index.go
new file mode 100644
index 0000000..abe073f
--- /dev/null
+++ b/internal/plan/index.go
@@ -0,0 +1,44 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package plan
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// MaxIndex returns the highest {N} used in s, 0 when none. It reports the
+// same errors Expand does for a malformed placeholder: an unclosed
+// placeholder, or {0} (capture groups are numbered from 1). It shares
+// Expand's scanner shape: "{{" and "}}" each emit one literal brace, and any
+// other "{" opens a placeholder that runs to the next "}".
+func MaxIndex(s string) (int, error) {
+ max := 0
+ for i := 0; i < len(s); {
+ switch {
+ case s[i] == '{' && i+1 < len(s) && s[i+1] == '{':
+ i += 2
+ case s[i] == '}' && i+1 < len(s) && s[i+1] == '}':
+ i += 2
+ case s[i] == '{':
+ end := strings.IndexByte(s[i+1:], '}')
+ if end < 0 {
+ return 0, fmt.Errorf("unclosed placeholder")
+ }
+ body := s[i+1 : i+1+end]
+ if n, err := strconv.Atoi(body); err == nil {
+ if n == 0 {
+ return 0, fmt.Errorf("capture groups are numbered from 1")
+ }
+ if n >= 1 && n <= 9 && n > max {
+ max = n
+ }
+ }
+ i += end + 2
+ default:
+ i++
+ }
+ }
+ return max, nil
+}
diff --git a/internal/plan/index_test.go b/internal/plan/index_test.go
new file mode 100644
index 0000000..b157f56
--- /dev/null
+++ b/internal/plan/index_test.go
@@ -0,0 +1,28 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package plan
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestMaxIndex(t *testing.T) {
+ for in, want := range map[string]int{
+ "Work/Acme": 0,
+ "{name}": 0,
+ "{1}": 1,
+ "Work/{2}/{1}": 2,
+ "{stem}-{9}{ext}": 9,
+ "{{1}}": 0,
+ "{mtime:%Y}/{3}-{1}": 3,
+ } {
+ got, err := MaxIndex(in)
+ if err != nil || got != want {
+ t.Errorf("MaxIndex(%q) = %d, %v; want %d", in, got, err, want)
+ }
+ }
+ if _, err := MaxIndex("{name"); err == nil || !strings.Contains(err.Error(), "unclosed placeholder") {
+ t.Errorf("unclosed: %v", err)
+ }
+}
diff --git a/internal/plan/json.go b/internal/plan/json.go
new file mode 100644
index 0000000..0b4f1bc
--- /dev/null
+++ b/internal/plan/json.go
@@ -0,0 +1,97 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package plan
+
+import "time"
+
+// jsonNote is carried in every JSON document, warning readers that the
+// shape is not yet stable.
+const jsonNote = "the shape of this document is unstable before krino 1.0"
+
+// JSON is the --json document. The shape is unstable before 1.0 and says
+// so in its own "note" field.
+type JSON struct {
+ Version int `json:"version"`
+ Note string `json:"note"`
+ Dirs []JSONDir `json:"dirs"`
+}
+
+// JSONDir is one directory's plan.
+type JSONDir struct {
+ Name string `json:"name"`
+ Root string `json:"root"`
+ Files []JSONFile `json:"files"`
+ Warnings []string `json:"warnings,omitempty"`
+}
+
+// JSONFile is one file's chain.
+type JSONFile struct {
+ Rel string `json:"rel"`
+ Size int64 `json:"size"`
+ ModTime time.Time `json:"mtime"`
+ Steps []JSONStep `json:"steps"`
+ Warnings []string `json:"warnings,omitempty"`
+}
+
+// JSONStep is one step of a chain. D14: Reason is carried because the text
+// plan already shows it and a machine reader should be able to see why a
+// file matched too; Conflict (the rule's on-conflict policy) is deliberately
+// not: it is a config detail, and its outcome is already visible through
+// dst, displaces and skip.
+type JSONStep struct {
+ Action string `json:"action"`
+ Rule string `json:"rule"`
+ Src string `json:"src"`
+ Dst string `json:"dst,omitempty"`
+ Displaces string `json:"displaces,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ Skip string `json:"skip,omitempty"`
+}
+
+// actionNames maps a Kind onto its JSON action name. This is its own
+// mapping, independent of Kind.String(): the display form renders "DELETE
+// permanently", which must never reach a machine reader. These names match
+// the log's action names in spec §9, so a later `krino log` and a --json
+// plan can be grepped together.
+var actionNames = map[Kind]string{
+ Copy: "copy",
+ Move: "move",
+ Rename: "rename",
+ Trash: "trash",
+ DeletePermanent: "delete",
+}
+
+// NewJSON builds the top-level --json document over dirs. Version and Note
+// are set here, in the one place jsonNote's wording already lives: it is
+// unexported, so a struct literal built outside this package would
+// silently ship an empty "note" and break the document's own contract.
+func NewJSON(dirs []JSONDir) JSON {
+ return JSON{Version: 1, Note: jsonNote, Dirs: dirs}
+}
+
+// NewJSONDir converts one directory's chains.
+func NewJSONDir(name, root string, chains []Chain, warnings []string) JSONDir {
+ files := make([]JSONFile, 0, len(chains))
+ for _, ch := range chains {
+ steps := make([]JSONStep, 0, len(ch.Steps))
+ for _, s := range ch.Steps {
+ steps = append(steps, JSONStep{
+ Action: actionNames[s.Kind],
+ Rule: s.Rule,
+ Src: s.Src,
+ Dst: s.Dst,
+ Displaces: s.Displaces,
+ Reason: s.Reason,
+ Skip: s.Skip,
+ })
+ }
+ files = append(files, JSONFile{
+ Rel: ch.File.Rel,
+ Size: ch.File.Size,
+ ModTime: ch.File.ModTime,
+ Steps: steps,
+ Warnings: ch.Warnings,
+ })
+ }
+ return JSONDir{Name: name, Root: root, Files: files, Warnings: warnings}
+}
diff --git a/internal/plan/json_test.go b/internal/plan/json_test.go
new file mode 100644
index 0000000..86c181c
--- /dev/null
+++ b/internal/plan/json_test.go
@@ -0,0 +1,87 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package plan
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestJSONDir(t *testing.T) {
+ chains := []Chain{{
+ File: file("/r", "x.pdf"),
+ Steps: []Step{
+ {Kind: Copy, Rule: "b", Src: "/r/x.pdf", Dst: "/backup/x.pdf", Reason: `content "acme ltd"`},
+ {Kind: Move, Rule: "a", Src: "/r/x.pdf", Dst: "/r/W/x.pdf", Displaces: "/r/W/x.pdf"},
+ {Kind: Rename, Rule: "r", Src: "/r/W/x.pdf", Dst: "/r/W/2026-x.pdf"},
+ {Kind: Trash, Rule: "t", Src: "/r/W/2026-x.pdf"},
+ {Kind: DeletePermanent, Rule: "z", Src: "/r/W/x.pdf", Skip: "deleted by rule a"},
+ },
+ Warnings: []string{"moved more than once; a (stop) is probably missing"},
+ }}
+ b, err := json.MarshalIndent(JSON{Version: 1, Note: jsonNote, Dirs: []JSONDir{NewJSONDir("dl", "/r", chains, nil)}}, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := string(b)
+ for _, want := range []string{
+ `"version": 1`,
+ `"note": "the shape of this document is unstable before krino 1.0"`,
+ `"name": "dl"`,
+ `"rel": "x.pdf"`,
+ `"action": "copy"`,
+ // D14: the reason a rule matched is carried into the JSON document too.
+ `"reason": "content \"acme ltd\""`,
+ // D7: rename and trash were previously covered only by inspection, so
+ // an edit garbling either name would have passed silently.
+ `"action": "rename"`,
+ `"action": "trash"`,
+ `"action": "move"`,
+ `"displaces": "/r/W/x.pdf"`,
+ `"action": "delete"`,
+ `"skip": "deleted by rule a"`,
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("json lacks %s:\n%s", want, out)
+ }
+ }
+ if strings.Contains(out, `"dst": ""`) {
+ t.Error("empty dst must be omitted")
+ }
+ var round JSON
+ if err := json.Unmarshal(b, &round); err != nil {
+ t.Fatalf("does not round-trip: %v", err)
+ }
+ if !round.Dirs[0].Files[0].ModTime.Equal(time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)) {
+ t.Errorf("mtime did not survive: %v", round.Dirs[0].Files[0].ModTime)
+ }
+}
+
+func TestJSONDirEmptyStepsAndFilesAreArraysNotNull(t *testing.T) {
+ stepless := []Chain{{File: file("/r", "y.pdf")}}
+ dir := NewJSONDir("dl", "/r", stepless, nil)
+ b, err := json.Marshal(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(b), `"steps":null`) {
+ t.Errorf("stepless file's steps must be [], not null: %s", b)
+ }
+ if !strings.Contains(string(b), `"steps":[]`) {
+ t.Errorf("stepless file's steps should marshal as []: %s", b)
+ }
+
+ empty := NewJSONDir("dl", "/r", nil, nil)
+ b, err = json.Marshal(empty)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(b), `"files":null`) {
+ t.Errorf("empty dir's files must be [], not null: %s", b)
+ }
+ if !strings.Contains(string(b), `"files":[]`) {
+ t.Errorf("empty dir's files should marshal as []: %s", b)
+ }
+}
diff --git a/internal/plan/placeholder.go b/internal/plan/placeholder.go
new file mode 100644
index 0000000..b947236
--- /dev/null
+++ b/internal/plan/placeholder.go
@@ -0,0 +1,155 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Package plan turns matched rules into concrete actions: placeholder
+// expansion, chains, conflict resolution and the JSON plan representation.
+package plan
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Facts are the values a placeholder can reference.
+type Facts struct {
+ Name string // the file's current base name
+ Captures []string // [0] whole match, [1:] groups, from the rule's first true name test
+ ModTime time.Time
+ Now time.Time
+}
+
+// splitExt splits name on its last dot, which does not count when it is the
+// first character: "a.tar.gz" -> "a.tar", ".gz"; ".bashrc" -> ".bashrc", "".
+func splitExt(name string) (stem, ext string) {
+ i := strings.LastIndexByte(name, '.')
+ if i <= 0 {
+ return name, ""
+ }
+ return name[:i], name[i:]
+}
+
+// Expand replaces every placeholder in s. It returns an error naming the
+// first placeholder it could not expand. It scans s once: "{{" and "}}"
+// each emit one literal brace, and any other "{" opens a placeholder that
+// runs to the next "}".
+func Expand(s string, f Facts) (string, error) {
+ var b strings.Builder
+ for i := 0; i < len(s); {
+ switch {
+ case s[i] == '{' && i+1 < len(s) && s[i+1] == '{':
+ b.WriteByte('{')
+ i += 2
+ case s[i] == '}' && i+1 < len(s) && s[i+1] == '}':
+ b.WriteByte('}')
+ i += 2
+ case s[i] == '{':
+ end := strings.IndexByte(s[i+1:], '}')
+ if end < 0 {
+ return "", errors.New("unclosed placeholder")
+ }
+ body := s[i+1 : i+1+end]
+ val, err := expandOne(body, f)
+ if err != nil {
+ return "", err
+ }
+ // D2: val is appended with WriteString, after the scan has
+ // already moved past this placeholder - it is never re-passed
+ // through this loop, so a "}}" or "{{" inside a capture's own
+ // text is never collapsed the way one written in the template
+ // itself would be. A refactor to scan-then-replace would
+ // silently undo this; TestExpandCaptureNotRescanned pins it.
+ b.WriteString(val)
+ i += end + 2
+ default:
+ b.WriteByte(s[i])
+ i++
+ }
+ }
+ return b.String(), nil
+}
+
+// expandOne expands the body of a single {...} placeholder (without the
+// braces) into its replacement text.
+func expandOne(body string, f Facts) (string, error) {
+ switch body {
+ case "name":
+ return f.Name, nil
+ case "stem":
+ stem, _ := splitExt(f.Name)
+ return stem, nil
+ case "ext":
+ _, ext := splitExt(f.Name)
+ return ext, nil
+ }
+
+ if verb, format, ok := strings.Cut(body, ":"); ok {
+ switch verb {
+ case "mtime":
+ return strftime(verb, format, f.ModTime)
+ case "now":
+ return strftime(verb, format, f.Now)
+ }
+ return "", fmt.Errorf("unknown placeholder {%s}", body)
+ }
+
+ if n, err := strconv.Atoi(body); err == nil {
+ if n == 0 {
+ return "", errors.New("capture groups are numbered from 1")
+ }
+ // D3: spec §7.3 defines the syntax as {1}...{9}, the same window
+ // MaxIndex enforces; without this check {10} and up bypass
+ // checkCaptures entirely (MaxIndex never sees them as capture
+ // uses) and fail only here, at expansion time.
+ if n > 9 {
+ return "", fmt.Errorf("unknown placeholder {%s}", body)
+ }
+ if n < 0 || n >= len(f.Captures) {
+ return "", fmt.Errorf("no capture group %d", n)
+ }
+ return f.Captures[n], nil
+ }
+
+ return "", fmt.Errorf("unknown placeholder {%s}", body)
+}
+
+// strftime renders a strftime subset (%Y %m %d %H %M %S %j %%) of t. verb is
+// the placeholder's own verb ("mtime" or "now"), named in error messages so
+// they point at what the config author actually wrote (B3) instead of
+// hardcoding "mtime" for a {now:...} format error.
+func strftime(verb, format string, t time.Time) (string, error) {
+ var b strings.Builder
+ for i := 0; i < len(format); i++ {
+ c := format[i]
+ if c != '%' {
+ b.WriteByte(c)
+ continue
+ }
+ i++
+ if i >= len(format) {
+ return "", fmt.Errorf("unknown time format %%%c in {%s:...}", format[i-1], verb)
+ }
+ switch format[i] {
+ case 'Y':
+ fmt.Fprintf(&b, "%04d", t.Year())
+ case 'm':
+ fmt.Fprintf(&b, "%02d", int(t.Month()))
+ case 'd':
+ fmt.Fprintf(&b, "%02d", t.Day())
+ case 'H':
+ fmt.Fprintf(&b, "%02d", t.Hour())
+ case 'M':
+ fmt.Fprintf(&b, "%02d", t.Minute())
+ case 'S':
+ fmt.Fprintf(&b, "%02d", t.Second())
+ case 'j':
+ fmt.Fprintf(&b, "%03d", t.YearDay())
+ case '%':
+ b.WriteByte('%')
+ default:
+ return "", fmt.Errorf("unknown time format %%%c in {%s:...}", format[i], verb)
+ }
+ }
+ return b.String(), nil
+}
diff --git a/internal/plan/placeholder_test.go b/internal/plan/placeholder_test.go
new file mode 100644
index 0000000..e155430
--- /dev/null
+++ b/internal/plan/placeholder_test.go
@@ -0,0 +1,94 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package plan
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func facts() Facts {
+ return Facts{
+ Name: "Invoice_2026-08.pdf",
+ Captures: []string{"2026-08", "2026", "a}}b"},
+ ModTime: time.Date(2026, 8, 15, 14, 30, 45, 0, time.UTC),
+ Now: time.Date(2026, 9, 12, 9, 0, 0, 0, time.UTC),
+ }
+}
+
+func TestExpand(t *testing.T) {
+ tests := []struct{ in, want string }{
+ {"Work/Acme", "Work/Acme"},
+ {"{name}", "Invoice_2026-08.pdf"},
+ {"{stem}", "Invoice_2026-08"},
+ {"{ext}", ".pdf"},
+ {"{stem}{ext}", "Invoice_2026-08.pdf"},
+ {"Work/{mtime:%Y}", "Work/2026"},
+ {"{mtime:%Y-%m-%d}", "2026-08-15"},
+ {"{mtime:%H%M%S}", "143045"},
+ {"{mtime:%j}", "227"},
+ {"{now:%Y-%m-%d}", "2026-09-12"},
+ {"{1}", "2026"},
+ // D2: an expanded value that itself contains "}}" must reach the
+ // output unchanged. Expand's single-pass scanner jumps past a
+ // placeholder's closing brace, so written bytes are never re-scanned;
+ // a refactor to scan-then-replace would silently re-collapse them.
+ {"{2}", "a}}b"},
+ {"{{literal}}", "{literal}"},
+ {"100{{%}}", "100{%}"},
+ {"{mtime:%Y%%}", "2026%"},
+ {"Photos/{mtime:%Y}/{stem}-{1}{ext}", "Photos/2026/Invoice_2026-08-2026.pdf"},
+ }
+ for _, tt := range tests {
+ got, err := Expand(tt.in, facts())
+ if err != nil || got != tt.want {
+ t.Errorf("Expand(%q) = %q, %v; want %q", tt.in, got, err, tt.want)
+ }
+ }
+}
+
+func TestExpandNoExtension(t *testing.T) {
+ f := facts()
+ f.Name = "README"
+ for in, want := range map[string]string{"{stem}": "README", "{ext}": ""} {
+ if got, err := Expand(in, f); err != nil || got != want {
+ t.Errorf("Expand(%q) on README = %q, %v; want %q", in, got, err, want)
+ }
+ }
+ f.Name = ".bashrc"
+ if got, _ := Expand("{stem}", f); got != ".bashrc" {
+ t.Errorf("dotfile stem = %q, want .bashrc", got)
+ }
+ if got, _ := Expand("{ext}", f); got != "" {
+ t.Errorf("dotfile ext = %q, want empty", got)
+ }
+ f.Name = "archive.tar.gz"
+ if got, _ := Expand("{stem}|{ext}", f); got != "archive.tar|.gz" {
+ t.Errorf("double extension = %q, want archive.tar|.gz", got)
+ }
+}
+
+func TestExpandErrors(t *testing.T) {
+ tests := []struct{ in, want string }{
+ {"{7}", "no capture group 7"},
+ {"{0}", "capture groups are numbered from 1"},
+ {"{whatever}", "unknown placeholder {whatever}"},
+ {"{name", "unclosed placeholder"},
+ {"{mtime:%Q}", "unknown time format %Q in {mtime:...}"},
+ {"{mtime}", "unknown placeholder {mtime}"},
+ // B3: the error must name the placeholder actually written ("now"),
+ // not hardcode "mtime" - the {mtime:%Q} case above passes either
+ // way, which is why that defect survived.
+ {"{now:%Q}", "unknown time format %Q in {now:...}"},
+ // D3: {1}...{9} is the syntax (spec §7.3); {10} and up must be
+ // rejected the same way an unknown placeholder is.
+ {"{10}", "unknown placeholder {10}"},
+ }
+ for _, tt := range tests {
+ _, err := Expand(tt.in, facts())
+ if err == nil || !strings.Contains(err.Error(), tt.want) {
+ t.Errorf("Expand(%q) error = %v; want %q", tt.in, err, tt.want)
+ }
+ }
+}
diff --git a/internal/plan/step.go b/internal/plan/step.go
new file mode 100644
index 0000000..e4f280f
--- /dev/null
+++ b/internal/plan/step.go
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package plan
+
+import (
+ "fmt"
+
+ "krino/internal/config"
+ "krino/internal/scan"
+)
+
+// Kind is what a step does.
+type Kind int
+
+const (
+ Copy Kind = iota
+ Move
+ Rename
+ Trash // (delete)
+ DeletePermanent // (delete permanent)
+)
+
+// String is for display only; Task 5's JSON representation defines its own
+// action names.
+func (k Kind) String() string {
+ switch k {
+ case Copy:
+ return "copy"
+ case Move:
+ return "move"
+ case Rename:
+ return "rename"
+ case Trash:
+ return "trash"
+ case DeletePermanent:
+ return "DELETE permanently"
+ }
+ return fmt.Sprintf("Kind(%d)", int(k))
+}
+
+// Step is one action to carry out, already resolved to absolute paths.
+type Step struct {
+ Kind Kind
+ Rule string // the rule that contributed it
+ Src string // absolute path the step reads from
+ Dst string // absolute path the file has after the step; "" for the two deletes
+ Reason string // the rule's match reasons, for display
+ Skip string // non-empty: this step will not run, and why
+ Conflict config.Conflict // the contributing rule's on-conflict policy
+ Displaces string // overwrite only: the existing file that must be trashed first
+}
+
+// Chain is one file's steps, in order.
+type Chain struct {
+ File scan.File
+ Steps []Step
+ Warnings []string
+}
+
+// RuleMatch is one matching rule's contribution to a file's chain.
+// internal/plan must not import internal/engine (engine imports plan), so
+// the engine converts its own types into these.
+type RuleMatch struct {
+ Name string
+ Actions []config.Action
+ Settings config.Resolved
+ Captures []string
+ Reasons []string
+}