diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-16 00:55:01 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-16 00:55:01 +0200 |
| commit | 86e55e31f6905ae997619aa095706674e2ffe623 (patch) | |
| tree | 35397d5b33b453b87473fac849d2fdb21ece36fa | |
| parent | 9a77de31dc9759708ce9c2d14ea7c0876c214e71 (diff) | |
| download | krino-86e55e31f6905ae997619aa095706674e2ffe623.tar.gz krino-86e55e31f6905ae997619aa095706674e2ffe623.zip | |
explain reports captures and the chain a file would get
| -rw-r--r-- | internal/engine/explain_test.go | 65 | ||||
| -rw-r--r-- | internal/engine/match.go | 32 | ||||
| -rw-r--r-- | internal/engine/plan.go | 29 |
3 files changed, 108 insertions, 18 deletions
diff --git a/internal/engine/explain_test.go b/internal/engine/explain_test.go new file mode 100644 index 0000000..4d304f9 --- /dev/null +++ b/internal/engine/explain_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package engine + +import ( + "context" + "path/filepath" + "reflect" + "strings" + "testing" + + "krino/internal/plan" +) + +// TestExplainReportsCapturesAndChain: explain returns what each matching +// rule captured and the steps the file would get, so a GUI can show +// "{1}=2026" and the destination without planning the whole directory +// (GUI design §1.3). +func TestExplainReportsCapturesAndChain(t *testing.T) { + h, dl := excludeTree(t, map[string]string{"Screenshot_2026-09-01.png": "x"}) + main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": ` +(path "~/dl") +(rule "shots" (when (name "^Screenshot_(\d{4})-(\d{2})")) (move "Pictures/{1}-{2}") (stop)) +`}) + e, errs := Load(main) + if len(errs) > 0 { + t.Fatal(errs) + } + x, err := e.Explain(context.Background(), filepath.Join(dl, "Screenshot_2026-09-01.png")) + if err != nil { + t.Fatal(err) + } + if len(x.Rules) != 1 || !reflect.DeepEqual(x.Rules[0].Captures, []string{"Screenshot_2026-09", "2026", "09"}) { + t.Fatalf("captures = %+v", x.Rules) + } + if len(x.Chain) != 1 || x.Chain[0].Kind != plan.Move || !strings.HasSuffix(x.Chain[0].Dst, "/dl/Pictures/2026-09/Screenshot_2026-09-01.png") { + t.Fatalf("chain = %+v", x.Chain) + } +} + +// TestExplainChainMatchesThePlan: the chain explain reports is the one the +// directory's plan gives the same file, conflicts and skips included. +func TestExplainChainMatchesThePlan(t *testing.T) { + h, dl := excludeTree(t, map[string]string{"a.pdf": "one", "Out/a.pdf": "taken"}) + main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": ` +(path "~/dl") +(ignore "Out/") +(rule "r" (when (name "^a")) (rename "b.pdf") (move "Out")) +`}) + e, errs := Load(main) + if len(errs) > 0 { + t.Fatal(errs) + } + dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims()) + if err != nil { + t.Fatal(err) + } + x, err := e.Explain(context.Background(), filepath.Join(dl, "a.pdf")) + if err != nil { + t.Fatal(err) + } + if len(dp.Chains) != 1 || !reflect.DeepEqual(x.Chain, dp.Chains[0].Steps) { + t.Errorf("explain chain %+v\nplan chain %+v", x.Chain, dp.Chains[0].Steps) + } +} diff --git a/internal/engine/match.go b/internal/engine/match.go index 67f0c06..591f686 100644 --- a/internal/engine/match.go +++ b/internal/engine/match.go @@ -228,10 +228,11 @@ func noDelete(f *facts, scopes [][]string) string { // RuleTrace is one rule's outcome in an Explain call. type RuleTrace struct { - Rule *Rule - Match bool - Trace *cond.Trace // nil when not evaluated - Stopped string // "stopped by rule acme" when an earlier (stop) ended the search + Rule *Rule + Match bool + Captures []string // of a matching rule, as its actions' {N} see them + Trace *cond.Trace // nil when not evaluated + Stopped string // "stopped by rule acme" when an earlier (stop) ended the search } // ExcludeTrace is one (exclude ...) form's outcome in an Explain call. @@ -250,6 +251,10 @@ type Explanation struct { Excluded string // the first exclude that matches, which sets the file aside; "" when none does Rules []RuleTrace NoDelete string // why a delete from the matching rules would be skipped (spec §5.5); "" when it would not + // Chain is what the matching rules would do to the file: the same steps + // the directory's plan builds for it, placeholders expanded and + // conflicts resolved (GUI design §1.3). + Chain []plan.Step } // Explain reports, for one file, whether krino's ordinary scan would ever @@ -333,11 +338,16 @@ func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error) continue } } + var caps []string if match { + // Eval, on the same memoised facts and before matched becomes + // true, is what gives the captures its actions would use. + res := r.Cond.Eval(f) + caps = res.Captures f.matched = true - matched = append(matched, RuleMatch{Rule: r}) + matched = append(matched, RuleMatch{Rule: r, Captures: caps, Reasons: res.Reasons}) } - rules = append(rules, RuleTrace{Rule: r, Match: match, Trace: trace}) + rules = append(rules, RuleTrace{Rule: r, Match: match, Captures: caps, Trace: trace}) if match && r.Conf.Stop { stoppedBy = r.Name } @@ -347,7 +357,15 @@ func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error) noDel = noDelete(f, d.DupScopes) } - return &Explanation{Dir: d, File: sf, Skip: skip, Excludes: excludes, Excluded: excluded, Rules: rules, NoDelete: noDel}, nil + var chain []plan.Step + if len(matched) > 0 { + in := []plan.Input{{File: sf, Rules: planRules(matched), NoDelete: noDel}} + if built := plan.Build(d.Root, in, now, plan.OS{}, plan.NewClaims()); len(built) == 1 { + chain = built[0].Steps + } + } + + return &Explanation{Dir: d, File: sf, Skip: skip, Excludes: excludes, Excluded: excluded, Rules: rules, NoDelete: noDel, Chain: chain}, nil } // excludesUseDuplicate reports whether any of d's excludes has a diff --git a/internal/engine/plan.go b/internal/engine/plan.go index d9ffeb3..f2d6312 100644 --- a/internal/engine/plan.go +++ b/internal/engine/plan.go @@ -42,19 +42,26 @@ func (e *Engine) Plan(ctx context.Context, d *Dir, claims *plan.Claims) (*DirPla 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, NoDelete: fm.NoDelete} + inputs[i] = plan.Input{File: fm.File, Rules: planRules(fm.Rules), NoDelete: fm.NoDelete} } chains := plan.Build(d.Root, inputs, e.Now(), plan.OS{}, claims) return &DirPlan{Dir: d, Chains: chains, Result: r, Elapsed: time.Since(started)}, nil } + +// planRules converts a file's matching rules into what plan.Build takes. +// Explain builds the chain of one file the same way (GUI design §1.3), so +// both go through this. +func planRules(matched []RuleMatch) []plan.RuleMatch { + rules := make([]plan.RuleMatch, len(matched)) + for i, rm := range matched { + rules[i] = plan.RuleMatch{ + Name: rm.Rule.Name, + Actions: rm.Rule.Conf.Actions, + Settings: rm.Rule.Settings, + Captures: rm.Captures, + Reasons: rm.Reasons, + } + } + return rules +} |
