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
|
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"krino/internal/plan"
"krino/internal/scan"
)
// TestRelWidthAndPadCellCountRunes: C4. relWidth and padCell must measure
// column width in runes, not bytes, or a name carrying diacritics
// misaligns its column - "próba.txt" is 9 runes but 10 bytes (ó is a
// two-byte UTF-8 sequence), one column narrower than its byte length
// would suggest.
func TestRelWidthAndPadCellCountRunes(t *testing.T) {
rels := []string{"a.txt", "próba.txt"}
if w := relWidth(rels); w != 9 {
t.Fatalf("relWidth(%q) = %d, want 9 (rune count of próba.txt, not its %d bytes)", rels, w, len("próba.txt"))
}
if got, want := padCell("a.txt", 9), "a.txt "; got != want {
t.Fatalf("padCell(%q, 9) = %q, want %q", "a.txt", got, want)
}
if got, want := padCell("próba.txt", 9), "próba.txt"; got != want {
t.Fatalf("padCell(%q, 9) = %q, want %q (already at width: no padding)", "próba.txt", got, want)
}
}
// TestActionableChainsAgreesWithCountActing is fix wave item 4 / Minor 5:
// countActing (render.go) and actionableChains used to disagree over a
// chain every one of whose steps is skipped (len(Steps) > 0, but every
// step's own Skip is set) - countActing already excluded it from "to act
// on", while actionableChains's own len(Steps) > 0 check still offered it
// for approval, so a directory could print "N scanned · 0 to act on" and
// then still ask the user to approve a file it had just said there were
// none of. Converged on chainActing (render.go), both must now agree.
func TestActionableChainsAgreesWithCountActing(t *testing.T) {
chains := []plan.Chain{
{File: scan.File{Rel: "a.txt"}, Steps: []plan.Step{{Kind: plan.Move, Skip: "target exists"}}},
{File: scan.File{Rel: "b.txt"}, Steps: []plan.Step{{Kind: plan.Move, Dst: "/r/W/b.txt"}}},
}
if got := countActing(chains); got != 1 {
t.Errorf("countActing = %d, want 1 (a.txt is all-skipped)", got)
}
actionable := actionableChains(chains)
if len(actionable) != 1 || actionable[0].File.Rel != "b.txt" {
t.Errorf("actionableChains = %+v, want only b.txt - an all-skipped chain must never be offered for approval", actionable)
}
}
// TestAllSkippedDirectoryReportsZeroAndLogsNothing is fix wave item 4 /
// Minor 5 and 6, end to end. Before the fix: a directory whose one file
// matches a rule under (on-conflict skip) - so its single step's own Skip
// is set ("target exists") - printed "0 to act on" (countActing) and then,
// with -y, still ran that chain through Apply anyway (actionableChains'
// own len(Steps) > 0 check approved it regardless), logging a
// run-start/run-end pair holding only a "skipped" entry while the outcome
// line read "0 applied · 0 failed · 0 declined" for a file that had just
// been silently processed. After the fix, the chain is never offered for
// approval, Apply is never even called for this directory, and the journal
// gains nothing at all.
func TestAllSkippedDirectoryReportsZeroAndLogsNothing(t *testing.T) {
h := home(t)
dl := filepath.Join(h, "dl")
if err := os.MkdirAll(filepath.Join(dl, "Out"), 0o755); err != nil {
t.Fatal(err)
}
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for _, p := range []string{filepath.Join(dl, "a.txt"), filepath.Join(dl, "Out", "a.txt")} {
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)
}
rules := "(path \"~/dl\")\n(min-age 0s)\n(on-conflict skip)\n(rule \"r\" (when (type text)) (move \"Out\"))\n"
if err := os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644); err != nil {
t.Fatal(err)
}
code, out, errOut := runCLI(t, "-y")
if code != 0 {
t.Fatalf("run: %d %s", code, errOut)
}
if !strings.Contains(out, "1 scanned · 0 to act on") {
t.Errorf("output = %q, want \"0 to act on\"", out)
}
if !strings.Contains(out, zeroOutcome) {
t.Errorf("output = %q, want the honest zero outcome %q", out, zeroOutcome)
}
logPath := filepath.Join(h, ".local", "state", "krino", "krino.log")
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("reading the journal: %v", err)
}
if len(data) != 0 {
t.Errorf("journal gained entries for a directory with nothing to act on:\n%s", data)
}
}
// TestDuplicatesUnderOverlappingDirMoveOnlyTheLooseCopy drives the whole run
// end to end on the shape where a (duplicate "DIR") overlaps the scanned
// tree: a recursive root holding Archive/x.pdf and a loose, older
// x-copy.pdf with the same bytes, and a rule that moves duplicates of
// anything under Archive aside. Every lookup must elect the same original,
// so the archived copy stays where it is and only the loose one is moved;
// an inconsistent election would move both.
func TestDuplicatesUnderOverlappingDirMoveOnlyTheLooseCopy(t *testing.T) {
h := home(t)
dl := filepath.Join(h, "dl")
content := []byte("%PDF acme statement")
archived := filepath.Join(dl, "Archive", "x.pdf")
loose := filepath.Join(dl, "x-copy.pdf")
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for p, mt := range map[string]time.Time{archived: old.Add(time.Hour), loose: old} {
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, content, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(p, mt, mt); 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)
}
rules := "(path \"~/dl\")\n(recursive yes)\n(min-age 0s)\n" +
"(rule \"dups\" (when (duplicate \"Archive\")) (move \"~/dupes\") (stop))\n"
if err := os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644); err != nil {
t.Fatal(err)
}
code, out, errOut := runCLI(t, "-y")
if code != 0 {
t.Fatalf("run: exit %d\nstdout:\n%s\nstderr:\n%s", code, out, errOut)
}
moved := filepath.Join(h, "dupes", "x-copy.pdf")
for _, p := range []string{archived, moved} {
b, err := os.ReadFile(p)
if err != nil || string(b) != string(content) {
t.Errorf("%s: want the content there, got err %v", p, err)
}
}
if _, err := os.Stat(loose); !os.IsNotExist(err) {
t.Errorf("%s is still in place; want it moved to %s", loose, moved)
}
}
// TestCheckRefusesDuplicateWithDelete: krino check reports the §4.5
// refusal on stderr and fails, before any run could act on the rule.
func TestCheckRefusesDuplicateWithDelete(t *testing.T) {
h := home(t)
dl := filepath.Join(h, "dl")
if err := os.MkdirAll(dl, 0o755); 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)
}
rules := "(path \"~/dl\")\n(rule \"d\" (when (duplicate)) (delete))\n"
if err := os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644); err != nil {
t.Fatal(err)
}
code, _, errOut := runCLI(t, "check")
want := `rule "d": (duplicate) cannot be combined with (delete)`
if code == 0 || !strings.Contains(errOut, want) {
t.Errorf("check: exit %d, stderr %q; want non-zero and %q", code, errOut, want)
}
}
// TestDryRunShowsNeverDeletedSkip pins the plan line spec §5.5 names.
func TestDryRunShowsNeverDeletedSkip(t *testing.T) {
h := home(t)
dl := filepath.Join(h, "dl")
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for name, mt := range map[string]time.Time{"a.pdf": old, "b.pdf": old.Add(time.Hour)} {
p := filepath.Join(dl, name)
if err := os.MkdirAll(dl, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("%PDF same"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(p, mt, mt); 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)
}
rules := "(path \"~/dl\")\n(min-age 0s)\n" +
"(rule \"dupes\" (when (duplicate)) (move \"Dupes\"))\n" +
"(rule \"cleanup\" (when (matched)) (delete))\n"
if err := os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644); err != nil {
t.Fatal(err)
}
code, out, errOut := runCLI(t, "-n")
if code != 0 {
t.Fatalf("exit %d: %s", code, errOut)
}
if !strings.Contains(out, "skipped: a duplicate is never deleted") {
t.Errorf("plan lacks the skipped delete:\n%s", out)
}
// explain shows it too, for the copy that is the duplicate (triage 30a).
_, outA, _ := runCLI(t, "explain", filepath.Join(dl, "a.pdf"))
_, outB, _ := runCLI(t, "explain", filepath.Join(dl, "b.pdf"))
if strings.Count(outA+outB, "a duplicate is never deleted") != 1 {
t.Errorf("explain should name the skipped delete for exactly one copy:\n%s\n%s", outA, outB)
}
}
// TestLaterDirectoryIsNotBlockedByAnEarlierOnesClaims: in a real run each
// directory is applied before the next is planned, so the disk is the
// truth; a path an earlier directory moved a file away from, or planned and
// did not apply, is not "taken" for a later one (triage 28i).
func TestLaterDirectoryIsNotBlockedByAnEarlierOnesClaims(t *testing.T) {
h := home(t)
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for _, p := range []string{"ca/x.txt", "cb/x.txt"} {
full := filepath.Join(h, p)
os.MkdirAll(filepath.Dir(full), 0o755)
os.WriteFile(full, []byte(p), 0o644)
os.Chtimes(full, old, old)
}
if code, _, errOut := runCLI(t, "init"); code != 0 {
t.Fatal(errOut)
}
for _, n := range []string{"ca", "cb"} {
if code, _, errOut := runCLI(t, "new", n, filepath.Join(h, n)); code != 0 {
t.Fatal(errOut)
}
}
dirs := filepath.Join(h, ".config", "krino", "dirs")
os.WriteFile(filepath.Join(dirs, "ca.conf"), []byte("(path \"~/ca\")\n(rule \"away\" (move \"~/elsewhere\"))\n"), 0o644)
os.WriteFile(filepath.Join(dirs, "cb.conf"), []byte("(path \"~/cb\")\n(on-conflict skip)\n(rule \"in\" (move \"~/ca\"))\n"), 0o644)
code, out, errOut := runCLI(t, "-y", "ca", "cb")
if code != 0 {
t.Fatalf("exit %d\n%s\n%s", code, out, errOut)
}
if b, err := os.ReadFile(filepath.Join(h, "ca", "x.txt")); err != nil || string(b) != "cb/x.txt" {
t.Errorf("cb's x.txt did not move into the place ca's left free: %q, %v\n%s", b, err, out)
}
}
// devFullFixture builds two directories, d1 and d2, each with two files a
// rule moves, and a log at /dev/full, where every write fails: applying
// anything is a genuine apply error, not a step failure.
func devFullFixture(t *testing.T) {
t.Helper()
if _, err := os.Stat("/dev/full"); err != nil {
t.Skip("no /dev/full on this system")
}
h := home(t)
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
if code, _, errOut := runCLI(t, "init"); code != 0 {
t.Fatal(errOut)
}
for _, n := range []string{"d1", "d2"} {
for _, name := range []string{"a.pdf", "b.pdf"} {
p := filepath.Join(h, n, name)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(n+name), 0o644)
os.Chtimes(p, old, old)
}
if code, _, errOut := runCLI(t, "new", n, filepath.Join(h, n)); code != 0 {
t.Fatal(errOut)
}
os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", n+".conf"), []byte("(path \"~/"+n+"\")\n(rule \"r\" (move \"Out\"))\n"), 0o644)
}
f, err := os.OpenFile(filepath.Join(h, ".config", "krino", "krino.conf"), os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
f.WriteString("(log \"/dev/full\")\n")
f.Close()
}
// TestApplyErrorExitsOne: an error applying a directory - here the log
// cannot be written - makes krino exit 1 (triage 34m: removing that exit
// code left every test passing).
func TestApplyErrorExitsOne(t *testing.T) {
devFullFixture(t)
code, _, errOut := runCLI(t, "-y", "d1")
if code != 1 || !strings.Contains(errOut, "no space left") {
t.Errorf("exit %d, stderr %q; want 1 and the write error", code, errOut)
}
}
// TestWriteStopsKrinoWhenApplyFails: [w] in review stops krino after its
// directory even when applying it fails - the next directory is not planned
// or asked about (review cli F3), driven through the real command with a
// pipe standing in for the terminal (triage 28m).
func TestWriteStopsKrinoWhenApplyFails(t *testing.T) {
devFullFixture(t)
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
w.WriteString("cyw") // choose per file, yes to a.pdf, write before b.pdf
w.Close()
oldStdin, oldTerm := stdin, stdinIsTerminal
stdin, stdinIsTerminal = r, func() bool { return true }
t.Cleanup(func() { stdin, stdinIsTerminal = oldStdin, oldTerm })
code, out, errOut := runCLI(t, "--no-pager")
if code != 1 {
t.Errorf("exit %d, want 1\n%s\n%s", code, out, errOut)
}
if strings.Contains(out, "krino: d2") {
t.Errorf("d2 was planned after [w]:\n%s", out)
}
}
// TestLaterDirectoryNeverOverwritesAnEarlierOnesResult: what an earlier
// directory of the run put somewhere stays claimed, so a later directory's
// (on-conflict overwrite) takes a free name instead of trashing it - as a
// dry run of the same two directories shows (plan 11 review M1).
func TestLaterDirectoryNeverOverwritesAnEarlierOnesResult(t *testing.T) {
h := home(t)
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
if code, _, errOut := runCLI(t, "init"); code != 0 {
t.Fatal(errOut)
}
for _, n := range []string{"a", "b"} {
p := filepath.Join(h, n, "x.pdf")
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte("from "+n), 0o644)
os.Chtimes(p, old, old)
if code, _, errOut := runCLI(t, "new", n, filepath.Join(h, n)); code != 0 {
t.Fatal(errOut)
}
os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", n+".conf"), []byte("(path \"~/"+n+"\")\n(on-conflict overwrite)\n(rule \"out\" (move \"~/Out\"))\n"), 0o644)
}
if code, out, errOut := runCLI(t, "-y", "a", "b"); code != 0 {
t.Fatalf("exit %d\n%s\n%s", code, out, errOut)
}
for rel, want := range map[string]string{"Out/x.pdf": "from a", "Out/x_1.pdf": "from b"} {
if b, err := os.ReadFile(filepath.Join(h, rel)); err != nil || string(b) != want {
t.Errorf("%s: %q, %v; want %q", rel, b, err, want)
}
}
if entries, _ := os.ReadDir(filepath.Join(h, ".local", "share", "Trash", "files")); len(entries) != 0 {
t.Errorf("the Trash holds %d entries; nothing should have been displaced", len(entries))
}
}
|