// SPDX-License-Identifier: GPL-3.0-or-later package main import ( "bytes" "fmt" "path/filepath" "strings" "testing" "time" "unicode/utf8" "git.labunix.xyz/krino/internal/engine" "git.labunix.xyz/krino/internal/plan" "git.labunix.xyz/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 one block per file (the numbered name, // each step, and each rule's name and reason after its steps), a file // whose steps come from two rules, the "DELETE permanently" // capitalisation and the counts line. Width 0 never wraps. // // 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". 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, // 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{}, 0) out := buf.String() for _, want := range []string{ "6 scanned · 3 to act on · 1 warnings · 0.42s\n", "\n 1 scan001.pdf\n" + " move → Work/Acme/2026/\n" + " rule acme\n" + " because content \"acme ltd\"\n", "\n 2 fv_123.pdf\n" + " copy → ~/backup/invoices/2026/\n" + " rule backup\n" + " because content \"invoice\"\n" + " move → Work/Acme/2026/\n" + " rule acme\n" + " because name \\bacme\\b\n", "\n 3 setup-1.2.deb\n" + " DELETE permanently\n" + " rule old-pkgs\n" + " because 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 plan:\n%s", out) } if strings.Contains(out, "# file") { t.Errorf("the plan is blocks now, with no table header:\n%s", out) } } // TestPrintPlanOmitsBecauseForUnconditionalRule: a rule with no condition // has nothing to say under "because", so the line is left out. func TestPrintPlanOmitsBecauseForUnconditionalRule(t *testing.T) { home(t) dp := &engine.DirPlan{ Dir: &engine.Dir{Name: "dl", Root: "/r"}, Chains: []plan.Chain{{File: scan.File{Rel: "a.iso"}, Steps: []plan.Step{ {Kind: plan.Move, Rule: "to-sort", Src: "/r/a.iso", Dst: "/r/TO_SORT/a.iso", Reason: "no condition"}, }}}, Result: &engine.Result{Matched: []engine.FileMatch{{File: scan.File{Rel: "a.iso"}}}}, } var buf bytes.Buffer printPlan(&buf, dp, false, palette{}, 0) want := "\n 1 a.iso\n move → TO_SORT/\n rule to-sort\n" if out := buf.String(); !strings.Contains(out, want) || strings.Contains(out, "because") { t.Errorf("output lacks %q or still says because:\n%s", want, out) } } // TestPrintPlanWrapsToWidth: given a width, every line fits it, and a long // name or reason continues on lines indented under its own first column, // so reading the pieces back in order gives the whole text. func TestPrintPlanWrapsToWidth(t *testing.T) { home(t) name := "A Rather Long Book Title -- First Author & Second Author -- 2005.pdf" reason := `content "acme ltd" "long street 12" "0000000000" "000000000"` dp := &engine.DirPlan{ Dir: &engine.Dir{Name: "dl", Root: "/r"}, Chains: []plan.Chain{{File: scan.File{Rel: name}, Steps: []plan.Step{ {Kind: plan.Move, Rule: "work-content", Src: "/r/" + name, Dst: "/r/work/Acme_main/accounting_acme/2026_08_acme/" + name, Reason: reason}, }}}, Result: &engine.Result{Matched: []engine.FileMatch{{File: scan.File{Rel: name}}}}, } var buf bytes.Buffer printPlan(&buf, dp, false, palette{}, 40) out := buf.String() lines := strings.Split(out, "\n") for _, l := range lines { if n := utf8.RuneCountInString(l); n > 40 { t.Errorf("line of %d runes exceeds width 40: %q", n, l) } } start := -1 for i, l := range lines { if strings.HasPrefix(l, " 1 ") { start = i break } } if start < 0 { t.Fatalf("no numbered line:\n%s", out) } gotName := strings.TrimPrefix(lines[start], " 1 ") i := start + 1 // Continuation lines start at the value column (13), never at the // label column (5), so a name cannot pass for a step line. for ; i < len(lines) && strings.HasPrefix(lines[i], " ") && !strings.HasPrefix(lines[i], " move"); i++ { gotName += strings.TrimPrefix(lines[i], " ") } if gotName != name { t.Errorf("wrapped name reads %q, want %q\n%s", gotName, name, out) } // The reason's value starts at column 5 + len("because") + 1 = 13, and so // does every continuation line of it. const valueCol = 13 gotReason, inReason := "", false for _, l := range lines[i:] { switch { case strings.HasPrefix(l, " because "): gotReason, inReason = strings.TrimPrefix(l, " because "), true case inReason && len(l) > valueCol && strings.TrimLeft(l[:valueCol], " ") == "" && l[valueCol] != ' ': gotReason += l[valueCol:] default: inReason = false } } if gotReason != reason { t.Errorf("wrapped reason reads %q, want %q\n%s", gotReason, reason, 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{}, 0) 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) } } // TestPrintPlanBlockNumberAlignment: with 11 acted-on files the number // widens past a single digit, numbers are right-aligned, and every block's // body is indented one step further so the labels line up across the whole // plan. A long file name and a long rule name no longer push anything out // of line: each sits on its own line. func TestPrintPlanBlockNumberAlignment(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{}, 0) out := buf.String() for _, want := range []string{ "\n 1 f01.txt\n move → Out/\n rule r\n because type txt\n", "\n 6 f06.txt\n move → Out/\n rule a-noticeably-longer-rule-name\n because type txt\n", "\n 10 f10.txt\n move → Out/\n", "\n 11 " + long + "\n move → Out/\n rule r\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{}, 0) 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) } } // TestWrappedNameCannotFakeAStepLine: a long name's continuation lines start // at the value column, not at the column step labels use, so a name holding // "rename → x" cannot pass for a step of its own block. func TestWrappedNameCannotFakeAStepLine(t *testing.T) { home(t) name := strings.Repeat("a", 30) + " rename → evil.pdf" dp := &engine.DirPlan{ Dir: &engine.Dir{Name: "dl", Root: "/r"}, Chains: []plan.Chain{{File: scan.File{Rel: name}, Steps: []plan.Step{{Kind: plan.Move, Rule: "r", Src: "/r/" + name, Dst: "/r/Out/" + name}}}}, Result: &engine.Result{Matched: []engine.FileMatch{{File: scan.File{Rel: name}}}}, } var buf bytes.Buffer printPlan(&buf, dp, false, palette{}, 40) for _, l := range strings.Split(buf.String(), "\n") { if strings.HasPrefix(l, " ") && len(l) > 5 && l[5] != ' ' && !strings.HasPrefix(l, " move") && !strings.HasPrefix(l, " rule") { t.Errorf("a line at the label column that is not a step: %q\n%s", l, buf.String()) } } } // TestColumnsCountWideAndCombiningCharacters: widths and wrapping count // terminal columns - two for a CJK character, none for a combining mark - // so a name in a wide script neither overruns the terminal nor misaligns // its column. func TestColumnsCountWideAndCombiningCharacters(t *testing.T) { if n := cols("漢字"); n != 4 { t.Errorf("cols(漢字) = %d, want 4", n) } if n := cols("e\u0301x"); n != 2 { t.Errorf("cols(e + combining acute + x) = %d, want 2", n) } // A format character is counted as one column: a terminal may draw it // (a soft hyphen), and counting one too many only wraps a line early, // while one too few lets it run past the edge. if n := cols("a\u00adb"); n != 3 { t.Errorf("cols(a + soft hyphen + b) = %d, want 3", n) } if got := padCell("漢字", 6); got != "漢字 " { t.Errorf("padCell(漢字, 6) = %q, want two spaces of padding", got) } if w := relWidth([]string{"a.txt", "漢字.txt"}); w != 8 { t.Errorf("relWidth = %d, want 8", w) } s := strings.Repeat("漢字", 5) pieces := wrapText(s, 5) if strings.Join(pieces, "") != s { t.Fatalf("wrapText lost text: %q", pieces) } for _, p := range pieces { if cols(p) > 5 { t.Errorf("piece %q is %d columns, over 5", p, cols(p)) } } }