aboutsummaryrefslogtreecommitdiff
path: root/cmd/krino/matching_test.go
blob: eaf94944699fd70c4944acced28f9e4aa665771e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
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)
	}
}