// SPDX-License-Identifier: GPL-3.0-or-later package main import ( "bytes" "fmt" "path/filepath" "strings" "testing" "time" "krino/internal/engine" "krino/internal/plan" "krino/internal/scan" ) // TestPrintPlan is the golden render test of spec §8.2. The DirPlan is // built by hand, not by running a scan, so the expected output cannot // drift with a fixture: it asserts the header row, a numbered row, a // continuation line, the "DELETE permanently" capitalisation and the // counts line. // // The two destinations pin both branches of destText: scan001.pdf's and // fv_123.pdf's "acme" step lands inside root and renders root-relative // ("Work/Acme/2026/"), while fv_123.pdf's "backup" step lands under $HOME // but outside root and stays home-abbreviated ("~/backup/invoices/2026/") // - spec §8.2's own worked example draws exactly this distinction. // // "excluded.txt" matched a rule with no actions (an exclusion, spec §4.5): // it has zero steps, so it must not appear in the table and must not // inflate "to act on" (ruling 2026-09-12). func TestPrintPlan(t *testing.T) { h := home(t) root := filepath.Join(h, "downloads") scan001 := plan.Chain{ File: scan.File{Rel: "scan001.pdf"}, Steps: []plan.Step{ { Kind: plan.Move, Rule: "acme", Src: filepath.Join(root, "scan001.pdf"), Dst: filepath.Join(root, "Work", "Acme", "2026", "scan001.pdf"), Reason: `content "acme ltd"`, }, }, } fv123 := plan.Chain{ File: scan.File{Rel: "fv_123.pdf"}, Steps: []plan.Step{ { Kind: plan.Copy, Rule: "backup", Src: filepath.Join(root, "fv_123.pdf"), Dst: filepath.Join(h, "backup", "invoices", "2026", "fv_123.pdf"), Reason: `content "invoice"`, }, { Kind: plan.Move, Rule: "acme", Src: filepath.Join(h, "backup", "invoices", "2026", "fv_123.pdf"), Dst: filepath.Join(root, "Work", "Acme", "2026", "fv_123.pdf"), Reason: `name \bacme\b`, }, }, } setup := plan.Chain{ File: scan.File{Rel: "setup-1.2.deb"}, Steps: []plan.Step{ {Kind: plan.DeletePermanent, Rule: "old-pkgs", Src: filepath.Join(root, "setup-1.2.deb"), Reason: "age 94d"}, }, } excluded := plan.Chain{File: scan.File{Rel: "excluded.txt"}} // matched a stop-only rule: no actions, no steps dp := &engine.DirPlan{ Dir: &engine.Dir{Name: "downloads", Root: root}, Chains: []plan.Chain{scan001, fv123, setup, excluded}, Elapsed: 420 * time.Millisecond, // D12: the counts line renders DirPlan.Elapsed (Match plus Build), not Result.Elapsed alone Result: &engine.Result{ Matched: []engine.FileMatch{ {File: scan.File{Rel: "scan001.pdf"}, Warnings: []string{"acme: content unreadable: needs pdftotext, not installed"}}, {File: scan.File{Rel: "fv_123.pdf"}}, {File: scan.File{Rel: "setup-1.2.deb"}}, {File: scan.File{Rel: "excluded.txt"}}, }, Unmatched: []engine.FileMatch{{File: scan.File{Rel: "unmatched.txt"}}}, Skipped: []scan.Skipped{{Rel: "busy.tmp", Reason: scan.Busy}}, }, } var buf bytes.Buffer printPlan(&buf, dp, false, palette{}) out := buf.String() for _, want := range []string{ "6 scanned · 3 to act on · 1 warnings · 0.42s\n", " # file actions rule\n", " 1 scan001.pdf move → Work/Acme/2026/ acme content \"acme ltd\"\n", " 2 fv_123.pdf copy → ~/backup/invoices/2026/ backup content \"invoice\"\n", " move → Work/Acme/2026/ acme name \\bacme\\b\n", " 3 setup-1.2.deb DELETE permanently old-pkgs age 94d\n", "warnings\n scan001.pdf acme: content unreadable: needs pdftotext, not installed\n", "not acted on: 1 busy · 1 excluded · 1 unmatched (-v lists them)\n", } { if !strings.Contains(out, want) { t.Errorf("output lacks %q:\n%s", want, out) } } if strings.Contains(out, "excluded.txt") { t.Errorf("excluded.txt has no steps and must not appear in the table:\n%s", out) } } // TestPrintPlanSkippedStep: a step with Skip set shows its reason in place // of the destination, and a Displaces step notes that it replaces the // existing file. func TestPrintPlanSkippedStep(t *testing.T) { home(t) // isolate HOME even though these paths do not use it dp := &engine.DirPlan{ Dir: &engine.Dir{Name: "dl", Root: "/r"}, Chains: []plan.Chain{ { File: scan.File{Rel: "a.pdf"}, Steps: []plan.Step{ {Kind: plan.Copy, Rule: "backup", Src: "/r/a.pdf", Dst: "/backup/a.pdf", Skip: "target exists"}, }, }, { File: scan.File{Rel: "b.pdf"}, Steps: []plan.Step{ {Kind: plan.Move, Rule: "acme", Src: "/r/b.pdf", Dst: "/r/Work/b.pdf", Displaces: "/r/Work/b.pdf"}, }, }, }, Result: &engine.Result{ Matched: []engine.FileMatch{{File: scan.File{Rel: "a.pdf"}}, {File: scan.File{Rel: "b.pdf"}}}, }, } var buf bytes.Buffer printPlan(&buf, dp, false, palette{}) out := buf.String() if !strings.Contains(out, "copy skipped: target exists") { t.Errorf("skipped step should show its reason in place of the destination:\n%s", out) } if !strings.Contains(out, "move → Work/ (replaces the existing file)") { t.Errorf("a Displaces step should note it replaces the existing file:\n%s", out) } if !strings.Contains(out, "2 scanned · 1 to act on · 0 warnings ·") { t.Errorf("a.pdf's only step is skipped, so it must not count as \"to act on\":\n%s", out) } } // TestPrintPlanRowNumberAlignment pins the table layout plan 4's review UI // inherits: with 11 acted-on files the row-number column has to widen past // a single digit, and the row number is right-aligned so "#" stays flush. // The fixture also carries a file name past the 40-character cap and a // rule name noticeably longer than the rest, exercising the file and rule // columns' own per-section widths at the same time. func TestPrintPlanRowNumberAlignment(t *testing.T) { h := home(t) root := filepath.Join(h, "dl") long := strings.Repeat("z", 42) + ".txt" // 46 runes: past the 40-column cap names := make([]string, 11) for i := range names { names[i] = fmt.Sprintf("f%02d.txt", i+1) } names[10] = long // row 11 carries the long name var chains []plan.Chain var matched []engine.FileMatch for i, name := range names { rule := "r" if i == 5 { rule = "a-noticeably-longer-rule-name" } chains = append(chains, plan.Chain{ File: scan.File{Rel: name}, Steps: []plan.Step{ {Kind: plan.Move, Rule: rule, Src: filepath.Join(root, name), Dst: filepath.Join(root, "Out", name), Reason: "type txt"}, }, }) matched = append(matched, engine.FileMatch{File: scan.File{Rel: name}}) } dp := &engine.DirPlan{ Dir: &engine.Dir{Name: "dl", Root: root}, Chains: chains, Result: &engine.Result{Matched: matched}, } var buf bytes.Buffer printPlan(&buf, dp, false, palette{}) out := buf.String() for _, want := range []string{ " # file actions rule\n", " 1 f01.txt move → Out/ r type txt\n", " 6 f06.txt move → Out/ a-noticeably-longer-rule-name type txt\n", " 10 f10.txt move → Out/ r type txt\n", " 11 " + long + " move → Out/ r type txt\n", } { if !strings.Contains(out, want) { t.Errorf("output lacks %q:\n%s", want, out) } } } // TestPrintPlanCountsFileOnceWithBothWarningKinds: B2 - a file may carry // both a match warning (Result.Matched[i].Warnings) and a chain warning // (Chain.Warnings, e.g. "moved more than once"). Both must reach the // warnings section, but the counts line's "N warnings" counts files with at // least one warning, not warning lines, so this one file must still count // as 1, not 2. func TestPrintPlanCountsFileOnceWithBothWarningKinds(t *testing.T) { h := home(t) root := filepath.Join(h, "dl") dp := &engine.DirPlan{ Dir: &engine.Dir{Name: "dl", Root: root}, Chains: []plan.Chain{ { File: scan.File{Rel: "a.pdf"}, Steps: []plan.Step{ {Kind: plan.Move, Rule: "r1", Src: filepath.Join(root, "a.pdf"), Dst: filepath.Join(root, "Out", "a.pdf")}, {Kind: plan.Move, Rule: "r2", Src: filepath.Join(root, "Out", "a.pdf"), Dst: filepath.Join(root, "Out2", "a.pdf")}, }, Warnings: []string{"moved more than once; a (stop) is probably missing"}, }, }, Result: &engine.Result{ Matched: []engine.FileMatch{ {File: scan.File{Rel: "a.pdf"}, Warnings: []string{"r1: content unreadable: needs pdftotext, not installed"}}, }, }, } var buf bytes.Buffer printPlan(&buf, dp, false, palette{}) out := buf.String() for _, want := range []string{ "1 scanned · 1 to act on · 1 warnings ·", " a.pdf r1: content unreadable: needs pdftotext, not installed\n", " a.pdf moved more than once; a (stop) is probably missing\n", } { if !strings.Contains(out, want) { t.Errorf("output lacks %q:\n%s", want, out) } } if strings.Contains(out, "2 warnings") { t.Errorf("one file with two warnings must count once, not twice:\n%s", out) } } // TestSkipSummaryLineAccountsForEveryFile pins that the footer's categories // add up to "scanned". The real downloads folder reported "267 scanned · 172 // to act on" while saying nothing about the other 95, which had matched an // exclusion rule carrying no actions: they were neither acted on, nor // unmatched, nor skipped by the walk. func TestSkipSummaryLineAccountsForEveryFile(t *testing.T) { r := &engine.Result{ Matched: make([]engine.FileMatch, 4), Unmatched: make([]engine.FileMatch, 2), Skipped: []scan.Skipped{{Rel: "a.part", Reason: scan.Ignored}, {Rel: "b.iso", Reason: scan.Busy}}, } chains := []plan.Chain{ {Steps: []plan.Step{{Kind: plan.Move, Dst: "/r/W/x"}}}, // acting {}, // excluded: matched an action-less rule {}, // excluded {Steps: []plan.Step{{Kind: plan.Move, Skip: "target exists"}}}, // every step skipped } got := skipSummaryLine(r, chains, false) want := "not acted on: 1 ignored · 1 busy · 2 excluded · 1 all steps skipped · 2 unmatched (-v lists them)" if got != want { t.Errorf("line =\n%q\nwant\n%q", got, want) } scanned := len(r.Matched) + len(r.Unmatched) + len(r.Skipped) excluded, allSkipped := chainOutcomes(chains) if acting := countActing(chains); acting+excluded+allSkipped+len(r.Unmatched)+len(r.Skipped) != scanned { t.Errorf("categories do not sum to scanned: %d acting + %d excluded + %d all-skipped + %d unmatched + %d skipped != %d", acting, excluded, allSkipped, len(r.Unmatched), len(r.Skipped), scanned) } }