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
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
|
// 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: <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: 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)
}
}
|