aboutsummaryrefslogtreecommitdiff
path: root/internal/engine
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/engine
parent3b36a48b7ce5a53a9366f3b31f94311f178e2553 (diff)
downloadkrino-24a84671ace373ae331fa83a1ff484990f4dff0e.tar.gz
krino-24a84671ace373ae331fa83a1ff484990f4dff0e.zip
krino: planning โ€” chains, placeholders, conflicts, JSON
Diffstat (limited to 'internal/engine')
-rw-r--r--internal/engine/engine.go38
-rw-r--r--internal/engine/engine_test.go49
-rw-r--r--internal/engine/facts.go13
-rw-r--r--internal/engine/match.go10
-rw-r--r--internal/engine/plan.go60
-rw-r--r--internal/engine/plan_test.go146
6 files changed, 303 insertions, 13 deletions
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
index eec9a60..ea61e8c 100644
--- a/internal/engine/engine.go
+++ b/internal/engine/engine.go
@@ -6,6 +6,7 @@
package engine
import (
+ "fmt"
"os"
"time"
@@ -13,6 +14,7 @@ import (
"krino/internal/config"
"krino/internal/extract"
"krino/internal/ignore"
+ "krino/internal/plan"
)
// Engine holds a loaded, compiled configuration: everything a front end
@@ -84,6 +86,10 @@ func Load(mainFile string, names ...string) (*Engine, []*config.Diag) {
errs = append(errs, cerrs...)
continue
}
+ if diag := checkCaptures(d.File, r, c); diag != nil {
+ errs = append(errs, diag)
+ continue
+ }
dir.Rules = append(dir.Rules, &Rule{Name: r.Name, Conf: r, Settings: rs, Cond: c})
}
dir.ContentVariants = contentVariants(dir.Rules)
@@ -102,6 +108,38 @@ func Load(mainFile string, names ...string) (*Engine, []*config.Diag) {
}, nil
}
+// checkCaptures validates a compiled rule's actions against the capture
+// groups its own name tests can supply (spec 7.3): a rule using {N} needs a
+// name test at all, and every name test in it needs at least N groups. It
+// reports only the first offending action, so one config mistake yields one
+// diagnostic.
+func checkCaptures(file string, r *config.Rule, c *cond.Cond) *config.Diag {
+ groups := c.NameGroups()
+ for _, a := range r.Actions {
+ n, err := plan.MaxIndex(a.Arg)
+ if err != nil || n == 0 {
+ continue
+ }
+ if len(groups) == 0 {
+ return &config.Diag{File: file, Pos: a.Pos, Msg: fmt.Sprintf("rule %q: {%d} needs a name test to capture from", r.Name, n)}
+ }
+ for _, g := range groups {
+ if g < n {
+ return &config.Diag{File: file, Pos: a.Pos, Msg: fmt.Sprintf("rule %q: {%d} but a name test has only %s", r.Name, n, captureGroups(g))}
+ }
+ }
+ }
+ return nil
+}
+
+// captureGroups renders a capture-group count with correct singular/plural.
+func captureGroups(n int) string {
+ if n == 1 {
+ return "1 capture group"
+ }
+ return fmt.Sprintf("%d capture groups", n)
+}
+
// dedupeNames returns names with every repeat after its first occurrence
// removed, order preserved.
func dedupeNames(names []string) []string {
diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go
index 5c0536f..8dcfcb3 100644
--- a/internal/engine/engine_test.go
+++ b/internal/engine/engine_test.go
@@ -138,6 +138,55 @@ func TestCheck(t *testing.T) {
}
}
+// TestLoadRejectsUnsuppliedCaptures: a rule using {N} must be able to get it
+// from its own name tests (spec 7.3) โ€” adapted from the brief to this
+// package's actual sandbox/writeConfig/Load helpers (writeConfig takes a
+// main file body and a dirs map; there is no separate engineLoad, Load is
+// called directly).
+func TestLoadRejectsUnsuppliedCaptures(t *testing.T) {
+ tests := []struct{ rule, want string }{
+ {`(rule "a" (when (type pdf)) (move "Work/{1}"))`,
+ `rule "a": {1} needs a name test to capture from`},
+ {`(rule "a" (when (name "inv-(\d+)")) (move "Work/{2}"))`,
+ `rule "a": {2} but a name test has only 1 capture group`},
+ {`(rule "a" (when (or (name "x-(\d+)-(\d+)") (name "y-(\d+)"))) (rename "{2}"))`,
+ `rule "a": {2} but a name test has only 1 capture group`},
+ }
+ for _, tt := range tests {
+ h := sandbox(t)
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `(path "/tmp")
+` + tt.rule})
+ _, errs := Load(main, "dl")
+ joined := ""
+ for _, d := range errs {
+ joined += d.Error() + "\n"
+ }
+ if len(errs) != 1 || !strings.Contains(joined, tt.want) {
+ t.Errorf("rule %s: errs %v, want %q", tt.rule, errs, tt.want)
+ }
+ }
+}
+
+// TestLoadAcceptsSuppliedCaptures: a rule whose name test has enough groups
+// for every {N} it uses loads clean.
+func TestLoadAcceptsSuppliedCaptures(t *testing.T) {
+ for _, rule := range []string{
+ `(rule "a" (when (name "inv-(\d+)-(\d+)")) (move "Work/{2}/{1}"))`,
+ // B1a: a name test reachable only under a (not ...) must not count
+ // toward this rule's own captures (B1), but it must also not make
+ // the rule itself invalid - the outer (name ...) alone already
+ // supplies the two groups {2} needs.
+ `(rule "a" (when (and (name "inv-(\d+)-(\d+)") (not (name "draft")))) (move "Work/{2}"))`,
+ } {
+ h := sandbox(t)
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `(path "/tmp")
+` + rule})
+ if _, errs := Load(main, "dl"); len(errs) != 0 {
+ t.Errorf("rule %s: unexpected diagnostics: %v", rule, errs)
+ }
+ }
+}
+
// TestContentVariantsComputedAtLoad: B2 plumbing. Load computes each
// directory's distinct (ignoreCase, fold) content-test variants from its
// rules' resolved settings: a rule with no content test contributes
diff --git a/internal/engine/facts.go b/internal/engine/facts.go
index 60380f3..423bfd9 100644
--- a/internal/engine/facts.go
+++ b/internal/engine/facts.go
@@ -13,6 +13,7 @@ import (
"krino/internal/cond"
"krino/internal/dup"
"krino/internal/norm"
+ "krino/internal/plan"
"krino/internal/scan"
"krino/internal/xdg"
)
@@ -166,7 +167,7 @@ func (f *facts) Duplicate(dirs []string) (string, bool, error) {
root := f.run.d.Root
resolved := make([]string, len(dirs))
for i, raw := range dirs {
- resolved[i] = resolveDir(raw, root)
+ resolved[i] = plan.ResolveDir(raw, root)
}
sorted := append([]string(nil), resolved...)
sort.Strings(sorted)
@@ -183,16 +184,6 @@ func (f *facts) Duplicate(dirs []string) (string, bool, error) {
return displayOriginal(orig, root), true, nil
}
-// resolveDir expands a leading ~ and joins a relative directory to root,
-// cleaned.
-func resolveDir(raw, root string) string {
- p := xdg.Expand(raw)
- if !filepath.IsAbs(p) {
- p = filepath.Join(root, p)
- }
- return filepath.Clean(p)
-}
-
// displayOriginal reports orig relative to root when it lies inside root,
// else as an absolute path.
func displayOriginal(orig, root string) string {
diff --git a/internal/engine/match.go b/internal/engine/match.go
index 0693bd5..e68a6e8 100644
--- a/internal/engine/match.go
+++ b/internal/engine/match.go
@@ -16,6 +16,7 @@ import (
"krino/internal/cond"
"krino/internal/config"
+ "krino/internal/plan"
"krino/internal/scan"
"krino/internal/xdg"
)
@@ -312,7 +313,12 @@ func isBusy(path string, suffixes []string) bool {
// excludeDirs computes the directories Match and Explain never enter: each
// rule's copy/move destination, the Trash, and the directory holding the
-// main config file - each kept only when it lies strictly inside d's root.
+// main config file - each kept only when it lies strictly inside d's root
+// (C3: root itself is not "inside" it here - a rule cannot exclude the very
+// directory being scanned. cmd/krino/render.go's relToRoot answers a
+// different question, whether a destination is root or beneath it for
+// display purposes, and there root does count as inside; the two are each
+// correct for their own question, so do not "unify" them).
// A destination with no template placeholder excludes exactly that
// directory; a destination with a placeholder excludes only the static
// part before its first "{", cut back to a full path component (its last
@@ -348,7 +354,7 @@ func (e *Engine) excludeDirs(d *Dir) []string {
prefix = ""
}
}
- add(resolveDir(prefix, root))
+ add(plan.ResolveDir(prefix, root))
}
}
add(filepath.Join(xdg.DataHome(), "Trash"))
diff --git a/internal/engine/plan.go b/internal/engine/plan.go
new file mode 100644
index 0000000..979bec1
--- /dev/null
+++ b/internal/engine/plan.go
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package engine
+
+import (
+ "context"
+ "time"
+
+ "krino/internal/plan"
+)
+
+// DirPlan is one directory's plan: the match Result it was built from (for
+// the counts and warnings a front end renders), the chains plan.Build
+// produced from its matched files, and Elapsed (D12), which - unlike
+// Result.Elapsed, stopped inside Match before plan.Build ever runs - spans
+// both Match and Build, so a front end reporting how long planning took is
+// not undercounting it. It lives here, not in internal/plan, because
+// internal/plan must not import internal/engine (engine already imports
+// plan).
+type DirPlan struct {
+ Dir *Dir
+ Chains []plan.Chain
+ Result *Result
+ Elapsed time.Duration
+}
+
+// Plan matches d, then turns every matched file into a chain per spec
+// ยง7.1. Unmatched files never reach plan.Build: they have no rules, hence
+// no steps, and Result.Unmatched already says they were not matched.
+// Chains keep Match's Rel order, since Matched is already sorted that way
+// and Build's output lines up with its input position for position. claims
+// is the run-scoped claim set (A3): the caller creates one *plan.Claims per
+// run and passes the same instance to every directory's Plan call, so two
+// directories claiming one destination resolve the collision instead of
+// both silently landing on it.
+func (e *Engine) Plan(ctx context.Context, d *Dir, claims *plan.Claims) (*DirPlan, error) {
+ started := time.Now()
+ r, err := e.Match(ctx, d)
+ if err != nil {
+ return nil, err
+ }
+
+ inputs := make([]plan.Input, len(r.Matched))
+ for i, fm := range r.Matched {
+ rules := make([]plan.RuleMatch, len(fm.Rules))
+ for j, rm := range fm.Rules {
+ rules[j] = plan.RuleMatch{
+ Name: rm.Rule.Name,
+ Actions: rm.Rule.Conf.Actions,
+ Settings: rm.Rule.Settings,
+ Captures: rm.Captures,
+ Reasons: rm.Reasons,
+ }
+ }
+ inputs[i] = plan.Input{File: fm.File, Rules: rules}
+ }
+
+ chains := plan.Build(d.Root, inputs, e.Now(), plan.OS{}, claims)
+ return &DirPlan{Dir: d, Chains: chains, Result: r, Elapsed: time.Since(started)}, nil
+}
diff --git a/internal/engine/plan_test.go b/internal/engine/plan_test.go
new file mode 100644
index 0000000..a169168
--- /dev/null
+++ b/internal/engine/plan_test.go
@@ -0,0 +1,146 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package engine
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "krino/internal/plan"
+)
+
+func TestPlanBuildsChainsFromMatchedFiles(t *testing.T) {
+ h := sandbox(t)
+ os.Mkdir(filepath.Join(h, "dl"), 0o755)
+ old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ for _, n := range []string{"z.pdf", "a.pdf", "notes.txt"} {
+ p := filepath.Join(h, "dl", n)
+ if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(p, old, old); err != nil {
+ t.Fatal(err)
+ }
+ }
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
+(path "~/dl")
+(min-age 0s)
+(rule "pdfs" (when (type pdf)) (move "PDF"))
+`})
+ e, errs := Load(main, "dl")
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+
+ dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dp.Dir != e.Dirs[0] {
+ t.Errorf("Dir = %v, want %v", dp.Dir, e.Dirs[0])
+ }
+ if dp.Result == nil || len(dp.Result.Matched) != 2 || len(dp.Result.Unmatched) != 1 {
+ t.Fatalf("Result = %+v", dp.Result)
+ }
+
+ // Chains keep Match's Rel order: "a.pdf" before "z.pdf", regardless of
+ // walk or map-iteration order.
+ if len(dp.Chains) != 2 {
+ t.Fatalf("got %d chains, want 2 (unmatched notes.txt must not appear)", len(dp.Chains))
+ }
+ if dp.Chains[0].File.Rel != "a.pdf" || dp.Chains[1].File.Rel != "z.pdf" {
+ t.Fatalf("chains not in Rel order: %s, %s", dp.Chains[0].File.Rel, dp.Chains[1].File.Rel)
+ }
+
+ c := dp.Chains[0]
+ if len(c.Steps) != 1 {
+ t.Fatalf("a.pdf steps = %+v", c.Steps)
+ }
+ s := c.Steps[0]
+ want := filepath.Join(h, "dl", "PDF", "a.pdf")
+ if s.Kind != plan.Move || s.Rule != "pdfs" || s.Dst != want || s.Skip != "" {
+ t.Errorf("step = %+v, want Move to %s", s, want)
+ }
+}
+
+// TestPlanSharesClaimsAcrossDirectories: A3 - plan.Claims is created once
+// by the caller and threaded through every Engine.Plan call of a run, so
+// two configured directories cannot plan the same final name. Two
+// directories each hold a file named "x.txt" and each carry a rule moving
+// it to one shared destination outside both roots; calling Plan for both
+// with the *same* claims must resolve the second directory's step onto
+// "x_1.txt" rather than let it collide on "x.txt" too.
+func TestPlanSharesClaimsAcrossDirectories(t *testing.T) {
+ h := sandbox(t)
+ shared := filepath.Join(h, "elsewhere")
+ for _, d := range []string{"d1", "d2"} {
+ if err := os.Mkdir(filepath.Join(h, d), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(h, d, "x.txt"), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ dirConf := func(path string) string {
+ return fmt.Sprintf(`(path %q)
+(min-age 0s)
+(rule "r" (when (type txt)) (move %q))
+`, path, shared)
+ }
+ main := writeConfig(t, h, `(include "d1" "d2")`, map[string]string{
+ "d1": dirConf("~/d1"),
+ "d2": dirConf("~/d2"),
+ })
+ e, errs := Load(main, "d1", "d2")
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+
+ claims := plan.NewClaims()
+ dp1, err := e.Plan(context.Background(), e.Dirs[0], claims)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dp2, err := e.Plan(context.Background(), e.Dirs[1], claims)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(dp1.Chains) != 1 || len(dp1.Chains[0].Steps) != 1 {
+ t.Fatalf("d1 chains = %+v", dp1.Chains)
+ }
+ if len(dp2.Chains) != 1 || len(dp2.Chains[0].Steps) != 1 {
+ t.Fatalf("d2 chains = %+v", dp2.Chains)
+ }
+ got1 := dp1.Chains[0].Steps[0].Dst
+ got2 := dp2.Chains[0].Steps[0].Dst
+ want1 := filepath.Join(shared, "x.txt")
+ want2 := filepath.Join(shared, "x_1.txt")
+ if got1 != want1 {
+ t.Errorf("d1 dst = %s, want %s", got1, want1)
+ }
+ if got2 != want2 {
+ t.Errorf("d2 dst = %s, want %s (claims must be shared with d1's plan)", got2, want2)
+ }
+}
+
+func TestPlanReturnsMatchError(t *testing.T) {
+ h := sandbox(t)
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
+(path "~/dl")
+(rule "r" (when (type pdf)) (move "PDF"))
+`})
+ e, errs := Load(main, "dl")
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ // dl's root ~/dl does not exist: scan.Walk fails, and Plan must
+ // propagate that error rather than paper over it with an empty plan.
+ if _, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims()); err == nil {
+ t.Error("want an error when the root does not exist, got nil")
+ }
+}