aboutsummaryrefslogtreecommitdiff
path: root/internal/plan/conflict_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/plan/conflict_test.go')
-rw-r--r--internal/plan/conflict_test.go224
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)
+ }
+}