aboutsummaryrefslogtreecommitdiff
path: root/cmd/krino/sort_test.go
blob: 4e68cb5ebe256e384d2c59131db72e34574c7143 (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
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
// 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: 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, end to end: a
// directory whose one file matches a rule under (on-conflict skip) - so
// its single step's own Skip is set ("target exists") - must report "0 to
// act on" (countActing) and, with -y, must not run that chain through
// Apply anyway: an all-skipped chain offered for approval regardless
// (as actionableChains' own len(Steps) > 0 check alone would allow) would
// log 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. Instead 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.
	_, 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.
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; removing that exit code left
// every other 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, driven through the real command with a pipe
// standing in for the terminal.
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.
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))
	}
}

// TestOnlyWhereFilesEndedUpStaysClaimed: a path an earlier directory's file
// passed through and left - renamed, then moved on - is free for a later
// directory; only where files ended up stays claimed.
func TestOnlyWhereFilesEndedUpStaysClaimed(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)
		}
	}
	dirs := filepath.Join(h, ".config", "krino", "dirs")
	os.WriteFile(filepath.Join(dirs, "a.conf"), []byte("(path \"~/a\")\n(rule \"r\" (rename \"x-r.pdf\") (move \"~/Out\"))\n"), 0o644)
	os.WriteFile(filepath.Join(dirs, "b.conf"), []byte("(path \"~/b\")\n(rule \"r\" (rename \"x-r.pdf\") (move \"~/a\"))\n"), 0o644)
	if code, out, errOut := runCLI(t, "-y", "a", "b"); code != 0 {
		t.Fatalf("exit %d\n%s\n%s", code, out, errOut)
	}
	if b, err := os.ReadFile(filepath.Join(h, "a", "x-r.pdf")); err != nil || string(b) != "from b" {
		t.Errorf("a/x-r.pdf: %q, %v; want b's file under its planned name", b, err)
	}
}