diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 12:58:14 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-12 12:58:14 +0200 |
| commit | 24a84671ace373ae331fa83a1ff484990f4dff0e (patch) | |
| tree | a6b6e3949d7dd241f1d13e079dfb982d758c89a2 /internal/plan/conflict_test.go | |
| parent | 3b36a48b7ce5a53a9366f3b31f94311f178e2553 (diff) | |
| download | krino-24a84671ace373ae331fa83a1ff484990f4dff0e.tar.gz krino-24a84671ace373ae331fa83a1ff484990f4dff0e.zip | |
krino: planning โ chains, placeholders, conflicts, JSON
Diffstat (limited to 'internal/plan/conflict_test.go')
| -rw-r--r-- | internal/plan/conflict_test.go | 224 |
1 files changed, 224 insertions, 0 deletions
diff --git a/internal/plan/conflict_test.go b/internal/plan/conflict_test.go new file mode 100644 index 0000000..bdc37af --- /dev/null +++ b/internal/plan/conflict_test.go @@ -0,0 +1,224 @@ +// 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 +} + +func (f fakeDisk) Exists(p string) bool { return f.exists[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) 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) + } +} |
