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
|
// SPDX-License-Identifier: GPL-3.0-or-later
package apply
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"krino/internal/plan"
"krino/internal/scan"
"krino/internal/trash"
)
// chainFor builds a Chain whose File describes path as it is on disk now, so
// the executor's "changed since plan" check passes.
func chainFor(t *testing.T, root, rel string, steps ...plan.Step) plan.Chain {
t.Helper()
p := filepath.Join(root, rel)
fi, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
return plan.Chain{
File: scan.File{Path: p, Rel: rel, Name: filepath.Base(rel), Size: fi.Size(), ModTime: fi.ModTime(), Mode: fi.Mode()},
Steps: steps,
}
}
func TestChainRunsStepsInOrder(t *testing.T) {
root := t.TempDir()
write(t, filepath.Join(root, "x.pdf"), "content", 0o644)
c := chainFor(t, root, "x.pdf",
plan.Step{Kind: plan.Copy, Rule: "backup", Src: filepath.Join(root, "x.pdf"), Dst: filepath.Join(root, "B", "x.pdf")},
plan.Step{Kind: plan.Move, Rule: "acme", Src: filepath.Join(root, "x.pdf"), Dst: filepath.Join(root, "W", "x.pdf")},
)
got := Chain(c)
if len(got) != 2 || got[0].Status != "ok" || got[1].Status != "ok" {
t.Fatalf("results = %+v", got)
}
if b, err := os.ReadFile(filepath.Join(root, "B", "x.pdf")); err != nil || string(b) != "content" {
t.Errorf("the copy is missing: %q %v", b, err)
}
if b, err := os.ReadFile(filepath.Join(root, "W", "x.pdf")); err != nil || string(b) != "content" {
t.Errorf("the move did not arrive: %q %v", b, err)
}
if _, err := os.Stat(filepath.Join(root, "x.pdf")); !os.IsNotExist(err) {
t.Error("the original survived the move")
}
if len(got[0].Made) == 0 {
t.Error("the created directory was not recorded in Made")
}
if got[1].Size != int64(len("content")) {
t.Errorf("Size = %d, want the size at Dst afterwards", got[1].Size)
}
}
// TestChainStopsWhenFileChanged is the guard that matters most: a file
// rewritten between planning and applying must not be acted on at all.
func TestChainStopsWhenFileChanged(t *testing.T) {
root := t.TempDir()
src := write(t, filepath.Join(root, "x.pdf"), "planned", 0o644)
c := chainFor(t, root, "x.pdf",
plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: filepath.Join(root, "W", "x.pdf")},
plan.Step{Kind: plan.Rename, Rule: "b", Src: filepath.Join(root, "W", "x.pdf"), Dst: filepath.Join(root, "W", "y.pdf")},
)
write(t, src, "rewritten since the plan was made", 0o644)
got := Chain(c)
if got[0].Status != "failed" || !strings.Contains(got[0].Detail, "changed since plan") {
t.Fatalf("first step = %+v; want failed \"changed since plan\"", got[0])
}
if got[1].Status != "skipped" {
t.Errorf("second step = %+v; want skipped after the failure", got[1])
}
if b, _ := os.ReadFile(src); string(b) != "rewritten since the plan was made" {
t.Error("the changed file was modified anyway")
}
}
func TestChainTrashAndPermanentDelete(t *testing.T) {
root := t.TempDir()
t.Setenv("HOME", root)
t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share"))
t.Setenv("XDG_STATE_HOME", "")
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("XDG_CACHE_HOME", "")
gone := write(t, filepath.Join(root, "gone.pdf"), "trash me", 0o644)
c1 := chainFor(t, root, "gone.pdf", plan.Step{Kind: plan.Trash, Rule: "dups", Src: gone})
r1 := Chain(c1)
if r1[0].Status != "ok" || r1[0].Entry == "" {
t.Fatalf("trash step = %+v; want ok with an Entry name", r1[0])
}
if r1[0].DisplacedEntry != "" {
t.Errorf("DisplacedEntry = %q, want empty: a plain trash step displaces nothing", r1[0].DisplacedEntry)
}
if _, err := os.Stat(gone); !os.IsNotExist(err) {
t.Error("the trashed file is still in place")
}
nuked := write(t, filepath.Join(root, "nuked.pdf"), "unlink me", 0o644)
c2 := chainFor(t, root, "nuked.pdf", plan.Step{Kind: plan.DeletePermanent, Rule: "old", Src: nuked})
if r2 := Chain(c2); r2[0].Status != "ok" {
t.Fatalf("permanent delete = %+v", r2[0])
}
if _, err := os.Stat(nuked); !os.IsNotExist(err) {
t.Error("the permanently deleted file is still in place")
}
}
func TestChainReChecksConflictAtExecutionTime(t *testing.T) {
root := t.TempDir()
src := write(t, filepath.Join(root, "x.pdf"), "mine", 0o644)
dst := filepath.Join(root, "W", "x.pdf")
c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: dst})
// Something took the planned name between planning and applying.
write(t, dst, "someone else got here first", 0o644)
got := Chain(c)
if got[0].Status != "ok" {
t.Fatalf("step = %+v", got[0])
}
if got[0].Dst == dst {
t.Error("the executor overwrote a name that appeared after planning")
}
if !strings.HasSuffix(got[0].Dst, "x_1.pdf") {
t.Errorf("Dst = %q, want the next free name", got[0].Dst)
}
if b, _ := os.ReadFile(dst); string(b) != "someone else got here first" {
t.Error("the file that took the planned name was overwritten")
}
}
func TestChainSkippedStepIsNotAttempted(t *testing.T) {
root := t.TempDir()
src := write(t, filepath.Join(root, "x.pdf"), "content", 0o644)
c := chainFor(t, root, "x.pdf",
plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: filepath.Join(root, "W", "x.pdf"), Skip: "target exists"},
)
if got := Chain(c); got[0].Status != "skipped" || got[0].Detail != "target exists" {
t.Fatalf("result = %+v; want skipped carrying the planning reason", got[0])
}
if _, err := os.Stat(filepath.Join(root, "W")); !os.IsNotExist(err) {
t.Error("a skipped step created its destination directory")
}
if _, err := os.Stat(src); err != nil {
t.Error("a skipped step moved the file anyway")
}
_ = time.Now
}
// TestChainDisplacedFileRestoresFromDisplacedEntry is the fix-round-1 test:
// DisplacedEntry must be usable for undo, not merely present. It proves
// that by actually restoring the displaced file from the Trash and checking
// its content, not just that the field is non-empty. The displaced file
// sits at its own path, distinct from the step's own Dst: were the two the
// same (the ordinary overwrite shape), the mover's own file would already
// occupy that name by the time Restore ran, and Restore correctly refuses
// to land on an occupied path — this test isolates DisplacedEntry's own
// round-trip instead of also exercising that refusal.
func TestChainDisplacedFileRestoresFromDisplacedEntry(t *testing.T) {
root := t.TempDir()
t.Setenv("HOME", root)
t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share"))
t.Setenv("XDG_STATE_HOME", "")
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("XDG_CACHE_HOME", "")
src := write(t, filepath.Join(root, "x.pdf"), "mine", 0o644)
displaced := write(t, filepath.Join(root, "old", "y.pdf"), "displaced content", 0o644)
dst := filepath.Join(root, "W", "x.pdf")
c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: dst, Displaces: displaced})
got := Chain(c)
if got[0].Status != "ok" {
t.Fatalf("step = %+v", got[0])
}
if got[0].DisplacedEntry == "" {
t.Fatal("DisplacedEntry is empty; undo has no way to find the displaced file")
}
if got[0].Entry != "" {
t.Errorf("Entry = %q, want empty: this step is a Move, not itself a Trash step", got[0].Entry)
}
if _, err := os.Stat(displaced); !os.IsNotExist(err) {
t.Error("the displaced file is still at its old path")
}
if b, err := os.ReadFile(dst); err != nil || string(b) != "mine" {
t.Errorf("the move's own destination = %q, %v", b, err)
}
restored, err := trash.Restore(got[0].DisplacedEntry)
if err != nil {
t.Fatalf("Restore(%q): %v", got[0].DisplacedEntry, err)
}
if restored != displaced {
t.Errorf("restored = %q, want %q", restored, displaced)
}
if b, err := os.ReadFile(restored); err != nil || string(b) != "displaced content" {
t.Errorf("restored content = %q, %v; want the displaced file's own content", b, err)
}
}
// TestChainRunsRenameStep is the fix-round-2 gap: apply_test.go's only other
// Rename (in TestChainStopsWhenFileChanged) is always reported "skipped",
// because the Move before it is made to fail on purpose, so
// "case plan.Rename: err = os.Rename(step.Src, dst)" is never exercised by
// a passing test. A reversed-argument typo there would compile, pass every
// other test, pass make ci, and surface only as live data corruption.
func TestChainRunsRenameStep(t *testing.T) {
root := t.TempDir()
src := write(t, filepath.Join(root, "x.pdf"), "content", 0o644)
dst := filepath.Join(root, "y.pdf")
c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Rename, Rule: "a", Src: src, Dst: dst})
got := Chain(c)
if got[0].Status != "ok" {
t.Fatalf("step = %+v", got[0])
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Error("the old name still exists after a successful rename")
}
if b, err := os.ReadFile(dst); err != nil || string(b) != "content" {
t.Errorf("the new name = %q, %v; want the original content at the new name", b, err)
}
}
// TestChainMadeIsOutermostFirstForNestedDirectories is the fix-round-2 gap:
// every other test creates at most one missing directory level, so
// mkdirAllTracked's outermost-first ordering is correct by trace but
// unpinned by any assertion. Task 5 removes these directories in reverse,
// so a later accidental reordering would break undo while passing
// everything else here.
func TestChainMadeIsOutermostFirstForNestedDirectories(t *testing.T) {
root := t.TempDir()
write(t, filepath.Join(root, "x.pdf"), "content", 0o644)
dst := filepath.Join(root, "A", "B", "x.pdf")
c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Copy, Rule: "a", Src: filepath.Join(root, "x.pdf"), Dst: dst})
got := Chain(c)
if got[0].Status != "ok" {
t.Fatalf("step = %+v", got[0])
}
want := []string{filepath.Join(root, "A"), filepath.Join(root, "A", "B")}
if !slices.Equal(got[0].Made, want) {
t.Errorf("Made = %v, want %v (outermost first)", got[0].Made, want)
}
}
|