aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 00:55:01 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 00:55:01 +0200
commit86e55e31f6905ae997619aa095706674e2ffe623 (patch)
tree35397d5b33b453b87473fac849d2fdb21ece36fa
parent9a77de31dc9759708ce9c2d14ea7c0876c214e71 (diff)
downloadkrino-86e55e31f6905ae997619aa095706674e2ffe623.tar.gz
krino-86e55e31f6905ae997619aa095706674e2ffe623.zip
explain reports captures and the chain a file would get
-rw-r--r--internal/engine/explain_test.go65
-rw-r--r--internal/engine/match.go32
-rw-r--r--internal/engine/plan.go29
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
+}