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
|
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"bytes"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"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 the header row, a numbered row, a
// continuation line, the "DELETE permanently" capitalisation and the
// counts line.
//
// 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{})
out := buf.String()
for _, want := range []string{
"6 scanned · 3 to act on · 1 warnings · 0.42s\n",
" # file actions rule\n",
" 1 scan001.pdf move → Work/Acme/2026/ acme content \"acme ltd\"\n",
" 2 fv_123.pdf copy → ~/backup/invoices/2026/ backup content \"invoice\"\n",
" move → Work/Acme/2026/ acme name \\bacme\\b\n",
" 3 setup-1.2.deb DELETE permanently old-pkgs 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 table:\n%s", 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{})
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)
}
}
// TestPrintPlanRowNumberAlignment pins the table layout plan 4's review UI
// inherits: with 11 acted-on files the row-number column has to widen past
// a single digit, and the row number is right-aligned so "#" stays flush.
// The fixture also carries a file name past the 40-character cap and a
// rule name noticeably longer than the rest, exercising the file and rule
// columns' own per-section widths at the same time.
func TestPrintPlanRowNumberAlignment(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{})
out := buf.String()
for _, want := range []string{
" # file actions rule\n",
" 1 f01.txt move → Out/ r type txt\n",
" 6 f06.txt move → Out/ a-noticeably-longer-rule-name type txt\n",
" 10 f10.txt move → Out/ r type txt\n",
" 11 " + long + " move → Out/ r type txt\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{})
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)
}
}
|