// 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) } }