// SPDX-License-Identifier: GPL-3.0-or-later package engine import ( "context" "os" "path/filepath" "reflect" "strings" "testing" "time" "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.ExplainWithChain(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: 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": ` (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.ExplainWithChain(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) } } // 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) } }