aboutsummaryrefslogtreecommitdiff
path: root/cmd/krino/render_test.go
blob: 9df75f40d129d44184a43b06972bd7ee89338936 (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
375
376
377
// SPDX-License-Identifier: GPL-3.0-or-later

package main

import (
	"bytes"
	"fmt"
	"path/filepath"
	"strings"
	"testing"
	"time"
	"unicode/utf8"

	"krino/internal/engine"
	"krino/internal/plan"
	"krino/internal/scan"
)

// TestPrintPlan is the golden render test of spec §8.2. The DirPlan is
// built by hand, not by running a scan, so the expected output cannot
// drift with a fixture: it asserts one block per file (the numbered name,
// each step, and each rule's name and reason after its steps), a file
// whose steps come from two rules, the "DELETE permanently"
// capitalisation and the counts line. Width 0 never wraps.
//
// The two destinations pin both branches of destText: scan001.pdf's and
// fv_123.pdf's "acme" step lands inside root and renders root-relative
// ("Work/Acme/2026/"), while fv_123.pdf's "backup" step lands under $HOME
// but outside root and stays home-abbreviated ("~/backup/invoices/2026/")
// - spec §8.2's own worked example draws exactly this distinction.
//
// "excluded.txt" matched a rule with no actions (an exclusion, spec §4.5):
// it has zero steps, so it must not appear in the table and must not
// inflate "to act on" (ruling 2026-09-12).
func TestPrintPlan(t *testing.T) {
	h := home(t)
	root := filepath.Join(h, "downloads")

	scan001 := plan.Chain{
		File: scan.File{Rel: "scan001.pdf"},
		Steps: []plan.Step{
			{
				Kind:   plan.Move,
				Rule:   "acme",
				Src:    filepath.Join(root, "scan001.pdf"),
				Dst:    filepath.Join(root, "Work", "Acme", "2026", "scan001.pdf"),
				Reason: `content "acme ltd"`,
			},
		},
	}
	fv123 := plan.Chain{
		File: scan.File{Rel: "fv_123.pdf"},
		Steps: []plan.Step{
			{
				Kind:   plan.Copy,
				Rule:   "backup",
				Src:    filepath.Join(root, "fv_123.pdf"),
				Dst:    filepath.Join(h, "backup", "invoices", "2026", "fv_123.pdf"),
				Reason: `content "invoice"`,
			},
			{
				Kind:   plan.Move,
				Rule:   "acme",
				Src:    filepath.Join(h, "backup", "invoices", "2026", "fv_123.pdf"),
				Dst:    filepath.Join(root, "Work", "Acme", "2026", "fv_123.pdf"),
				Reason: `name \bacme\b`,
			},
		},
	}
	setup := plan.Chain{
		File: scan.File{Rel: "setup-1.2.deb"},
		Steps: []plan.Step{
			{Kind: plan.DeletePermanent, Rule: "old-pkgs", Src: filepath.Join(root, "setup-1.2.deb"), Reason: "age 94d"},
		},
	}
	excluded := plan.Chain{File: scan.File{Rel: "excluded.txt"}} // matched a stop-only rule: no actions, no steps

	dp := &engine.DirPlan{
		Dir:     &engine.Dir{Name: "downloads", Root: root},
		Chains:  []plan.Chain{scan001, fv123, setup, excluded},
		Elapsed: 420 * time.Millisecond, // D12: the counts line renders DirPlan.Elapsed (Match plus Build), not Result.Elapsed alone
		Result: &engine.Result{
			Matched: []engine.FileMatch{
				{File: scan.File{Rel: "scan001.pdf"}, Warnings: []string{"acme: content unreadable: needs pdftotext, not installed"}},
				{File: scan.File{Rel: "fv_123.pdf"}},
				{File: scan.File{Rel: "setup-1.2.deb"}},
				{File: scan.File{Rel: "excluded.txt"}},
			},
			Unmatched: []engine.FileMatch{{File: scan.File{Rel: "unmatched.txt"}}},
			Skipped:   []scan.Skipped{{Rel: "busy.tmp", Reason: scan.Busy}},
		},
	}

	var buf bytes.Buffer
	printPlan(&buf, dp, false, palette{}, 0)
	out := buf.String()

	for _, want := range []string{
		"6 scanned · 3 to act on · 1 warnings · 0.42s\n",
		"\n  1  scan001.pdf\n" +
			"     move    → Work/Acme/2026/\n" +
			"     rule    acme\n" +
			"     because content \"acme ltd\"\n",
		"\n  2  fv_123.pdf\n" +
			"     copy    → ~/backup/invoices/2026/\n" +
			"     rule    backup\n" +
			"     because content \"invoice\"\n" +
			"     move    → Work/Acme/2026/\n" +
			"     rule    acme\n" +
			"     because name \\bacme\\b\n",
		"\n  3  setup-1.2.deb\n" +
			"     DELETE permanently\n" +
			"     rule    old-pkgs\n" +
			"     because age 94d\n",
		"warnings\n  scan001.pdf  acme: content unreadable: needs pdftotext, not installed\n",
		"not acted on: 1 busy · 1 excluded · 1 unmatched   (-v lists them)\n",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("output lacks %q:\n%s", want, out)
		}
	}
	if strings.Contains(out, "excluded.txt") {
		t.Errorf("excluded.txt has no steps and must not appear in the plan:\n%s", out)
	}
	if strings.Contains(out, "#  file") {
		t.Errorf("the plan is blocks now, with no table header:\n%s", out)
	}
}

// TestPrintPlanOmitsBecauseForUnconditionalRule: a rule with no condition
// has nothing to say under "because", so the line is left out.
func TestPrintPlanOmitsBecauseForUnconditionalRule(t *testing.T) {
	home(t)
	dp := &engine.DirPlan{
		Dir: &engine.Dir{Name: "dl", Root: "/r"},
		Chains: []plan.Chain{{File: scan.File{Rel: "a.iso"}, Steps: []plan.Step{
			{Kind: plan.Move, Rule: "to-sort", Src: "/r/a.iso", Dst: "/r/TO_SORT/a.iso", Reason: "no condition"},
		}}},
		Result: &engine.Result{Matched: []engine.FileMatch{{File: scan.File{Rel: "a.iso"}}}},
	}
	var buf bytes.Buffer
	printPlan(&buf, dp, false, palette{}, 0)
	want := "\n  1  a.iso\n     move    → TO_SORT/\n     rule    to-sort\n"
	if out := buf.String(); !strings.Contains(out, want) || strings.Contains(out, "because") {
		t.Errorf("output lacks %q or still says because:\n%s", want, out)
	}
}

// TestPrintPlanWrapsToWidth: given a width, every line fits it, and a long
// name or reason continues on lines indented under its own first column,
// so reading the pieces back in order gives the whole text.
func TestPrintPlanWrapsToWidth(t *testing.T) {
	home(t)
	name := "A Rather Long Book Title -- First Author & Second Author -- 2005.pdf"
	reason := `content "acme ltd" "long street 12" "0000000000" "000000000"`
	dp := &engine.DirPlan{
		Dir: &engine.Dir{Name: "dl", Root: "/r"},
		Chains: []plan.Chain{{File: scan.File{Rel: name}, Steps: []plan.Step{
			{Kind: plan.Move, Rule: "work-content", Src: "/r/" + name, Dst: "/r/work/Acme_main/accounting_acme/2026_08_acme/" + name, Reason: reason},
		}}},
		Result: &engine.Result{Matched: []engine.FileMatch{{File: scan.File{Rel: name}}}},
	}
	var buf bytes.Buffer
	printPlan(&buf, dp, false, palette{}, 40)
	out := buf.String()
	lines := strings.Split(out, "\n")
	for _, l := range lines {
		if n := utf8.RuneCountInString(l); n > 40 {
			t.Errorf("line of %d runes exceeds width 40: %q", n, l)
		}
	}

	start := -1
	for i, l := range lines {
		if strings.HasPrefix(l, "  1  ") {
			start = i
			break
		}
	}
	if start < 0 {
		t.Fatalf("no numbered line:\n%s", out)
	}
	gotName := strings.TrimPrefix(lines[start], "  1  ")
	i := start + 1
	for ; i < len(lines) && strings.HasPrefix(lines[i], "     ") && !strings.HasPrefix(lines[i], "     move"); i++ {
		gotName += strings.TrimPrefix(lines[i], "     ")
	}
	if gotName != name {
		t.Errorf("wrapped name reads %q, want %q\n%s", gotName, name, out)
	}

	// The reason's value starts at column 5 + len("because") + 1 = 13, and so
	// does every continuation line of it.
	const valueCol = 13
	gotReason, inReason := "", false
	for _, l := range lines[i:] {
		switch {
		case strings.HasPrefix(l, "     because "):
			gotReason, inReason = strings.TrimPrefix(l, "     because "), true
		case inReason && len(l) > valueCol && strings.TrimLeft(l[:valueCol], " ") == "" && l[valueCol] != ' ':
			gotReason += l[valueCol:]
		default:
			inReason = false
		}
	}
	if gotReason != reason {
		t.Errorf("wrapped reason reads %q, want %q\n%s", gotReason, reason, out)
	}
}

// TestPrintPlanSkippedStep: a step with Skip set shows its reason in place
// of the destination, and a Displaces step notes that it replaces the
// existing file.
func TestPrintPlanSkippedStep(t *testing.T) {
	home(t) // isolate HOME even though these paths do not use it
	dp := &engine.DirPlan{
		Dir: &engine.Dir{Name: "dl", Root: "/r"},
		Chains: []plan.Chain{
			{
				File: scan.File{Rel: "a.pdf"},
				Steps: []plan.Step{
					{Kind: plan.Copy, Rule: "backup", Src: "/r/a.pdf", Dst: "/backup/a.pdf", Skip: "target exists"},
				},
			},
			{
				File: scan.File{Rel: "b.pdf"},
				Steps: []plan.Step{
					{Kind: plan.Move, Rule: "acme", Src: "/r/b.pdf", Dst: "/r/Work/b.pdf", Displaces: "/r/Work/b.pdf"},
				},
			},
		},
		Result: &engine.Result{
			Matched: []engine.FileMatch{{File: scan.File{Rel: "a.pdf"}}, {File: scan.File{Rel: "b.pdf"}}},
		},
	}
	var buf bytes.Buffer
	printPlan(&buf, dp, false, palette{}, 0)
	out := buf.String()
	if !strings.Contains(out, "copy    skipped: target exists") {
		t.Errorf("skipped step should show its reason in place of the destination:\n%s", out)
	}
	if !strings.Contains(out, "move    → Work/ (replaces the existing file)") {
		t.Errorf("a Displaces step should note it replaces the existing file:\n%s", out)
	}
	if !strings.Contains(out, "2 scanned · 1 to act on · 0 warnings ·") {
		t.Errorf("a.pdf's only step is skipped, so it must not count as \"to act on\":\n%s", out)
	}
}

// TestPrintPlanBlockNumberAlignment: with 11 acted-on files the number
// widens past a single digit, numbers are right-aligned, and every block's
// body is indented one step further so the labels line up across the whole
// plan. A long file name and a long rule name no longer push anything out
// of line: each sits on its own line.
func TestPrintPlanBlockNumberAlignment(t *testing.T) {
	h := home(t)
	root := filepath.Join(h, "dl")
	long := strings.Repeat("z", 42) + ".txt" // 46 runes: past the 40-column cap

	names := make([]string, 11)
	for i := range names {
		names[i] = fmt.Sprintf("f%02d.txt", i+1)
	}
	names[10] = long // row 11 carries the long name

	var chains []plan.Chain
	var matched []engine.FileMatch
	for i, name := range names {
		rule := "r"
		if i == 5 {
			rule = "a-noticeably-longer-rule-name"
		}
		chains = append(chains, plan.Chain{
			File: scan.File{Rel: name},
			Steps: []plan.Step{
				{Kind: plan.Move, Rule: rule, Src: filepath.Join(root, name), Dst: filepath.Join(root, "Out", name), Reason: "type txt"},
			},
		})
		matched = append(matched, engine.FileMatch{File: scan.File{Rel: name}})
	}

	dp := &engine.DirPlan{
		Dir:    &engine.Dir{Name: "dl", Root: root},
		Chains: chains,
		Result: &engine.Result{Matched: matched},
	}

	var buf bytes.Buffer
	printPlan(&buf, dp, false, palette{}, 0)
	out := buf.String()

	for _, want := range []string{
		"\n   1  f01.txt\n      move    → Out/\n      rule    r\n      because type txt\n",
		"\n   6  f06.txt\n      move    → Out/\n      rule    a-noticeably-longer-rule-name\n      because type txt\n",
		"\n  10  f10.txt\n      move    → Out/\n",
		"\n  11  " + long + "\n      move    → Out/\n      rule    r\n",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("output lacks %q:\n%s", want, out)
		}
	}
}

// TestPrintPlanCountsFileOnceWithBothWarningKinds: B2 - a file may carry
// both a match warning (Result.Matched[i].Warnings) and a chain warning
// (Chain.Warnings, e.g. "moved more than once"). Both must reach the
// warnings section, but the counts line's "N warnings" counts files with at
// least one warning, not warning lines, so this one file must still count
// as 1, not 2.
func TestPrintPlanCountsFileOnceWithBothWarningKinds(t *testing.T) {
	h := home(t)
	root := filepath.Join(h, "dl")
	dp := &engine.DirPlan{
		Dir: &engine.Dir{Name: "dl", Root: root},
		Chains: []plan.Chain{
			{
				File: scan.File{Rel: "a.pdf"},
				Steps: []plan.Step{
					{Kind: plan.Move, Rule: "r1", Src: filepath.Join(root, "a.pdf"), Dst: filepath.Join(root, "Out", "a.pdf")},
					{Kind: plan.Move, Rule: "r2", Src: filepath.Join(root, "Out", "a.pdf"), Dst: filepath.Join(root, "Out2", "a.pdf")},
				},
				Warnings: []string{"moved more than once; a (stop) is probably missing"},
			},
		},
		Result: &engine.Result{
			Matched: []engine.FileMatch{
				{File: scan.File{Rel: "a.pdf"}, Warnings: []string{"r1: content unreadable: needs pdftotext, not installed"}},
			},
		},
	}
	var buf bytes.Buffer
	printPlan(&buf, dp, false, palette{}, 0)
	out := buf.String()

	for _, want := range []string{
		"1 scanned · 1 to act on · 1 warnings ·",
		"  a.pdf  r1: content unreadable: needs pdftotext, not installed\n",
		"  a.pdf  moved more than once; a (stop) is probably missing\n",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("output lacks %q:\n%s", want, out)
		}
	}
	if strings.Contains(out, "2 warnings") {
		t.Errorf("one file with two warnings must count once, not twice:\n%s", out)
	}
}

// TestSkipSummaryLineAccountsForEveryFile pins that the footer's categories
// add up to "scanned". The real downloads folder reported "267 scanned · 172
// to act on" while saying nothing about the other 95, which had matched an
// exclusion rule carrying no actions: they were neither acted on, nor
// unmatched, nor skipped by the walk.
func TestSkipSummaryLineAccountsForEveryFile(t *testing.T) {
	r := &engine.Result{
		Matched:   make([]engine.FileMatch, 4),
		Unmatched: make([]engine.FileMatch, 2),
		Skipped:   []scan.Skipped{{Rel: "a.part", Reason: scan.Ignored}, {Rel: "b.iso", Reason: scan.Busy}},
	}
	chains := []plan.Chain{
		{Steps: []plan.Step{{Kind: plan.Move, Dst: "/r/W/x"}}}, // acting
		{}, // excluded: matched an action-less rule
		{}, // excluded
		{Steps: []plan.Step{{Kind: plan.Move, Skip: "target exists"}}}, // every step skipped
	}
	got := skipSummaryLine(r, chains, false)
	want := "not acted on: 1 ignored · 1 busy · 2 excluded · 1 all steps skipped · 2 unmatched   (-v lists them)"
	if got != want {
		t.Errorf("line =\n%q\nwant\n%q", got, want)
	}
	scanned := len(r.Matched) + len(r.Unmatched) + len(r.Skipped)
	excluded, allSkipped := chainOutcomes(chains)
	if acting := countActing(chains); acting+excluded+allSkipped+len(r.Unmatched)+len(r.Skipped) != scanned {
		t.Errorf("categories do not sum to scanned: %d acting + %d excluded + %d all-skipped + %d unmatched + %d skipped != %d",
			acting, excluded, allSkipped, len(r.Unmatched), len(r.Skipped), scanned)
	}
}