aboutsummaryrefslogtreecommitdiff
path: root/internal/engine
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 01:34:45 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 01:34:45 +0200
commitecfaeabf2a92e26c6a521d5fac404a0fff6263b6 (patch)
tree4147dd6809a45fe5b773447352354dbee2295900 /internal/engine
parentbe4b1275c76b9cad984dfafaa8023db94eb3eecf (diff)
downloadkrino-ecfaeabf2a92e26c6a521d5fac404a0fff6263b6.tar.gz
krino-ecfaeabf2a92e26c6a521d5fac404a0fff6263b6.zip
milestone 1 review: claims span the run, explain's chain is opt-in and its own, overrides keyed by clean path, splice and enum guards
Diffstat (limited to 'internal/engine')
-rw-r--r--internal/engine/explain_test.go83
-rw-r--r--internal/engine/match.go23
-rw-r--r--internal/engine/session.go33
-rw-r--r--internal/engine/session_test.go90
4 files changed, 209 insertions, 20 deletions
diff --git a/internal/engine/explain_test.go b/internal/engine/explain_test.go
index 4d304f9..83011c5 100644
--- a/internal/engine/explain_test.go
+++ b/internal/engine/explain_test.go
@@ -4,10 +4,12 @@ package engine
import (
"context"
+ "os"
"path/filepath"
"reflect"
"strings"
"testing"
+ "time"
"krino/internal/plan"
)
@@ -26,7 +28,7 @@ func TestExplainReportsCapturesAndChain(t *testing.T) {
if len(errs) > 0 {
t.Fatal(errs)
}
- x, err := e.Explain(context.Background(), filepath.Join(dl, "Screenshot_2026-09-01.png"))
+ x, err := e.ExplainWithChain(context.Background(), filepath.Join(dl, "Screenshot_2026-09-01.png"))
if err != nil {
t.Fatal(err)
}
@@ -38,8 +40,9 @@ func TestExplainReportsCapturesAndChain(t *testing.T) {
}
}
-// TestExplainChainMatchesThePlan: the chain explain reports is the one the
-// directory's plan gives the same file, conflicts and skips included.
+// TestExplainChainMatchesThePlan: for a file planned on its own, the chain
+// is the one the directory's plan gives it, conflicts with files already on
+// disk 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": `
@@ -55,7 +58,7 @@ func TestExplainChainMatchesThePlan(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- x, err := e.Explain(context.Background(), filepath.Join(dl, "a.pdf"))
+ x, err := e.ExplainWithChain(context.Background(), filepath.Join(dl, "a.pdf"))
if err != nil {
t.Fatal(err)
}
@@ -63,3 +66,75 @@ func TestExplainChainMatchesThePlan(t *testing.T) {
t.Errorf("explain chain %+v\nplan chain %+v", x.Chain, dp.Chains[0].Steps)
}
}
+
+// TestExplainChainOnlyWhenAsked: the command line's explain does not build
+// the chain (it can hash files to resolve a conflict), and a file no rule
+// acts on has none at all (plan 13 review F1, F2).
+func TestExplainChainOnlyWhenAsked(t *testing.T) {
+ h, dl := excludeTree(t, map[string]string{"a.pdf": "one", "keep-b.pdf": "two", "new.pdf": "three"})
+ now := time.Now()
+ os.Chtimes(filepath.Join(dl, "new.pdf"), now, now)
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
+(path "~/dl")
+(exclude (name "^keep-"))
+(rule "all" (move "Out"))
+`})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ x, err := e.Explain(context.Background(), filepath.Join(dl, "a.pdf"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if x.Chain != nil {
+ t.Errorf("Explain built a chain: %+v", x.Chain)
+ }
+ if x, err = e.ExplainWithChain(context.Background(), filepath.Join(dl, "a.pdf")); err != nil || len(x.Chain) != 1 {
+ t.Fatalf("ExplainWithChain: %+v, %v", x.Chain, err)
+ }
+ for _, rel := range []string{"keep-b.pdf", "new.pdf"} {
+ x, err := e.ExplainWithChain(context.Background(), filepath.Join(dl, rel))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if x.Chain != nil {
+ t.Errorf("%s (%s%s) has a chain: %+v", rel, x.Skip, x.Excluded, x.Chain)
+ }
+ }
+}
+
+// TestExplainChainIsThisFileAlone: with two files competing for one name,
+// the chain shows what this file alone would do; the plan is where the two
+// are resolved against each other (plan 13 review F2).
+func TestExplainChainIsThisFileAlone(t *testing.T) {
+ h, dl := excludeTree(t, map[string]string{"a.pdf": "one", "b.pdf": "two"})
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
+(path "~/dl")
+(rule "r" (rename "same.pdf"))
+`})
+ 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)
+ }
+ var planned string
+ for _, c := range dp.Chains {
+ if c.File.Rel == "b.pdf" {
+ planned = filepath.Base(c.Steps[0].Dst)
+ }
+ }
+ if planned != "same_1.pdf" {
+ t.Fatalf("the plan gave b.pdf %q; expected the suffix", planned)
+ }
+ x, err := e.ExplainWithChain(context.Background(), filepath.Join(dl, "b.pdf"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := filepath.Base(x.Chain[0].Dst); got != "same.pdf" {
+ t.Errorf("chain for b.pdf alone = %q, want same.pdf (the plan resolves the clash with a.pdf)", got)
+ }
+}
diff --git a/internal/engine/match.go b/internal/engine/match.go
index 591f686..59cea42 100644
--- a/internal/engine/match.go
+++ b/internal/engine/match.go
@@ -251,9 +251,12 @@ 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 is what the matching rules would do to this file alone:
+ // placeholders expanded and conflicts resolved against the disk, but
+ // not against the other files of a plan, which can still take a name
+ // this chain shows (the plan itself is where those are resolved). It is
+ // nil unless ExplainWithChain asked for it, and nil for a file the scan
+ // would skip or an exclude sets aside, which no rule acts on.
Chain []plan.Step
}
@@ -263,6 +266,18 @@ type Explanation struct {
// match if the file were looked at; a rule reached after an earlier
// matching (stop) is recorded as Stopped, with no trace.
func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error) {
+ return e.explain(ctx, path, false)
+}
+
+// ExplainWithChain is Explain with Explanation.Chain filled in: the steps
+// this file alone would get. Building them resolves conflicts against the
+// disk, which can read files (a copy whose target holds the same bytes), so
+// the command line's explain does not ask for it (plan 13 review F1).
+func (e *Engine) ExplainWithChain(ctx context.Context, path string) (*Explanation, error) {
+ return e.explain(ctx, path, true)
+}
+
+func (e *Engine) explain(ctx context.Context, path string, withChain bool) (*Explanation, error) {
abs, err := filepath.Abs(path)
if err != nil {
return nil, err
@@ -358,7 +373,7 @@ func (e *Engine) Explain(ctx context.Context, path string) (*Explanation, error)
}
var chain []plan.Step
- if len(matched) > 0 {
+ if withChain && skip == "" && excluded == "" && 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
diff --git a/internal/engine/session.go b/internal/engine/session.go
index 33b6a76..d79c364 100644
--- a/internal/engine/session.go
+++ b/internal/engine/session.go
@@ -25,6 +25,7 @@ type Session struct {
j *journal.Writer
run string
claims *plan.Claims
+ landed []string // where this run's applied files ended up, in order
dry bool
}
@@ -90,26 +91,34 @@ func (s *Session) Plan(ctx context.Context, d *Dir) (*DirPlan, error) {
return s.e.Plan(ctx, d, s.claims)
}
-// Apply carries out the approved files of dp and logs the run's steps. A
-// real run applies each directory before the next is planned, so afterwards
-// the disk is the truth for the next one: only the paths this directory's
-// files ended up at stay claimed, which keeps a later (on-conflict
-// overwrite) from displacing this run's own result (spec §7.4). A dry
-// session keeps every claim, since it applies nothing.
+// Apply carries out the approved files of dp and logs the run's steps,
+// remembering where they ended up for FinishDirectory.
func (s *Session) Apply(ctx context.Context, dp *DirPlan, approved map[string]bool) (*ApplyResult, error) {
if err := s.OpenLog(); err != nil {
return nil, err
}
res, err := s.e.Apply(ctx, dp, approved, s.j, s.run)
- if !s.dry {
- s.claims = plan.NewClaims()
- for _, p := range landedAt(res) {
- s.claims.Claim(p)
- }
- }
+ s.landed = append(s.landed, landedAt(res)...)
return res, err
}
+// FinishDirectory ends one directory of a real run: the disk is now the
+// truth for the next one, so the claims start again from where this run's
+// files have actually ended up - every directory's, not just this one's
+// (spec §7.4, plan 13 review F3). A path this directory planned but did not
+// apply is free again; a path it did apply stays protected from a later
+// (on-conflict overwrite) for the rest of the run, even across a directory
+// that applies nothing. A dry run applies nothing and keeps every claim.
+func (s *Session) FinishDirectory() {
+ if s.dry {
+ return
+ }
+ s.claims = plan.NewClaims()
+ for _, p := range s.landed {
+ s.claims.Claim(p)
+ }
+}
+
// landedAt is where res's files ended up: each copy, and the last place a
// move or rename put a file - not a path it passed through and left, and
// nothing at all for a file deleted for good.
diff --git a/internal/engine/session_test.go b/internal/engine/session_test.go
index 8fd4593..98ea1ee 100644
--- a/internal/engine/session_test.go
+++ b/internal/engine/session_test.go
@@ -180,3 +180,93 @@ func TestSessionLockDirsReleasesOnFailure(t *testing.T) {
l.Release()
}
}
+
+// TestSessionKeepsEveryAppliedDestinationClaimed: what a directory's files
+// ended up at stays protected for the whole run, even across a directory
+// that applies nothing (plan 13 review F3): a later (on-conflict overwrite)
+// takes a free name instead of trashing an earlier directory's result.
+func TestSessionKeepsEveryAppliedDestinationClaimed(t *testing.T) {
+ h := sandbox(t)
+ old := time.Now().Add(-2 * time.Hour)
+ dirs := map[string]string{
+ "alpha": "(path \"~/alpha\")\n(rule \"out\" (move \"~/shared\"))\n",
+ "beta": "(path \"~/beta\")\n(rule \"none\" (when (type zzz)) (move \"~/shared\"))\n",
+ "gamma": "(path \"~/gamma\")\n(on-conflict overwrite)\n(rule \"out\" (move \"~/shared\"))\n",
+ }
+ for _, n := range []string{"alpha", "beta", "gamma"} {
+ p := filepath.Join(h, n, "x.pdf")
+ os.MkdirAll(filepath.Dir(p), 0o755)
+ os.WriteFile(p, []byte("from "+n), 0o644)
+ os.Chtimes(p, old, old)
+ }
+ main := writeConfig(t, h, `(include "alpha" "beta" "gamma")`, dirs)
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ s, err := e.NewSession(false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ for _, d := range e.Dirs {
+ dp, err := s.Plan(context.Background(), d)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Apply(context.Background(), dp, map[string]bool{"x.pdf": true}); err != nil {
+ t.Fatal(err)
+ }
+ s.FinishDirectory()
+ }
+ for rel, want := range map[string]string{"shared/x.pdf": "from alpha", "shared/x_1.pdf": "from gamma"} {
+ if b, err := os.ReadFile(filepath.Join(h, rel)); err != nil || string(b) != want {
+ t.Errorf("%s: %q, %v; want %q", rel, b, err, want)
+ }
+ }
+ if entries, _ := os.ReadDir(filepath.Join(h, ".local", "share", "Trash", "files")); len(entries) != 0 {
+ t.Errorf("the Trash holds %d entries; an earlier directory's result was displaced", len(entries))
+ }
+}
+
+// TestSessionDropsUnappliedClaims: a destination a directory planned but did
+// not apply (declined) is free for the next directory.
+func TestSessionDropsUnappliedClaims(t *testing.T) {
+ h := sandbox(t)
+ old := time.Now().Add(-2 * time.Hour)
+ dirs := map[string]string{
+ "alpha": "(path \"~/alpha\")\n(rule \"out\" (move \"~/shared\"))\n",
+ "beta": "(path \"~/beta\")\n(rule \"out\" (move \"~/shared\"))\n",
+ }
+ for _, n := range []string{"alpha", "beta"} {
+ p := filepath.Join(h, n, "x.pdf")
+ os.MkdirAll(filepath.Dir(p), 0o755)
+ os.WriteFile(p, []byte("from "+n), 0o644)
+ os.Chtimes(p, old, old)
+ }
+ main := writeConfig(t, h, `(include "alpha" "beta")`, dirs)
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ s, err := e.NewSession(false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ dp, err := s.Plan(context.Background(), e.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Apply(context.Background(), dp, map[string]bool{}); err != nil { // declined
+ t.Fatal(err)
+ }
+ s.FinishDirectory()
+ dp, err = s.Plan(context.Background(), e.Dirs[1])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := dp.Chains[0].Steps[0].Dst; filepath.Base(got) != "x.pdf" {
+ t.Errorf("beta planned %q; the declined destination should be free", got)
+ }
+}