diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 12:58:14 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 12:58:14 +0200 |
| commit | 24a84671ace373ae331fa83a1ff484990f4dff0e (patch) | |
| tree | a6b6e3949d7dd241f1d13e079dfb982d758c89a2 /internal/engine | |
| parent | 3b36a48b7ce5a53a9366f3b31f94311f178e2553 (diff) | |
| download | krino-24a84671ace373ae331fa83a1ff484990f4dff0e.tar.gz krino-24a84671ace373ae331fa83a1ff484990f4dff0e.zip | |
krino: planning โ chains, placeholders, conflicts, JSON
Diffstat (limited to 'internal/engine')
| -rw-r--r-- | internal/engine/engine.go | 38 | ||||
| -rw-r--r-- | internal/engine/engine_test.go | 49 | ||||
| -rw-r--r-- | internal/engine/facts.go | 13 | ||||
| -rw-r--r-- | internal/engine/match.go | 10 | ||||
| -rw-r--r-- | internal/engine/plan.go | 60 | ||||
| -rw-r--r-- | internal/engine/plan_test.go | 146 |
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") + } +} |
