// SPDX-License-Identifier: GPL-3.0-or-later package plan import ( "path/filepath" "testing" "time" "krino/internal/config" "krino/internal/scan" ) func file(root, rel string) scan.File { return scan.File{ Path: filepath.Join(root, rel), Rel: rel, Name: filepath.Base(rel), Size: 10, ModTime: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC), } } func act(k config.ActionKind, arg string) config.Action { return config.Action{Kind: k, Arg: arg} } func TestBuildChain(t *testing.T) { root := "/r" now := time.Date(2026, 9, 12, 0, 0, 0, 0, time.UTC) in := []Input{{ File: file(root, "inv1.pdf"), Rules: []RuleMatch{ {Name: "backup", Actions: []config.Action{act(config.Copy, "/backup/{mtime:%Y}")}}, {Name: "acme", Actions: []config.Action{ act(config.Rename, "{mtime:%Y-%m-%d}_{name}"), act(config.Move, "Work/Acme/{mtime:%Y}"), }}, }, }} got := Build(root, in, now, NoDisk{}, NewClaims()) if len(got) != 1 || len(got[0].Steps) != 3 { t.Fatalf("got %d chains / %d steps", len(got), len(got[0].Steps)) } want := []Step{ {Kind: Copy, Rule: "backup", Src: "/r/inv1.pdf", Dst: "/backup/2026/inv1.pdf"}, {Kind: Rename, Rule: "acme", Src: "/r/inv1.pdf", Dst: "/r/2026-08-15_inv1.pdf"}, {Kind: Move, Rule: "acme", Src: "/r/2026-08-15_inv1.pdf", Dst: "/r/Work/Acme/2026/2026-08-15_inv1.pdf"}, } for i, w := range want { g := got[0].Steps[i] if g.Kind != w.Kind || g.Rule != w.Rule || g.Src != w.Src || g.Dst != w.Dst || g.Skip != "" { t.Errorf("step %d = %+v; want %+v", i, g, w) } } if len(got[0].Warnings) != 0 { t.Errorf("unexpected warnings: %v", got[0].Warnings) } } func TestBuildDeleteEndsChain(t *testing.T) { in := []Input{{ File: file("/r", "old.iso"), Rules: []RuleMatch{ {Name: "dups", Actions: []config.Action{act(config.Delete, "")}}, {Name: "archive", Actions: []config.Action{act(config.Move, "Archive")}}, }, }} steps := Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0].Steps if len(steps) != 2 || steps[0].Kind != Trash || steps[0].Dst != "" { t.Fatalf("steps = %+v", steps) } if steps[1].Skip != "deleted by rule dups" { t.Errorf("step after delete: Skip = %q", steps[1].Skip) } } func TestBuildWarnsOnTwoMoves(t *testing.T) { in := []Input{{ File: file("/r", "x.pdf"), Rules: []RuleMatch{ {Name: "a", Actions: []config.Action{act(config.Move, "A")}}, {Name: "b", Actions: []config.Action{act(config.Move, "B")}}, }, }} c := Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0] if len(c.Warnings) != 1 || c.Warnings[0] != "moved more than once; a (stop) is probably missing" { t.Errorf("warnings = %v", c.Warnings) } if c.Steps[1].Src != "/r/A/x.pdf" { t.Errorf("second move reads from %q, want the path after the first move", c.Steps[1].Src) } } func TestBuildBadPlaceholderSkipsOneStep(t *testing.T) { in := []Input{{ File: file("/r", "x.pdf"), Rules: []RuleMatch{{Name: "a", Actions: []config.Action{ act(config.Move, "Work/{1}"), act(config.Rename, "ok-{name}"), }}}, }} c := Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0] if c.Steps[0].Skip == "" { t.Errorf("step with {1} and no captures should be skipped: %+v", c.Steps[0]) } if c.Steps[1].Skip != "" || c.Steps[1].Dst != "/r/ok-x.pdf" { t.Errorf("chain should continue from the unchanged path: %+v", c.Steps[1]) } } func TestBuildRenameWithSlash(t *testing.T) { in := []Input{{ File: file("/r", "x.pdf"), Rules: []RuleMatch{{Name: "a", Actions: []config.Action{act(config.Rename, "sub/{name}")}}}, }} c := Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0] if c.Steps[0].Skip != `rename produced a name containing "/"` { t.Errorf("Skip = %q", c.Steps[0].Skip) } } // TestBuildKeepsSteplessChains is D6: Build returns one Chain per Input even // when a file's rules contribute no actions, so a caller can tell "matched a // rule that does nothing" (an exclusion) from "not matched at all". Plan 3's // "to act on" count and the JSON document's empty steps array both rest on // this. func TestBuildKeepsSteplessChains(t *testing.T) { in := []Input{ {File: file("/r", "excluded.txt"), Rules: []RuleMatch{{Name: "only-stop"}}}, {File: file("/r", "untouched.txt")}, } chains := Build("/r", in, time.Now(), NoDisk{}, NewClaims()) if len(chains) != 2 { t.Fatalf("got %d chains, want one per Input", len(chains)) } for i, c := range chains { if len(c.Steps) != 0 { t.Errorf("chain %d: got %d steps, want none", i, len(c.Steps)) } if c.File.Rel != in[i].File.Rel { t.Errorf("chain %d: File.Rel = %q, want %q (positional alignment)", i, c.File.Rel, in[i].File.Rel) } } } // TestBuildNoDeleteSkipsDeletesAndContinues is spec §5.5 rule 2 and §7.1: // with Input.NoDelete set, every delete step is skipped with that reason, // and the chain carries on from the file's current path instead of ending. func TestBuildNoDeleteSkipsDeletesAndContinues(t *testing.T) { in := []Input{{ File: file("/r", "b.pdf"), NoDelete: "a duplicate is never deleted", Rules: []RuleMatch{ {Name: "old", Actions: []config.Action{act(config.DeletePermanent, "")}}, {Name: "dupes", Actions: []config.Action{act(config.Move, "Dupes")}}, {Name: "cleanup", Actions: []config.Action{act(config.Delete, "")}}, }, }} steps := Build("/r", in, time.Now(), NoDisk{}, NewClaims())[0].Steps if len(steps) != 3 { t.Fatalf("steps = %+v", steps) } if steps[0].Kind != DeletePermanent || steps[0].Skip != "a duplicate is never deleted" { t.Errorf("step 0 = %+v, want a skipped permanent delete", steps[0]) } if steps[1].Kind != Move || steps[1].Skip != "" || steps[1].Dst != "/r/Dupes/b.pdf" { t.Errorf("step 1 = %+v, want the move to run", steps[1]) } if steps[2].Kind != Trash || steps[2].Skip != "a duplicate is never deleted" || steps[2].Src != "/r/Dupes/b.pdf" { t.Errorf("step 2 = %+v, want a skipped trash from the moved path", steps[2]) } }