// SPDX-License-Identifier: GPL-3.0-or-later package main import ( "bytes" "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "git.labunix.xyz/krino/internal/lock" ) const dlRules = ` (path "~/dl") (recursive yes) (min-age 0s) (ignore "*.part") (rule "dups" (when (duplicate)) (move "Dupes") (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 to act on · 2 warnings · ", "\n 1 inv1.txt\n move → Work/Acme/\n rule acme\n because type txt, content \"acme ltd\"\n", "\n 2 notes.txt\n move → Other/\n rule rest\n because not matched, type txt\n", "\n 4 report (1).pdf\n move → Dupes/\n rule dups\n because duplicate of report.pdf\n", "\nwarnings\n brochure.doc acme: content unreadable: needs antiword or catdoc, not installed\n", "\nnot acted on: 1 ignored · 1 busy · 2 unmatched (-v lists them)\n", } { if !strings.Contains(out, want) { t.Errorf("output lacks %q:\n%s", want, out) } } } // TestDryRunJSON is the --json counterpart of TestDryRun: with -n it now // prints the plan instead of refusing, and the document parses. func TestDryRunJSON(t *testing.T) { matchingFixture(t) code, out, errOut := runCLI(t, "-n", "--json") if code != 0 { t.Fatalf("exit %d: %s", code, errOut) } for _, want := range []string{`"action": "move"`, `"rel": "inv1.txt"`} { if !strings.Contains(out, want) { t.Errorf("json output lacks %s:\n%s", want, out) } } var doc struct { Version int `json:"version"` Dirs []struct { Name string `json:"name"` } `json:"dirs"` } if err := json.Unmarshal([]byte(out), &doc); err != nil { t.Fatalf("output does not parse as JSON: %v\n%s", err, out) } if doc.Version != 1 || len(doc.Dirs) != 1 || doc.Dirs[0].Name != "dl" { t.Errorf("document = %+v", doc) } } func TestDryRunVerbose(t *testing.T) { matchingFixture(t) _, out, _ := runCLI(t, "-n", "-v") for _, want := range []string{ "not acted on: 1 ignored · 1 busy · 2 unmatched\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, "not a terminal"}, // no -y, no -n, and the test's stdin is not one {[]string{"--json"}, "--json is only valid with -n"}, // --json without -n stays an error } 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: 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: 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: 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: 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: 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. // TestApplyWithYesMovesFiles is the basic apply-path test: -y applies the // plan with no prompt, the file actually moves, the outcome line says so, // and the run is logged with both boundaries. func TestApplyWithYesMovesFiles(t *testing.T) { h := matchingFixture(t) code, out, errOut := runCLI(t, "-y") if code != 0 { t.Fatalf("exit %d: %s", code, errOut) } if _, err := os.Stat(filepath.Join(h, "dl", "Work", "Acme", "inv1.txt")); err != nil { t.Errorf("inv1.txt was not filed: %v", err) } if !strings.Contains(out, "applied") { t.Errorf("no outcome line:\n%s", out) } log := filepath.Join(h, ".local", "state", "krino", "krino.log") b, err := os.ReadFile(log) if err != nil { t.Fatalf("nothing was logged: %v", err) } if !strings.Contains(string(b), "run-start") || !strings.Contains(string(b), "run-end") { t.Errorf("log lacks run boundaries:\n%s", b) } } // TestDryRunLogsNothing: journal.Open must never be called at all in // dry-run mode, since it materialises both the state directory and an // empty log file as a side effect of merely opening it. func TestDryRunLogsNothing(t *testing.T) { h := matchingFixture(t) if code, _, errOut := runCLI(t, "-n"); code != 0 { t.Fatalf("exit %d: %s", code, errOut) } if _, err := os.Stat(filepath.Join(h, ".local", "state", "krino", "krino.log")); !os.IsNotExist(err) { t.Error("a dry run wrote to the log") } } // TestRefusesWithoutTerminalAndWithoutFlags is spec §8.4: with neither -y // nor -n, and stdin not a terminal (as in every test), krino refuses rather // than guess. func TestRefusesWithoutTerminalAndWithoutFlags(t *testing.T) { matchingFixture(t) code, _, errOut := runCLI(t) if code != 2 || !strings.Contains(errOut, "not a terminal") { t.Errorf("no-flags, no-terminal: %d %q", code, errOut) } } // TestSecondRunFailsImmediatelyWithYes is spec §11: a second krino on the // same directory fails immediately with -y (wait = false) rather than // piling up behind a stuck run, so a cron job never queues silently. func TestSecondRunFailsImmediatelyWithYes(t *testing.T) { h := matchingFixture(t) held := filepath.Join(h, ".local", "state", "krino", "dl.lock") // A lock really held: it is the kernel's, so a file that merely looks // like one holds nothing. l, err := lock.Acquire(context.Background(), held, false) if err != nil { t.Fatal(err) } defer l.Release() code, _, errOut := runCLI(t, "-y") if code != 1 || !strings.Contains(errOut, "another krino") { t.Errorf("-y against a held lock: %d %q", code, errOut) } } // TestFlagsMustPrecedeDirectoryNames: Go's flag package stops parsing at // the first non-flag argument, so "krino dl -n" would otherwise silently // take "-n" as a second directory name and never honour the dry run. A // leftover argument starting with "-" is a usage error instead of a guess. func TestFlagsMustPrecedeDirectoryNames(t *testing.T) { home(t) code, _, errOut := runCLI(t, "dl", "-n") if code != 2 || !strings.Contains(errOut, "-n") { t.Errorf("krino dl -n: %d %q", code, errOut) } } // TestNoColourEscapeToNonTerminal pins one guarantee: a plan piped to a // file or read by another tool must be plain text. tui.Colour(w) // already returns false for anything that is not a terminal *os.File, and // runCLI's stdout is a bytes.Buffer, so this holds end to end through the // real command path, not just at the helper that decides it. func TestNoColourEscapeToNonTerminal(t *testing.T) { matchingFixture(t) _, out, _ := runCLI(t, "-n") if strings.ContainsRune(out, '\x1b') { t.Errorf("escape byte reached a non-terminal writer:\n%q", out) } } // TestLongNameGetsItsOwnLine: a long file name sits on its own numbered // line, and the block below it is laid out exactly as a short name's is. // Output to a non-terminal is never wrapped. func TestLongNameGetsItsOwnLine(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)) (move "Other")) ` 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) } body := " move → Other/\n rule r\n because type txt\n" if want := "\n 1 " + long + "\n" + body; !strings.Contains(out, want) { t.Errorf("long name block:\n%s\nwant substring:\n%s", out, want) } if want := "\n 2 " + short + "\n" + body; !strings.Contains(out, want) { t.Errorf("short name block:\n%s\nwant substring:\n%s", out, want) } } // TestDryRunJSONCarriesExclusionsAndWarnings: the JSON plan says which // exclude set a file aside and carries the warnings matching raised, for // matched and unmatched files alike, as the text plan does. func TestDryRunJSONCarriesExclusionsAndWarnings(t *testing.T) { h := home(t) dl := filepath.Join(h, "dl") os.MkdirAll(dl, 0o755) old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for name, body := range map[string]string{"keep.pdf": "x", "big.txt": strings.Repeat("y", 4096)} { p := filepath.Join(dl, name) os.WriteFile(p, []byte(body), 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) } rules := "(path \"~/dl\")\n(max-read 1K)\n(exclude (name \"^keep\"))\n(rule \"acme\" (when (content \"acme\")) (move \"Acme\"))\n" os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644) code, out, errOut := runCLI(t, "-n", "--json") if code != 0 { t.Fatalf("exit %d: %s", code, errOut) } var doc struct { Dirs []struct { Files []struct { Rel string `json:"rel"` Excluded string `json:"excluded"` } `json:"files"` Unmatched []struct { Rel string `json:"rel"` Warnings []string `json:"warnings"` } `json:"unmatched"` } `json:"dirs"` } if err := json.Unmarshal([]byte(out), &doc); err != nil { t.Fatalf("%v\n%s", err, out) } d := doc.Dirs[0] if len(d.Files) != 1 || d.Files[0].Rel != "keep.pdf" || d.Files[0].Excluded != `(exclude (name "^keep"))` { t.Errorf("files = %+v; want keep.pdf excluded by its form\n%s", d.Files, out) } if len(d.Unmatched) != 1 || d.Unmatched[0].Rel != "big.txt" || len(d.Unmatched[0].Warnings) == 0 || !strings.Contains(d.Unmatched[0].Warnings[0], "content unreadable") { t.Errorf("unmatched = %+v; want big.txt with its content warning\n%s", d.Unmatched, out) } }