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
|
// SPDX-License-Identifier: GPL-3.0-or-later
package plan
import (
"os"
"path/filepath"
"testing"
"time"
"krino/internal/config"
)
// fakeDisk reports the paths it was given as existing, and equality of
// content by exact string match on a separate map.
type fakeDisk struct {
exists map[string]bool
same map[[2]string]bool
dirs map[string]bool // existing paths that are directories
}
func (f fakeDisk) Exists(p string) bool { return f.exists[p] }
func (f fakeDisk) Regular(p string) bool { return f.exists[p] && !f.dirs[p] }
func (f fakeDisk) SameContent(a, b string) (bool, error) {
return f.same[[2]string{a, b}], nil
}
// TestConflictAlreadyThereMove is A1: a move whose destination resolves to
// the directory the file is already sitting in must be a no-op, not a
// rename - probe: "(rule "byyear" (when (type txt)) (move "{mtime:%Y}"))"
// over "dl/2026/a.txt" planned "a.txt -> a_1.txt" on run 1 and, on run 2,
// both "a.txt -> a_2.txt" and "a_1.txt -> a_1_1.txt": every run renamed the
// whole destination tree and added a generation, because the file's own
// existence at dst read as a conflict with itself.
func TestConflictAlreadyThereMove(t *testing.T) {
d := fakeDisk{exists: map[string]bool{"/r/2026/a.txt": true}}
in := []Input{{File: file("/r", "2026/a.txt"), Rules: []RuleMatch{
{Name: "byyear", Actions: []config.Action{act(config.Move, "2026")}}}}}
s := Build("/r", in, time.Now(), d, NewClaims())[0].Steps[0]
if s.Dst != "/r/2026/a.txt" || s.Skip != "already there" || s.Displaces != "" {
t.Errorf("step = %+v; want a no-op at the file's own path", s)
}
}
// TestConflictAlreadyThereRename is A1's rename counterpart:
// (rename "{name}") over a file already named that must also be a no-op.
func TestConflictAlreadyThereRename(t *testing.T) {
d := fakeDisk{exists: map[string]bool{"/r/2026/a.txt": true}}
in := []Input{{File: file("/r", "2026/a.txt"), Rules: []RuleMatch{
{Name: "byyear", Actions: []config.Action{act(config.Rename, "{name}")}}}}}
s := Build("/r", in, time.Now(), d, NewClaims())[0].Steps[0]
if s.Dst != "/r/2026/a.txt" || s.Skip != "already there" || s.Displaces != "" {
t.Errorf("step = %+v; want a no-op at the file's own path", s)
}
}
// TestConflictAlreadyThereOverwriteNoDisplace is A2: the same already-there
// case under (on-conflict overwrite) must not record the file as its own
// Displaces - spec §7.4's overwrite moves the existing target to Trash
// first, then proceeds; applied literally here that trashes the user's file
// and then moves from a path that no longer exists, so the file survives
// only in Trash. A1's guard (dst == src, checked before the policy switch)
// fixes this too, since it runs before overwrite's own branch is ever
// reached.
func TestConflictAlreadyThereOverwriteNoDisplace(t *testing.T) {
d := fakeDisk{exists: map[string]bool{"/r/2026/a.txt": true}}
over := config.ConflictOverwrite
in := []Input{{File: file("/r", "2026/a.txt"), Rules: []RuleMatch{
{Name: "byyear", Settings: config.Resolved{OnConflict: over}, Actions: []config.Action{act(config.Move, "2026")}}}}}
s := Build("/r", in, time.Now(), d, NewClaims())[0].Steps[0]
if s.Skip != "already there" || s.Displaces != "" {
t.Errorf("step = %+v; want Skip \"already there\" and empty Displaces", s)
}
}
func TestConflictSuffix(t *testing.T) {
d := fakeDisk{exists: map[string]bool{"/r/Work/x.pdf": true, "/r/Work/x_1.pdf": true}}
in := []Input{{File: file("/r", "x.pdf"), Rules: []RuleMatch{
{Name: "a", Actions: []config.Action{act(config.Move, "Work")}}}}}
s := Build("/r", in, time.Now(), d, NewClaims())[0].Steps[0]
if s.Dst != "/r/Work/x_2.pdf" || s.Skip != "" {
t.Errorf("step = %+v; want Dst /r/Work/x_2.pdf", s)
}
}
func TestConflictSkipKeepsCurrentPath(t *testing.T) {
d := fakeDisk{exists: map[string]bool{"/r/Work/x.pdf": true}}
skip := config.ConflictSkip
in := []Input{{File: file("/r", "x.pdf"), Rules: []RuleMatch{
{Name: "a", Settings: config.Resolved{OnConflict: skip}, Actions: []config.Action{
act(config.Move, "Work"),
act(config.Rename, "later-{name}"),
}}}}}
steps := Build("/r", in, time.Now(), d, NewClaims())[0].Steps
if steps[0].Skip != "target exists" {
t.Errorf("skip step = %+v", steps[0])
}
if steps[1].Src != "/r/x.pdf" || steps[1].Dst != "/r/later-x.pdf" {
t.Errorf("chain must continue from the unchanged path: %+v", steps[1])
}
}
func TestConflictOverwriteRecordsDisplaced(t *testing.T) {
d := fakeDisk{exists: map[string]bool{"/r/Work/x.pdf": true}}
over := config.ConflictOverwrite
in := []Input{{File: file("/r", "x.pdf"), Rules: []RuleMatch{
{Name: "a", Settings: config.Resolved{OnConflict: over}, Actions: []config.Action{act(config.Move, "Work")}}}}}
s := Build("/r", in, time.Now(), d, NewClaims())[0].Steps[0]
if s.Dst != "/r/Work/x.pdf" || s.Displaces != "/r/Work/x.pdf" {
t.Errorf("step = %+v", s)
}
}
// TestConflictOverwriteTwoFilesOnDisk: two files collide on one path that
// already exists on disk, both under overwrite. Only the first may displace
// the pre-existing file; the second must take a free name and displace
// nothing, or applying both later would trash the first file's own output.
func TestConflictOverwriteTwoFilesOnDisk(t *testing.T) {
d := fakeDisk{exists: map[string]bool{"/r/Work/x.pdf": true}}
over := config.ConflictOverwrite
in := []Input{
{File: file("/r", "a/x.pdf"), Rules: []RuleMatch{
{Name: "r", Settings: config.Resolved{OnConflict: over}, Actions: []config.Action{act(config.Move, "Work")}}}},
{File: file("/r", "b/x.pdf"), Rules: []RuleMatch{
{Name: "r", Settings: config.Resolved{OnConflict: over}, Actions: []config.Action{act(config.Move, "Work")}}}},
}
chains := Build("/r", in, time.Now(), d, NewClaims())
first, second := chains[0].Steps[0], chains[1].Steps[0]
if first.Dst != "/r/Work/x.pdf" || first.Displaces != "/r/Work/x.pdf" {
t.Errorf("first step = %+v; want Dst and Displaces /r/Work/x.pdf", first)
}
if second.Dst != "/r/Work/x_1.pdf" || second.Displaces != "" {
t.Errorf("second step = %+v; want Dst /r/Work/x_1.pdf and empty Displaces", second)
}
}
// TestConflictOverwriteTwoFilesClaimedOnly: same collision, but nothing is
// on disk — the two files claim the same name only in-plan. Neither may
// displace (an in-plan claim is never displaced); the second must fall back
// to a free name with no Displaces recorded.
func TestConflictOverwriteTwoFilesClaimedOnly(t *testing.T) {
over := config.ConflictOverwrite
in := []Input{
{File: file("/r", "a/x.pdf"), Rules: []RuleMatch{
{Name: "r", Settings: config.Resolved{OnConflict: over}, Actions: []config.Action{act(config.Move, "Work")}}}},
{File: file("/r", "b/x.pdf"), Rules: []RuleMatch{
{Name: "r", Settings: config.Resolved{OnConflict: over}, Actions: []config.Action{act(config.Move, "Work")}}}},
}
chains := Build("/r", in, time.Now(), NoDisk{}, NewClaims())
first, second := chains[0].Steps[0], chains[1].Steps[0]
if first.Dst != "/r/Work/x.pdf" || first.Displaces != "" {
t.Errorf("first step = %+v; want Dst /r/Work/x.pdf and empty Displaces", first)
}
if second.Dst != "/r/Work/x_1.pdf" || second.Displaces != "" {
t.Errorf("second step = %+v; want Dst /r/Work/x_1.pdf and empty Displaces", second)
}
}
func TestCopyAlreadyThere(t *testing.T) {
d := fakeDisk{
exists: map[string]bool{"/backup/x.pdf": true},
same: map[[2]string]bool{{"/r/x.pdf", "/backup/x.pdf"}: true},
}
in := []Input{{File: file("/r", "x.pdf"), Rules: []RuleMatch{
{Name: "b", Actions: []config.Action{act(config.Copy, "/backup")}}}}}
s := Build("/r", in, time.Now(), d, NewClaims())[0].Steps[0]
if s.Skip != "already there" {
t.Errorf("step = %+v; want Skip \"already there\"", s)
}
}
func TestTwoFilesOneTarget(t *testing.T) {
in := []Input{
{File: file("/r", "a/x.pdf"), Rules: []RuleMatch{
{Name: "r", Actions: []config.Action{act(config.Move, "Work")}}}},
{File: file("/r", "b/x.pdf"), Rules: []RuleMatch{
{Name: "r", Actions: []config.Action{act(config.Move, "Work")}}}},
}
chains := Build("/r", in, time.Now(), NoDisk{}, NewClaims())
if chains[0].Steps[0].Dst != "/r/Work/x.pdf" || chains[1].Steps[0].Dst != "/r/Work/x_1.pdf" {
t.Errorf("in-plan collision: %q and %q", chains[0].Steps[0].Dst, chains[1].Steps[0].Dst)
}
}
func TestSameContentReal(t *testing.T) {
dir := t.TempDir()
a := filepath.Join(dir, "a")
b := filepath.Join(dir, "b")
c := filepath.Join(dir, "c")
if err := os.WriteFile(a, []byte("same bytes"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(b, []byte("same bytes"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(c, []byte("other bytes"), 0o644); err != nil {
t.Fatal(err)
}
if ok, err := (OS{}).SameContent(a, b); err != nil || !ok {
t.Errorf("identical files: %v %v", ok, err)
}
if ok, _ := (OS{}).SameContent(a, c); ok {
t.Error("different files reported identical")
}
}
// stubAlwaysExists is C2's stub Disk: every path reports as existing (and
// nothing is ever the same content), so suffixed() can never find a free
// name and must give up instead of spinning forever.
type stubAlwaysExists struct{}
func (stubAlwaysExists) Exists(string) bool { return true }
func (stubAlwaysExists) Regular(string) bool { return true }
func (stubAlwaysExists) SameContent(string, string) (bool, error) { return false, nil }
// TestSuffixedCapsAttempts is C2: suffixed() gives up after
// maxSuffixAttempts, reporting Skip "too many conflicting names" and no
// Dst, rather than looping forever against a Disk that never reports a free
// name.
func TestSuffixedCapsAttempts(t *testing.T) {
in := []Input{{File: file("/r", "x.pdf"), Rules: []RuleMatch{
{Name: "a", Actions: []config.Action{act(config.Move, "Work")}}}}}
s := Build("/r", in, time.Now(), stubAlwaysExists{}, NewClaims())[0].Steps[0]
if s.Skip != "too many conflicting names" || s.Dst != "" {
t.Errorf("step = %+v; want Skip \"too many conflicting names\" and empty Dst", s)
}
}
// TestOverwriteNeverDisplacesADirectory: a hostile file named like an
// existing directory must not have that directory trashed in its place
// (review M4).
func TestOverwriteNeverDisplacesADirectory(t *testing.T) {
dir := "/home/x/Documents/Invoices"
d := fakeDisk{exists: map[string]bool{dir: true}, dirs: map[string]bool{dir: true}}
_, skip, displaces := resolveConflict(Move, config.ConflictOverwrite, "/r/Invoices", dir, d, claimed{})
if displaces != "" || skip != "target is not a regular file" {
t.Errorf("skip %q displaces %q; want skipped, nothing displaced", skip, displaces)
}
}
// TestOverwriteNeverDisplacesAnotherScannedFile: a file of the same plan is
// not trashed to make room - it may be about to move away or be acted on -
// so the step takes a free name instead (review M4).
func TestOverwriteNeverDisplacesAnotherScannedFile(t *testing.T) {
d := fakeDisk{exists: map[string]bool{"/r/a.pdf": true, "/r/b.pdf": true}}
in := []Input{
{File: file("/r", "a.pdf"), Rules: []RuleMatch{{Name: "r", Settings: config.Resolved{OnConflict: config.ConflictOverwrite}, Actions: []config.Action{act(config.Rename, "b.pdf")}}}},
{File: file("/r", "b.pdf"), Rules: []RuleMatch{{Name: "m", Actions: []config.Action{act(config.Move, "Out")}}}},
}
s := Build("/r", in, time.Now(), d, NewClaims())[0].Steps[0]
if s.Displaces != "" || s.Dst != "/r/b_1.pdf" || s.Skip != "" {
t.Errorf("Displaces %q Dst %q Skip %q; want no displacement and /r/b_1.pdf", s.Displaces, s.Dst, s.Skip)
}
}
|