aboutsummaryrefslogtreecommitdiff
path: root/cmd/krino/matching_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 01:22:12 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 01:22:12 +0200
commit3b36a48b7ce5a53a9366f3b31f94311f178e2553 (patch)
treeecbb277ff916b719f2ee45fba017792b85d5faf9 /cmd/krino/matching_test.go
parent42b02c47be9b285099203e44a2570636d4ca6f03 (diff)
downloadkrino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.tar.gz
krino-3b36a48b7ce5a53a9366f3b31f94311f178e2553.zip
krino: matching — scan, ignore, conditions, extraction, duplicates, explain, dry run
Diffstat (limited to 'cmd/krino/matching_test.go')
-rw-r--r--cmd/krino/matching_test.go374
1 files changed, 374 insertions, 0 deletions
diff --git a/cmd/krino/matching_test.go b/cmd/krino/matching_test.go
new file mode 100644
index 0000000..eaf9494
--- /dev/null
+++ b/cmd/krino/matching_test.go
@@ -0,0 +1,374 @@
+// 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: <warning>" 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)
+ }
+}