// SPDX-License-Identifier: GPL-3.0-or-later package apply import ( "os" "path/filepath" "slices" "strings" "testing" "time" "krino/internal/plan" "krino/internal/scan" "krino/internal/trash" ) // chainFor builds a Chain whose File describes path as it is on disk now, so // the executor's "changed since plan" check passes. func chainFor(t *testing.T, root, rel string, steps ...plan.Step) plan.Chain { t.Helper() p := filepath.Join(root, rel) fi, err := os.Stat(p) if err != nil { t.Fatal(err) } return plan.Chain{ File: scan.File{Path: p, Rel: rel, Name: filepath.Base(rel), Size: fi.Size(), ModTime: fi.ModTime(), Mode: fi.Mode()}, Steps: steps, } } func TestChainRunsStepsInOrder(t *testing.T) { root := t.TempDir() write(t, filepath.Join(root, "x.pdf"), "content", 0o644) c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Copy, Rule: "backup", Src: filepath.Join(root, "x.pdf"), Dst: filepath.Join(root, "B", "x.pdf")}, plan.Step{Kind: plan.Move, Rule: "acme", Src: filepath.Join(root, "x.pdf"), Dst: filepath.Join(root, "W", "x.pdf")}, ) got := Chain(c) if len(got) != 2 || got[0].Status != "ok" || got[1].Status != "ok" { t.Fatalf("results = %+v", got) } if b, err := os.ReadFile(filepath.Join(root, "B", "x.pdf")); err != nil || string(b) != "content" { t.Errorf("the copy is missing: %q %v", b, err) } if b, err := os.ReadFile(filepath.Join(root, "W", "x.pdf")); err != nil || string(b) != "content" { t.Errorf("the move did not arrive: %q %v", b, err) } if _, err := os.Stat(filepath.Join(root, "x.pdf")); !os.IsNotExist(err) { t.Error("the original survived the move") } if len(got[0].Made) == 0 { t.Error("the created directory was not recorded in Made") } if got[1].Size != int64(len("content")) { t.Errorf("Size = %d, want the size at Dst afterwards", got[1].Size) } } // TestChainStopsWhenFileChanged is the guard that matters most: a file // rewritten between planning and applying must not be acted on at all. func TestChainStopsWhenFileChanged(t *testing.T) { root := t.TempDir() src := write(t, filepath.Join(root, "x.pdf"), "planned", 0o644) c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: filepath.Join(root, "W", "x.pdf")}, plan.Step{Kind: plan.Rename, Rule: "b", Src: filepath.Join(root, "W", "x.pdf"), Dst: filepath.Join(root, "W", "y.pdf")}, ) write(t, src, "rewritten since the plan was made", 0o644) got := Chain(c) if got[0].Status != "failed" || !strings.Contains(got[0].Detail, "changed since plan") { t.Fatalf("first step = %+v; want failed \"changed since plan\"", got[0]) } if got[1].Status != "skipped" { t.Errorf("second step = %+v; want skipped after the failure", got[1]) } if b, _ := os.ReadFile(src); string(b) != "rewritten since the plan was made" { t.Error("the changed file was modified anyway") } } func TestChainTrashAndPermanentDelete(t *testing.T) { root := t.TempDir() t.Setenv("HOME", root) t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share")) t.Setenv("XDG_STATE_HOME", "") t.Setenv("XDG_CONFIG_HOME", "") t.Setenv("XDG_CACHE_HOME", "") gone := write(t, filepath.Join(root, "gone.pdf"), "trash me", 0o644) c1 := chainFor(t, root, "gone.pdf", plan.Step{Kind: plan.Trash, Rule: "dups", Src: gone}) r1 := Chain(c1) if r1[0].Status != "ok" || r1[0].Entry == "" { t.Fatalf("trash step = %+v; want ok with an Entry name", r1[0]) } if r1[0].DisplacedEntry != "" { t.Errorf("DisplacedEntry = %q, want empty: a plain trash step displaces nothing", r1[0].DisplacedEntry) } if _, err := os.Stat(gone); !os.IsNotExist(err) { t.Error("the trashed file is still in place") } nuked := write(t, filepath.Join(root, "nuked.pdf"), "unlink me", 0o644) c2 := chainFor(t, root, "nuked.pdf", plan.Step{Kind: plan.DeletePermanent, Rule: "old", Src: nuked}) if r2 := Chain(c2); r2[0].Status != "ok" { t.Fatalf("permanent delete = %+v", r2[0]) } if _, err := os.Stat(nuked); !os.IsNotExist(err) { t.Error("the permanently deleted file is still in place") } } func TestChainReChecksConflictAtExecutionTime(t *testing.T) { root := t.TempDir() src := write(t, filepath.Join(root, "x.pdf"), "mine", 0o644) dst := filepath.Join(root, "W", "x.pdf") c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: dst}) // Something took the planned name between planning and applying. write(t, dst, "someone else got here first", 0o644) got := Chain(c) if got[0].Status != "ok" { t.Fatalf("step = %+v", got[0]) } if got[0].Dst == dst { t.Error("the executor overwrote a name that appeared after planning") } if !strings.HasSuffix(got[0].Dst, "x_1.pdf") { t.Errorf("Dst = %q, want the next free name", got[0].Dst) } if b, _ := os.ReadFile(dst); string(b) != "someone else got here first" { t.Error("the file that took the planned name was overwritten") } } func TestChainSkippedStepIsNotAttempted(t *testing.T) { root := t.TempDir() src := write(t, filepath.Join(root, "x.pdf"), "content", 0o644) c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: filepath.Join(root, "W", "x.pdf"), Skip: "target exists"}, ) if got := Chain(c); got[0].Status != "skipped" || got[0].Detail != "target exists" { t.Fatalf("result = %+v; want skipped carrying the planning reason", got[0]) } if _, err := os.Stat(filepath.Join(root, "W")); !os.IsNotExist(err) { t.Error("a skipped step created its destination directory") } if _, err := os.Stat(src); err != nil { t.Error("a skipped step moved the file anyway") } _ = time.Now } // TestChainDisplacedFileRestoresFromDisplacedEntry is the fix-round-1 test: // DisplacedEntry must be usable for undo, not merely present. It proves // that by actually restoring the displaced file from the Trash and checking // its content, not just that the field is non-empty. The displaced file // sits at its own path, distinct from the step's own Dst: were the two the // same (the ordinary overwrite shape), the mover's own file would already // occupy that name by the time Restore ran, and Restore correctly refuses // to land on an occupied path — this test isolates DisplacedEntry's own // round-trip instead of also exercising that refusal. func TestChainDisplacedFileRestoresFromDisplacedEntry(t *testing.T) { root := t.TempDir() t.Setenv("HOME", root) t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share")) t.Setenv("XDG_STATE_HOME", "") t.Setenv("XDG_CONFIG_HOME", "") t.Setenv("XDG_CACHE_HOME", "") src := write(t, filepath.Join(root, "x.pdf"), "mine", 0o644) displaced := write(t, filepath.Join(root, "old", "y.pdf"), "displaced content", 0o644) dst := filepath.Join(root, "W", "x.pdf") c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: dst, Displaces: displaced}) got := Chain(c) if got[0].Status != "ok" { t.Fatalf("step = %+v", got[0]) } if got[0].DisplacedEntry == "" { t.Fatal("DisplacedEntry is empty; undo has no way to find the displaced file") } if got[0].Entry != "" { t.Errorf("Entry = %q, want empty: this step is a Move, not itself a Trash step", got[0].Entry) } if _, err := os.Stat(displaced); !os.IsNotExist(err) { t.Error("the displaced file is still at its old path") } if b, err := os.ReadFile(dst); err != nil || string(b) != "mine" { t.Errorf("the move's own destination = %q, %v", b, err) } restored, err := trash.Restore(got[0].DisplacedEntry) if err != nil { t.Fatalf("Restore(%q): %v", got[0].DisplacedEntry, err) } if restored != displaced { t.Errorf("restored = %q, want %q", restored, displaced) } if b, err := os.ReadFile(restored); err != nil || string(b) != "displaced content" { t.Errorf("restored content = %q, %v; want the displaced file's own content", b, err) } } // TestChainRunsRenameStep is the fix-round-2 gap: apply_test.go's only other // Rename (in TestChainStopsWhenFileChanged) is always reported "skipped", // because the Move before it is made to fail on purpose, so // "case plan.Rename: err = os.Rename(step.Src, dst)" is never exercised by // a passing test. A reversed-argument typo there would compile, pass every // other test, pass make ci, and surface only as live data corruption. func TestChainRunsRenameStep(t *testing.T) { root := t.TempDir() src := write(t, filepath.Join(root, "x.pdf"), "content", 0o644) dst := filepath.Join(root, "y.pdf") c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Rename, Rule: "a", Src: src, Dst: dst}) got := Chain(c) if got[0].Status != "ok" { t.Fatalf("step = %+v", got[0]) } if _, err := os.Stat(src); !os.IsNotExist(err) { t.Error("the old name still exists after a successful rename") } if b, err := os.ReadFile(dst); err != nil || string(b) != "content" { t.Errorf("the new name = %q, %v; want the original content at the new name", b, err) } } // TestChainMadeIsOutermostFirstForNestedDirectories is the fix-round-2 gap: // every other test creates at most one missing directory level, so // mkdirAllTracked's outermost-first ordering is correct by trace but // unpinned by any assertion. Task 5 removes these directories in reverse, // so a later accidental reordering would break undo while passing // everything else here. func TestChainMadeIsOutermostFirstForNestedDirectories(t *testing.T) { root := t.TempDir() write(t, filepath.Join(root, "x.pdf"), "content", 0o644) dst := filepath.Join(root, "A", "B", "x.pdf") c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Copy, Rule: "a", Src: filepath.Join(root, "x.pdf"), Dst: dst}) got := Chain(c) if got[0].Status != "ok" { t.Fatalf("step = %+v", got[0]) } want := []string{filepath.Join(root, "A"), filepath.Join(root, "A", "B")} if !slices.Equal(got[0].Made, want) { t.Errorf("Made = %v, want %v (outermost first)", got[0].Made, want) } } // TestMoveFileRefusesOccupiedDestination is item 16 (fix round 2026-09-12, // plan 5 Task 2): moveFile must refuse an occupied destination on its own, // not merely rely on runFileStep having already checked - the exact // arrangement that produced plan 4's Task 5 Critical, where a helper that // replaced silently was trusted because some caller had checked. Called // directly, bypassing runFileStep's own pre-check entirely. func TestMoveFileRefusesOccupiedDestination(t *testing.T) { dir := t.TempDir() src := write(t, filepath.Join(dir, "x.pdf"), "source", 0o644) dst := write(t, filepath.Join(dir, "y.pdf"), "already there", 0o644) if err := moveFile(src, dst); err == nil { t.Fatal("moveFile overwrote an existing destination") } if b, err := os.ReadFile(src); err != nil || string(b) != "source" { t.Errorf("moveFile touched its source: %q, %v", b, err) } if b, err := os.ReadFile(dst); err != nil || string(b) != "already there" { t.Errorf("moveFile touched its destination: %q, %v", b, err) } } // TestRenameFileRefusesOccupiedDestination is item 16's other half: // runFileStep's bare os.Rename call for the Rename kind was just as // unguarded in itself as moveFile was. renameFile is the helper that now // carries the same independent guard, called directly here. func TestRenameFileRefusesOccupiedDestination(t *testing.T) { dir := t.TempDir() src := write(t, filepath.Join(dir, "x.pdf"), "source", 0o644) dst := write(t, filepath.Join(dir, "y.pdf"), "already there", 0o644) if err := renameFile(src, dst); err == nil { t.Fatal("renameFile overwrote an existing destination") } if b, err := os.ReadFile(src); err != nil || string(b) != "source" { t.Errorf("renameFile touched its source: %q, %v", b, err) } if b, err := os.ReadFile(dst); err != nil || string(b) != "already there" { t.Errorf("renameFile touched its destination: %q, %v", b, err) } }