// SPDX-License-Identifier: GPL-3.0-or-later package main import ( "bytes" "os" "path/filepath" "strings" "testing" "time" ) const dlRules = ` (path "~/dl") (recursive yes) (min-age 0s) (ignore "*.part") (rule "dups" (when (duplicate)) (delete) (stop)) (rule "acme" (when (type document) (content "acme ltd")) (move "Work/Acme") (stop)) (rule "images" (when (type image)) (move "Pictures")) (rule "rest" (when (not (matched)) (type text)) (move "Other")) ` // matchingFixture creates ~/dl, a config for it, and an empty PATH, so no // extraction tool exists. func matchingFixture(t *testing.T) string { t.Helper() h := home(t) t.Setenv("PATH", t.TempDir()) dl := filepath.Join(h, "dl") files := map[string]string{ "inv1.txt": "Invoice from ACME LTD", "notes.txt": "shopping list", "photo.jpg": "\xff\xd8 jpeg", "report.pdf": "%PDF same", "report (1).pdf": "%PDF same", "brochure.doc": "\xd0\xcf doc", "movie.mkv": "v", "movie.mkv.part": "p", } old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for n, b := range files { p := filepath.Join(dl, n) os.MkdirAll(filepath.Dir(p), 0o755) os.WriteFile(p, []byte(b), 0o644) os.Chtimes(p, old, old) } os.Chtimes(filepath.Join(dl, "report (1).pdf"), old.Add(time.Hour), old.Add(time.Hour)) if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { t.Fatal(errOut) } if err := os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(dlRules), 0o644); err != nil { t.Fatal(err) } return h } func TestDryRun(t *testing.T) { matchingFixture(t) code, out, errOut := runCLI(t, "-n") if code != 0 { t.Fatalf("exit %d: %s", code, errOut) } for _, want := range []string{ "krino: dl ~/dl\n8 scanned · 4 matched · 2 warnings · ", "\n inv1.txt acme: type txt, content \"acme ltd\"\n", "\n notes.txt rest: not matched, type txt\n", "\n report (1).pdf dups: duplicate of report.pdf\n", "\nwarnings\n brochure.doc acme: content unreadable: needs antiword or catdoc, not installed\n", "\nnot matched: 2 · ignored: 1 · busy: 1 (-v lists them)\n", } { if !strings.Contains(out, want) { t.Errorf("output lacks %q:\n%s", want, out) } } } func TestDryRunVerbose(t *testing.T) { matchingFixture(t) _, out, _ := runCLI(t, "-n", "-v") for _, want := range []string{ "not matched: 2 · ignored: 1 · busy: 1\n", "\nnot matched\n brochure.doc\n report.pdf\n", "\nskipped\n movie.mkv busy\n movie.mkv.part ignored\n", } { if !strings.Contains(out, want) { t.Errorf("verbose output lacks %q:\n%s", want, out) } } } func TestExplainCommand(t *testing.T) { h := matchingFixture(t) code, out, errOut := runCLI(t, "explain", filepath.Join(h, "dl", "inv1.txt")) if code != 0 { t.Fatalf("exit %d: %s", code, errOut) } for _, want := range []string{ "~/dl/inv1.txt (directory dl)\n", "rule dups: no\n no duplicate\n", "rule acme: MATCH\n yes and\n yes type document\n yes content \"acme ltd\"\n", "rule images: not evaluated, stopped by rule acme\n", } { if !strings.Contains(out, want) { t.Errorf("explain output lacks %q:\n%s", want, out) } } _, out, _ = runCLI(t, "explain", "~/dl/movie.mkv") if !strings.Contains(out, "krino would not look at this file: busy\n") { t.Errorf("busy file:\n%s", out) } if code, _, errOut := runCLI(t, "explain"); code != 2 || !strings.Contains(errOut, "usage: krino explain FILE") { t.Errorf("no argument: %d %q", code, errOut) } } func TestSortFlags(t *testing.T) { matchingFixture(t) tests := []struct { args []string want string }{ {[]string{"-y", "-n"}, "-y and -n cannot be used together"}, {nil, "applying files is not implemented yet; use -n to see what would happen"}, {[]string{"-n", "--json"}, "--json is not implemented yet"}, } for _, tt := range tests { if code, _, errOut := runCLI(t, tt.args...); code != 2 || !strings.Contains(errOut, tt.want) { t.Errorf("%v: %d %q, want %q", tt.args, code, errOut, tt.want) } } } func TestCheckListsExtractors(t *testing.T) { h := matchingFixture(t) bin := filepath.Join(h, "bin") os.Mkdir(bin, 0o755) os.WriteFile(filepath.Join(bin, "pdftotext"), []byte("#!/bin/sh\n"), 0o755) t.Setenv("PATH", bin) code, out, errOut := runCLI(t, "check") if code != 0 { t.Fatalf("exit %d: %s", code, errOut) } for _, want := range []string{"log: ~/.local/state/krino/krino.log\n", "\nextractors:\n pdftotext ~/bin/pdftotext\n antiword not installed\n"} { if !strings.Contains(out, want) { t.Errorf("check output lacks %q:\n%s", want, out) } } os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(`(path "~/dl") (rule "x" (when (type "pdf")) (stop))`), 0o644) if code, _, errOut := runCLI(t, "check"); code != 2 || !strings.Contains(errOut, `dl.conf:1:37: type names are bare words: write (type pdf)`) { t.Errorf("condition error: %d %q", code, errOut) } } // TestDryRunWarningsSortedByRel is controller ruling 2026-09-12: the // warnings section is one Rel-sorted list across matched and unmatched // files, not matched files followed by unmatched files - a reader scans it // by name and has no way to see which group a file fell into. "cover.pdf" // matches "pdfs" but still carries the warning "acme" recorded before it // gave up; "brochure.doc" never matches at all. Their Rel order // ("brochure.doc" < "cover.pdf") is the reverse of matched-then-unmatched // grouping, so a grouped rendering fails this. func TestDryRunWarningsSortedByRel(t *testing.T) { h := home(t) t.Setenv("PATH", t.TempDir()) dl := filepath.Join(h, "dl") files := map[string]string{ "brochure.doc": "\xd0\xcf doc", "cover.pdf": "%PDF fake", } old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for n, b := range files { p := filepath.Join(dl, n) os.MkdirAll(filepath.Dir(p), 0o755) os.WriteFile(p, []byte(b), 0o644) os.Chtimes(p, old, old) } if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { t.Fatal(errOut) } conf := ` (path "~/dl") (recursive yes) (min-age 0s) (rule "acme" (when (content "acme ltd")) (move "Work/Acme")) (rule "pdfs" (when (type pdf)) (move "PDFs")) ` if err := os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(conf), 0o644); err != nil { t.Fatal(err) } code, out, errOut := runCLI(t, "-n") if code != 0 { t.Fatalf("exit %d: %s", code, errOut) } want := "\nwarnings\n brochure.doc acme: content unreadable: needs antiword or catdoc, not installed\n cover.pdf acme: content unreadable: needs pdftotext, not installed\n" if !strings.Contains(out, want) { t.Errorf("warnings not Rel-sorted across matched and unmatched:\n%s\nwant substring:\n%s", out, want) } } // TestDirectoryWarningAfterHeader: C3. A directory-level warning must be // emitted after its own header line, not before it, so on a terminal (both // streams sharing one tty, hence stdout and stderr driven into the same // buffer here to observe their relative order) it reads as describing the // directory just named instead of floating above it. func TestDirectoryWarningAfterHeader(t *testing.T) { h := home(t) dl := filepath.Join(h, "dl") if err := os.MkdirAll(dl, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dl, "only.txt"), []byte("hello"), 0o644); err != nil { t.Fatal(err) } if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { t.Fatal(errOut) } conf := ` (path "~/dl") (recursive yes) (min-age 0s) (rule "r" (when (duplicate "~/missing")) (stop)) ` if err := os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(conf), 0o644); err != nil { t.Fatal(err) } var buf bytes.Buffer if code := run([]string{"-n"}, &buf, &buf); code != 0 { t.Fatalf("exit %d: %s", code, buf.String()) } out := buf.String() header := strings.Index(out, "krino: dl ~/dl\n") warning := strings.Index(out, "krino: dl: duplicate: ") if header == -1 || warning == -1 || warning < header { t.Fatalf("directory-level warning not after its header line:\n%s", out) } } // TestSortSkipsMissingRootAndContinues: C5, the exit-1 skip path. A // directory whose root has vanished since it was configured is skipped // with one line on stderr naming it, but every other directory is still // processed, with a blank line still separating their two outputs, and // the run as a whole exits 1. func TestSortSkipsMissingRootAndContinues(t *testing.T) { h := home(t) a, b, gone := filepath.Join(h, "a"), filepath.Join(h, "b"), filepath.Join(h, "gone") for _, p := range []string{a, b, gone} { if err := os.MkdirAll(p, 0o755); err != nil { t.Fatal(err) } } if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } for _, tt := range []struct{ name, path string }{{"a", a}, {"gone", gone}, {"b", b}} { if code, _, errOut := runCLI(t, "new", tt.name, tt.path); code != 0 { t.Fatal(errOut) } } if err := os.RemoveAll(gone); err != nil { t.Fatal(err) } code, out, errOut := runCLI(t, "-n") if code != 1 { t.Fatalf("exit = %d, want 1", code) } if want := "krino: skipping gone: ~/gone is not a directory\n"; errOut != want { t.Fatalf("stderr = %q, want %q", errOut, want) } ai := strings.Index(out, "krino: a ~/a\n") bi := strings.Index(out, "\n\nkrino: b ~/b\n") if ai == -1 || bi == -1 || bi < ai { t.Fatalf("directories a and b not both processed with a blank line between them:\n%s", out) } } // TestDirectoryWarningNotCountedInWarningsField: C5. A directory-level // warning is printed as "krino: NAME: " on stderr, but is not one // of the per-file warnings the "N warnings" field in the summary line // counts. func TestDirectoryWarningNotCountedInWarningsField(t *testing.T) { h := home(t) dl := filepath.Join(h, "dl") if err := os.MkdirAll(dl, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dl, "only.txt"), []byte("hello"), 0o644); err != nil { t.Fatal(err) } if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { t.Fatal(errOut) } conf := ` (path "~/dl") (recursive yes) (min-age 0s) (rule "r" (when (duplicate "~/missing")) (stop)) ` if err := os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(conf), 0o644); err != nil { t.Fatal(err) } code, out, errOut := runCLI(t, "-n") if code != 0 { t.Fatalf("exit %d: %s", code, errOut) } if want := "krino: dl: duplicate: "; !strings.Contains(errOut, want) { t.Fatalf("stderr lacks the directory-level warning %q:\n%s", want, errOut) } if want := "0 warnings"; !strings.Contains(out, want) { t.Fatalf("summary line should not count the directory-level warning:\n%s", out) } } // TestLongNameNotPaddedLayoutIntact: C5, the 40-character cap. A file name // longer than the 40-character column cap is left unpadded (not truncated, // not stretched further), while a short name alongside it is still padded // out to the full 40-column cap — the layout stays a clean two-column grid // even though one row's first cell overruns it. func TestLongNameNotPaddedLayoutIntact(t *testing.T) { h := home(t) dl := filepath.Join(h, "dl") long := strings.Repeat("a", 42) + ".txt" // 46 runes: past the 40-column cap short := "b.txt" old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for _, name := range []string{long, short} { p := filepath.Join(dl, name) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } 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) } } if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { t.Fatal(errOut) } conf := ` (path "~/dl") (min-age 0s) (rule "r" (when (type txt)) (stop)) ` if err := os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(conf), 0o644); err != nil { t.Fatal(err) } code, out, errOut := runCLI(t, "-n") if code != 0 { t.Fatalf("exit %d: %s", code, errOut) } if want := "\n " + long + " r: type txt\n"; !strings.Contains(out, want) { t.Errorf("long name should be unpadded (exactly two trailing spaces before the rule column):\n%s\nwant substring:\n%s", out, want) } if want := "\n " + short + strings.Repeat(" ", 40-len(short)) + " r: type txt\n"; !strings.Contains(out, want) { t.Errorf("short name should still be padded to the 40-column cap:\n%s\nwant substring:\n%s", out, want) } }