// SPDX-License-Identifier: GPL-3.0-or-later package engine import ( "context" "fmt" "os" "path/filepath" "testing" "time" "git.labunix.xyz/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") } }