diff options
Diffstat (limited to 'internal/engine')
| -rw-r--r-- | internal/engine/explain_test.go | 83 | ||||
| -rw-r--r-- | internal/engine/match.go | 23 | ||||
| -rw-r--r-- | internal/engine/session.go | 33 | ||||
| -rw-r--r-- | internal/engine/session_test.go | 90 |
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) + } +} |
