// SPDX-License-Identifier: GPL-3.0-or-later package plan import ( "fmt" "os" "path/filepath" "testing" "time" "git.labunix.xyz/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, newClaimed()) 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) } } // countingDisk counts Exists calls, so a test can pin how much probing a // plan does rather than only what it produces. type countingDisk struct { fakeDisk calls int } func (d *countingDisk) Exists(p string) bool { d.calls++ return d.fakeDisk.Exists(p) } // TestSuffixedDoesNotRescanFromOne: every file renamed to one name probes // stem_1, stem_2, ... for a free suffix. Starting each file's search at 1 // makes N files into N^2/2 Exists calls - 4000 files were eight million of // them - so the search continues from the highest suffix already tried for // that name. Within one plan that is the same answer: the taken set only // grows while a plan is built, so a suffix taken once stays taken. func TestSuffixedDoesNotRescanFromOne(t *testing.T) { const n = 60 in := make([]Input, 0, n) for i := 0; i < n; i++ { in = append(in, Input{File: file("/r", fmt.Sprintf("f%02d.pdf", i)), Rules: []RuleMatch{ {Name: "one", Actions: []config.Action{act(config.Move, "Work"), act(config.Rename, "same.pdf")}}}}) } d := &countingDisk{fakeDisk: fakeDisk{exists: map[string]bool{}}} chains := Build("/r", in, time.Now(), d, NewClaims()) // Every file must still land on its own name, the lowest free one. seen := map[string]bool{} for _, c := range chains { last := c.Steps[len(c.Steps)-1] if last.Dst == "" { t.Fatalf("%s was skipped: %q", c.File.Rel, last.Skip) } if seen[last.Dst] { t.Errorf("two files planned onto %s", last.Dst) } seen[last.Dst] = true } if len(seen) != n { t.Errorf("%d distinct destinations for %d files", len(seen), n) } // Quadratic probing would be about n*n/2 = 1800 here; linear is a few // per file. The bound is loose on purpose - it must fail on n^2 and // pass on anything sane. if d.calls > 6*n { t.Errorf("%d Exists calls for %d files: the suffix search is rescanning from _1", d.calls, n) } }