From 24a84671ace373ae331fa83a1ff484990f4dff0e Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 12 Sep 2026 12:58:14 +0200 Subject: krino: planning — chains, placeholders, conflicts, JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/engine/plan_test.go | 146 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 internal/engine/plan_test.go (limited to 'internal/engine/plan_test.go') 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") + } +} -- cgit v1.3