summaryrefslogtreecommitdiff
path: root/cmd/krino/sort_test.go
blob: fa9a7ca37e4b46008421013b4182f84795b4dbe6 (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
// 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)
	}
}

// TestPermanentDeleteOfDuplicatesUnderOverlappingDirKeepsACopy 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 permanently deletes
// duplicates of anything under Archive. Whatever the verdicts, applying
// the plan must leave the content on disk; the expected outcome is that
// the archived copy stays and only the loose one goes.
func TestPermanentDeleteOfDuplicatesUnderOverlappingDirKeepsACopy(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\")) (delete permanent))\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")

	var copies []string
	err := filepath.WalkDir(dl, func(p string, d os.DirEntry, err error) error {
		if err != nil || !d.Type().IsRegular() {
			return err
		}
		if b, err := os.ReadFile(p); err == nil && string(b) == string(content) {
			copies = append(copies, p)
		}
		return nil
	})
	if err != nil {
		t.Fatal(err)
	}
	if len(copies) == 0 {
		t.Fatalf("every copy was deleted (exit %d)\nstdout:\n%s\nstderr:\n%s", code, out, errOut)
	}
	if code != 0 {
		t.Fatalf("run: %d %s", code, errOut)
	}
	if len(copies) != 1 || copies[0] != archived {
		t.Errorf("copies left = %v, want only %s", copies, archived)
	}
}