diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 01:22:12 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 01:22:12 +0200 |
| commit | 3b36a48b7ce5a53a9366f3b31f94311f178e2553 (patch) | |
| tree | ecbb277ff916b719f2ee45fba017792b85d5faf9 /cmd/krino/matching_test.go | |
| parent | 42b02c47be9b285099203e44a2570636d4ca6f03 (diff) | |
| download | krino-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.go | 374 |
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) + } +} |
