aboutsummaryrefslogtreecommitdiff
path: root/internal/engine/apply_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 20:14:47 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 20:14:47 +0200
commit3f8679be9373ee7508d512dfdfc1dda0839c7f90 (patch)
treeec02eb075f6c4e90f21baa2fe674e86a2f7f6a62 /internal/engine/apply_test.go
parent24a84671ace373ae331fa83a1ff484990f4dff0e (diff)
downloadkrino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.tar.gz
krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.zip
krino: acting — trash, journal, apply, lock, review, undo
Diffstat (limited to 'internal/engine/apply_test.go')
-rw-r--r--internal/engine/apply_test.go1182
1 files changed, 1182 insertions, 0 deletions
diff --git a/internal/engine/apply_test.go b/internal/engine/apply_test.go
new file mode 100644
index 0000000..42724a0
--- /dev/null
+++ b/internal/engine/apply_test.go
@@ -0,0 +1,1182 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package engine
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "krino/internal/apply"
+ "krino/internal/journal"
+ "krino/internal/plan"
+ "krino/internal/trash"
+)
+
+// applyFixture builds a directory with two files and a rule moving pdfs into
+// Work, then plans it. It returns the home, the plan and an open journal.
+//
+// Adapted from the brief to this package's actual writeConfig helper, which
+// takes a main-file body and a dirs map keyed by name (see
+// TestLoadRejectsUnsuppliedCaptures's comment in engine_test.go for the same
+// adaptation elsewhere in this package): the brief's fixture wrote
+// `(path ...)` and `(rule ...)` straight into what it called the main file,
+// but the real config language (docs/design.md §4.2-4.3) requires those in a
+// directory file reached through `(include ...)`. Every assertion below is
+// unchanged from the brief; only this setup plumbing differs.
+func applyFixture(t *testing.T) (string, *Engine, *DirPlan, *journal.Writer, string) {
+ t.Helper()
+ h := sandbox(t)
+ dl := filepath.Join(h, "dl")
+ for name, body := range map[string]string{"a.pdf": "one", "b.txt": "two"} {
+ if err := os.MkdirAll(dl, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dl, name), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ old := time.Now().Add(-time.Hour)
+ os.Chtimes(filepath.Join(dl, name), old, old)
+ }
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{
+ "dl": `(path "~/dl")` + "\n" + `(rule "pdfs" (when (type pdf)) (move "Work"))`,
+ })
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims())
+ if err != nil {
+ t.Fatal(err)
+ }
+ j, err := journal.Open(filepath.Join(h, ".local", "state", "krino", "krino.log"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { j.Close() })
+ return h, e, dp, j, journal.NewRunID(time.Now())
+}
+
+func TestApplyMovesApprovedAndDeclinesTheRest(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ res, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Applied != 1 || res.Failed != 0 {
+ t.Errorf("result = %+v; want one applied, none failed", res)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "Work", "a.pdf")); err != nil {
+ t.Errorf("the approved file did not move: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "a.pdf")); !os.IsNotExist(err) {
+ t.Error("the original survived the move")
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "b.txt")); err != nil {
+ t.Error("a file that matched no rule was touched")
+ }
+}
+
+func TestApplyLogsRunBoundariesAndSteps(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ entries, err := journal.Entries(filepath.Join(h, ".local", "state", "krino", "krino.log"), run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) < 3 {
+ t.Fatalf("logged %d entries, want run-start, at least one step and run-end", len(entries))
+ }
+ if entries[0].Action != "run-start" || entries[len(entries)-1].Action != "run-end" {
+ t.Errorf("boundaries = %q .. %q", entries[0].Action, entries[len(entries)-1].Action)
+ }
+ var moved *journal.Entry
+ for i := range entries {
+ if entries[i].Action == "move" {
+ moved = &entries[i]
+ }
+ }
+ if moved == nil {
+ t.Fatal("no move entry was logged")
+ }
+ if moved.Status != "ok" || moved.File != "a.pdf" || moved.Rule != "pdfs" {
+ t.Errorf("move entry = %+v", *moved)
+ }
+ if moved.Size != int64(len("one")) {
+ t.Errorf("Size = %d; want the size at Dst after the step", moved.Size)
+ }
+ if !strings.HasSuffix(moved.Dst, filepath.Join("Work", "a.pdf")) {
+ t.Errorf("Dst = %q", moved.Dst)
+ }
+}
+
+func TestPlanUndoReversesLastStepFirst(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(up.Files) != 1 {
+ t.Fatalf("undo plan covers %d files, want 1", len(up.Files))
+ }
+ f := up.Files[0]
+ if f.Refused != "" {
+ t.Fatalf("undo refused: %s", f.Refused)
+ }
+ if len(f.Steps) == 0 || f.Steps[0].Action != "undo-move" {
+ t.Fatalf("steps = %+v; want undo-move first", f.Steps)
+ }
+ if f.Steps[0].Dst != filepath.Join(h, "dl", "a.pdf") {
+ t.Errorf("undo-move puts the file at %q, want its original path", f.Steps[0].Dst)
+ }
+}
+
+// TestPlanUndoRefusesWholeFileWhenOneStepCannotBeReversed: spec §10 - no file
+// is left half undone, so one refused step refuses the file.
+func TestPlanUndoRefusesWholeFileWhenOneStepCannotBeReversed(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+ // Someone edited the moved file, so the reversal is no longer safe.
+ moved := filepath.Join(h, "dl", "Work", "a.pdf")
+ if err := os.WriteFile(moved, []byte("edited since the run"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f := up.Files[0]
+ if f.Refused == "" {
+ t.Fatal("undo did not refuse a file that changed since the run")
+ }
+ if !strings.Contains(f.Refused, "changed") {
+ t.Errorf("Refused = %q; want it to say the file changed", f.Refused)
+ }
+}
+
+func TestPlanUndoRefusesPermanentDelete(t *testing.T) {
+ h := sandbox(t)
+ dl := filepath.Join(h, "dl")
+ os.MkdirAll(dl, 0o755)
+ os.WriteFile(filepath.Join(dl, "old.iso"), []byte("gone"), 0o644)
+ old := time.Now().Add(-time.Hour)
+ os.Chtimes(filepath.Join(dl, "old.iso"), old, old)
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{
+ "dl": `(path "~/dl")` + "\n" + `(rule "purge" (when (type iso)) (delete permanent))`,
+ })
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims())
+ if err != nil {
+ t.Fatal(err)
+ }
+ j, _ := journal.Open(filepath.Join(h, ".local", "state", "krino", "krino.log"))
+ run := journal.NewRunID(time.Now())
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"old.iso": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if up.Files[0].Refused == "" || !strings.Contains(up.Files[0].Refused, "permanent") {
+ t.Errorf("Refused = %q; want it to name the permanent delete", up.Files[0].Refused)
+ }
+}
+
+// TestPlanUndoDropsDeclinedFileButKeepsPermanentDelete is the Critical
+// finding from Task 8's review: a file the ORIGINAL forward run declined
+// (spec §9: its steps are still logged, status "declined") has no "ok"
+// entries at all, so planUndoFile's last-to-first walk skips every one of
+// them and returns an UndoFile with Steps == nil and Refused == "" - a file
+// that was never touched, not a reversible one. Before the fix, PlanUndo
+// appended that empty UndoFile anyway, and undoActionableCount (cmd/krino)
+// counts every Refused == "" file as "to reverse" regardless of whether it
+// has any steps - inflating the header's count while the table renders no
+// row for it and the final tally comes up one short, silently, at exit 0.
+//
+// The two halves in one test, deliberately, per the review: a fix that
+// dropped every zero-step UndoFile instead of the correct
+// "len(Steps) == 0 && Refused == \"\"" condition would also drop a
+// permanently deleted file (zero steps, but Refused IS set - spec §10
+// requires it to stay visible with its reason) - so both conditions live
+// in the same test, and a future "simplification" that breaks either one
+// fails this one test immediately rather than needing two separate reviews
+// to notice.
+func TestPlanUndoDropsDeclinedFileButKeepsPermanentDelete(t *testing.T) {
+ h := sandbox(t)
+ dl := filepath.Join(h, "dl")
+ if err := os.MkdirAll(dl, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ files := map[string]string{"moved.pdf": "one", "declined.pdf": "two", "old.iso": "gone"}
+ old := time.Now().Add(-time.Hour)
+ for name, body := range files {
+ p := filepath.Join(dl, name)
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(p, old, old); err != nil {
+ t.Fatal(err)
+ }
+ }
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{
+ "dl": `(path "~/dl")` + "\n" +
+ `(rule "pdfs" (when (type pdf)) (move "Work"))` + "\n" +
+ `(rule "purge" (when (type iso)) (delete permanent))`,
+ })
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims())
+ if err != nil {
+ t.Fatal(err)
+ }
+ j, err := journal.Open(filepath.Join(h, ".local", "state", "krino", "krino.log"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ run := journal.NewRunID(time.Now())
+ // declined.pdf is deliberately left out of approved: spec §9 still logs
+ // its step, status "declined" - it was never touched.
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"moved.pdf": true, "old.iso": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ byFile := map[string]UndoFile{}
+ for _, f := range up.Files {
+ byFile[f.File] = f
+ }
+
+ if _, ok := byFile["declined.pdf"]; ok {
+ t.Errorf("a file with no \"ok\" entries (declined in the original run) must not appear in the undo plan at all: %+v", up.Files)
+ }
+ if got := byFile["moved.pdf"]; len(got.Steps) == 0 {
+ t.Errorf("the actually-reversed file lost its steps: %+v", got)
+ }
+ permDel, ok := byFile["old.iso"]
+ if !ok {
+ t.Fatal("the permanently deleted file was dropped too - a zero-step file is not always an untouched one, and this one must stay visible with its refusal reason")
+ }
+ if permDel.Refused == "" || !strings.Contains(permDel.Refused, "permanent") {
+ t.Errorf("Refused = %q; want it to still name the permanent delete", permDel.Refused)
+ }
+ if len(up.Files) != 2 {
+ t.Errorf("undo plan has %d files, want exactly 2 (moved.pdf and old.iso); declined.pdf must be omitted, not merely empty: %+v", len(up.Files), up.Files)
+ }
+}
+
+// TestPlanUndoAcceptsIntactRun pins the trust Task 1 established but never
+// itself exercised through PlanUndo: journal.Entries returning a nil error
+// for a run whose run-start and run-end both parsed cleanly is the signal
+// that the chain is intact, and PlanUndo must build a usable plan from it
+// rather than refuse.
+func TestPlanUndoAcceptsIntactRun(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ logPath := filepath.Join(h, ".local", "state", "krino", "krino.log")
+ entries, err := journal.Entries(logPath, run)
+ if err != nil {
+ t.Fatalf("Entries refused a fully intact run: %v", err)
+ }
+ if entries[len(entries)-1].Action != "run-end" {
+ t.Fatalf("fixture run is not intact: last action %q", entries[len(entries)-1].Action)
+ }
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatalf("PlanUndo refused an intact run: %v", err)
+ }
+ if len(up.Files) != 1 || up.Files[0].Refused != "" {
+ t.Fatalf("intact run did not yield a usable undo plan: %+v", up)
+ }
+}
+
+// TestPlanUndoAcceptsCrashedRun: a run-start with no run-end (the process
+// died mid-run) must still yield a usable undo plan, per Entries' documented
+// window-to-EOF behaviour. If this refused, Task 1's contract and this
+// task's assumption would disagree - worth a ruling, not a workaround.
+func TestPlanUndoAcceptsCrashedRun(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ logPath := filepath.Join(h, ".local", "state", "krino", "krino.log")
+ data, err := os.ReadFile(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
+ if !strings.Contains(lines[len(lines)-1], "\trun-end\t") {
+ t.Fatalf("fixture's last line is not run-end: %q", lines[len(lines)-1])
+ }
+ // Simulate a crash: the process died before writing run-end.
+ truncated := strings.Join(lines[:len(lines)-1], "\n") + "\n"
+ if err := os.WriteFile(logPath, []byte(truncated), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ entries, err := journal.Entries(logPath, run)
+ if err != nil {
+ t.Fatalf("Entries refused a crashed-but-clean run: %v", err)
+ }
+ if len(entries) == 0 {
+ t.Fatal("no entries survived truncation")
+ }
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatalf("PlanUndo refused a crashed run: %v", err)
+ }
+ if len(up.Files) != 1 || up.Files[0].Refused != "" {
+ t.Fatalf("crashed run did not yield a usable undo plan: %+v", up)
+ }
+}
+
+// TestApplyDeclinesLogEachStepAndTouchNothing: a chain that is not named in
+// approved is left completely alone, but still logged (spec §9: "declined
+// files are [logged]"), one entry per step, status "declined".
+func TestApplyDeclinesLogEachStepAndTouchNothing(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ res, err := e.Apply(context.Background(), dp, map[string]bool{}, j, run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Declined != 1 || res.Applied != 0 {
+ t.Errorf("result = %+v; want one declined, none applied", res)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "a.pdf")); err != nil {
+ t.Errorf("a declined file was touched: %v", err)
+ }
+ j.Close()
+
+ entries, err := journal.Entries(filepath.Join(h, ".local", "state", "krino", "krino.log"), run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var declined *journal.Entry
+ for i := range entries {
+ if entries[i].Status == "declined" {
+ declined = &entries[i]
+ }
+ }
+ if declined == nil {
+ t.Fatal("no declined entry was logged")
+ }
+ if declined.Action != "move" || declined.File != "a.pdf" {
+ t.Errorf("declined entry = %+v", *declined)
+ }
+}
+
+// TestApplyChecksContextBetweenFilesNotWithinOne: Ctrl-C finishes the
+// current file's chain, logs it, and stops before the next one - spec §11.
+// The context is already cancelled before Apply is even called, so the
+// boundary check must fire before the first (only actionable) file, proving
+// cancellation is honoured rather than ignored.
+func TestApplyChecksContextBetweenFilesNotWithinOne(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ res, err := e.Apply(ctx, dp, map[string]bool{"a.pdf": true}, j, run)
+ if err == nil {
+ t.Fatal("Apply did not report the cancellation")
+ }
+ if len(res.Files) != 0 || res.Applied != 0 {
+ t.Errorf("result = %+v; want nothing done once already cancelled", res)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "a.pdf")); err != nil {
+ t.Error("a cancelled Apply touched a file")
+ }
+}
+
+// TestApplyUndoRestoresMovedFile: the smallest possible round trip through
+// ApplyUndo, since Task 9's is the only other test that exercises it.
+func TestApplyUndoRestoresMovedFile(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if up.Files[0].Refused != "" {
+ t.Fatalf("undo refused: %s", up.Files[0].Refused)
+ }
+
+ logPath := filepath.Join(h, ".local", "state", "krino", "krino.log")
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j2.Close()
+ undoRun := journal.NewRunID(time.Now())
+ res, err := e.ApplyUndo(context.Background(), up, j2, undoRun)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Applied != 1 || res.Failed != 0 {
+ t.Errorf("undo result = %+v; want one applied, none failed", res)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "a.pdf")); err != nil {
+ t.Errorf("undo did not restore the file: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "Work", "a.pdf")); !os.IsNotExist(err) {
+ t.Error("undo left a copy at the moved-to location")
+ }
+}
+
+// TestApplyUndoSkipsRefusedFiles: rule 4 enforced at execution time too - a
+// refused file must come back from ApplyUndo untouched.
+func TestApplyUndoSkipsRefusedFiles(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+ moved := filepath.Join(h, "dl", "Work", "a.pdf")
+ if err := os.WriteFile(moved, []byte("edited since the run"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if up.Files[0].Refused == "" {
+ t.Fatal("expected the file to be refused")
+ }
+
+ logPath := filepath.Join(h, ".local", "state", "krino", "krino.log")
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j2.Close()
+ res, err := e.ApplyUndo(context.Background(), up, j2, journal.NewRunID(time.Now()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Declined != 1 || res.Applied != 0 {
+ t.Errorf("undo result = %+v; want the refused file declined, nothing applied", res)
+ }
+ if got, err := os.ReadFile(moved); err != nil || string(got) != "edited since the run" {
+ t.Errorf("a refused file was touched: content=%q err=%v", got, err)
+ }
+}
+
+// TestApplyUndoLogsDeclinedFile is fix round 2026-09-12, item 2 of Task 8's
+// review: a file the front end's own review chose not to reverse (Refused
+// empty, Declined set by the caller - PlanUndo itself never sets it) must
+// still be logged, spec §9's "declined files are logged even though nothing
+// happens to them" extended to undo. The file must come back untouched, the
+// run must still get its run-start/run-end boundaries even though nothing
+// was actually reversed, and the logged entry's status must read "declined",
+// never "refused" - which spec §9/§10 give a different meaning (the world
+// changed under us).
+func TestApplyUndoLogsDeclinedFile(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if up.Files[0].Refused != "" {
+ t.Fatalf("expected the file to be reversible, got refused: %s", up.Files[0].Refused)
+ }
+ up.Files[0].Declined = true
+
+ logPath := filepath.Join(h, ".local", "state", "krino", "krino.log")
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j2.Close()
+ undoRun := journal.NewRunID(time.Now())
+ res, err := e.ApplyUndo(context.Background(), up, j2, undoRun)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Declined != 1 || res.Applied != 0 {
+ t.Errorf("undo result = %+v; want the declined file counted, nothing applied", res)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "Work", "a.pdf")); err != nil {
+ t.Errorf("the declined file was moved: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "a.pdf")); !os.IsNotExist(err) {
+ t.Error("the declined file's reversal ran anyway")
+ }
+
+ entries, err := journal.Entries(logPath, undoRun)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if entries[0].Action != "run-start" || entries[len(entries)-1].Action != "run-end" {
+ t.Errorf("boundaries = %q .. %q; a run with only a declined file must still get both", entries[0].Action, entries[len(entries)-1].Action)
+ }
+ // a.pdf's chain moved it into a directory Apply had to create (spec
+ // §10: last-original-step-first means undo-move is logged before its
+ // own undo-mkdir), so more than one entry carries File "a.pdf" -
+ // every one of them must read "declined", and the first must be the
+ // file's own undo-move.
+ var fileEntries []journal.Entry
+ for _, en := range entries {
+ if en.File == "a.pdf" {
+ fileEntries = append(fileEntries, en)
+ }
+ }
+ if len(fileEntries) == 0 {
+ t.Fatal("no entry was logged for the declined file")
+ }
+ if fileEntries[0].Action != "undo-move" {
+ t.Errorf("first step's action = %q, want the file's own undo-move", fileEntries[0].Action)
+ }
+ for _, en := range fileEntries {
+ if en.Status != "declined" {
+ t.Errorf("entry %+v: status = %q, want %q (never \"refused\", which means something else)", en, en.Status, "declined")
+ }
+ }
+}
+
+// TestApplyUndoDecliningEveryFileDoesNotMarkOriginalRunUndone is fix wave
+// item 2 (Important): reproduced by the reviewer via pty as `1 moved
+// (undone)` with the file still filed. The mechanism is
+// journal.Runs' own (see TestRunsDoesNotMarkUndoneWhenEveryFileWasDeclined
+// for that unit-level pin); this is the same defect exercised end to end
+// through a real forward run, a real declined undo, and e.Runs() itself -
+// the exact call `krino log` makes - rather than a hand-built log.
+func TestApplyUndoDecliningEveryFileDoesNotMarkOriginalRunUndone(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if up.Files[0].Refused != "" {
+ t.Fatalf("expected the file to be reversible, got refused: %s", up.Files[0].Refused)
+ }
+ up.Files[0].Declined = true // the front end's own review declined it
+
+ logPath := filepath.Join(h, ".local", "state", "krino", "krino.log")
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ undoRun := journal.NewRunID(time.Now())
+ res, err := e.ApplyUndo(context.Background(), up, j2, undoRun)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j2.Close()
+ if res.Declined != 1 || res.Applied != 0 {
+ t.Fatalf("undo result = %+v; want the declined file counted, nothing applied", res)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "Work", "a.pdf")); err != nil {
+ t.Fatalf("the declined file was moved: %v", err)
+ }
+
+ runs, err := e.Runs(0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ byID := map[string]bool{}
+ for _, r := range runs {
+ byID[r.ID] = r.Undone
+ }
+ if byID[run] {
+ t.Errorf("original run %q marked Undone, but every file's reversal was declined and nothing moved", run)
+ }
+ if byID[undoRun] {
+ t.Errorf("the undo run %q itself must never read as Undone", undoRun)
+ }
+}
+
+func TestRunsDelegatesToJournal(t *testing.T) {
+ _, e, dp, j, run := applyFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ runs, err := e.Runs(0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(runs) != 1 || runs[0].ID != run {
+ t.Errorf("runs = %+v, want one run %q", runs, run)
+ }
+}
+
+// --- Fix round 1 ---
+
+// TestApplyLogsTrashEntryNameInDetail: fix round 1, item 3. The trash entry
+// name must be logged explicitly (Detail), not left to be re-derived from
+// Dst's basename - Dst's shape is internal/apply's contract, not undo's, and
+// the two must not be secretly coupled.
+func TestApplyLogsTrashEntryNameInDetail(t *testing.T) {
+ h := sandbox(t)
+ dl := filepath.Join(h, "dl")
+ if err := os.MkdirAll(dl, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dl, "old.log"), []byte("stale"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ old := time.Now().Add(-time.Hour)
+ os.Chtimes(filepath.Join(dl, "old.log"), old, old)
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{
+ "dl": `(path "~/dl")` + "\n" + `(rule "trash-logs" (when (type log)) (delete))`,
+ })
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims())
+ if err != nil {
+ t.Fatal(err)
+ }
+ logPath := filepath.Join(h, ".local", "state", "krino", "krino.log")
+ j, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ run := journal.NewRunID(time.Now())
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"old.log": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ entries, err := journal.Entries(logPath, run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var trashEntry *journal.Entry
+ for i := range entries {
+ if entries[i].Action == "trash" {
+ trashEntry = &entries[i]
+ }
+ }
+ if trashEntry == nil {
+ t.Fatal("no trash entry was logged")
+ }
+ if trashEntry.Detail == "" {
+ t.Fatal("trash entry's Detail does not carry the trash entry name")
+ }
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if up.Files[0].Refused != "" {
+ t.Fatalf("undo refused: %s", up.Files[0].Refused)
+ }
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j2.Close()
+ res, err := e.ApplyUndo(context.Background(), up, j2, journal.NewRunID(time.Now()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Applied != 1 {
+ t.Errorf("undo result = %+v; want the trashed file restored", res)
+ }
+ if _, err := os.Stat(filepath.Join(h, "dl", "old.log")); err != nil {
+ t.Errorf("undo did not restore the trashed file: %v", err)
+ }
+}
+
+// TestRunUndoStepTrashReadsEntryNameFromDetailNotDst: fix round 1, item 3,
+// isolated. Src is deliberately a path whose basename names no real trash
+// entry; only Original.Detail names the real one. If runUndoStep ever goes
+// back to deriving the name from Dst (or Src), this fails.
+func TestRunUndoStepTrashReadsEntryNameFromDetailNotDst(t *testing.T) {
+ h := sandbox(t)
+ dl := filepath.Join(h, "dl")
+ if err := os.MkdirAll(dl, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ target := filepath.Join(dl, "gone.txt")
+ if err := os.WriteFile(target, []byte("data"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ entry, err := trash.Put(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ step := UndoStep{
+ Action: "undo-trash",
+ Src: "/this/path/does/not/exist/files/wrong-name",
+ Dst: target,
+ Original: journal.Entry{Detail: entry},
+ }
+ sr := runUndoStep(step)
+ if sr.Status != "ok" {
+ t.Fatalf("runUndoStep = %+v; want ok, using Original.Detail's entry name", sr)
+ }
+ if _, err := os.Stat(target); err != nil {
+ t.Errorf("file was not restored: %v", err)
+ }
+}
+
+// TestPlanUndoRefusesFileModifiedWithinSameSecond: fix round 1, item 4. The
+// journal now records ModTime with sub-second precision (RFC3339Nano), so a
+// file rewritten within the same whole second as the run must still be
+// detected as changed - a .Unix()-granularity comparison would miss this
+// and undo would silently move the edited file back over the user's data.
+func TestPlanUndoRefusesFileModifiedWithinSameSecond(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ moved := filepath.Join(h, "dl", "Work", "a.pdf")
+ fi, err := os.Stat(moved)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sec := fi.ModTime().Truncate(time.Second)
+ nudge := 100 * time.Millisecond
+ if sec.Add(nudge).Equal(fi.ModTime()) {
+ nudge = 700 * time.Millisecond // guaranteed different sub-second offset
+ }
+ nudged := sec.Add(nudge)
+ if err := os.Chtimes(moved, nudged, nudged); err != nil {
+ t.Fatal(err)
+ }
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if up.Files[0].Refused == "" {
+ t.Fatal("undo did not refuse a file whose mtime changed within the same second")
+ }
+}
+
+// TestUndoFileStopsAfterFailedFileAffectingStep: fix round 1, item 2. A
+// failed undo-move must stop the rest of that file's reversal - continuing
+// would leave it half undone (spec §10), even though the later step
+// (undo-copy) would, in isolation, have succeeded.
+func TestUndoFileStopsAfterFailedFileAffectingStep(t *testing.T) {
+ h := sandbox(t)
+ keep := filepath.Join(h, "keep.txt")
+ if err := os.WriteFile(keep, []byte("do not trash me"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ j, err := journal.Open(filepath.Join(h, "state", "krino.log"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j.Close()
+ e := &Engine{Now: time.Now}
+
+ uf := UndoFile{
+ File: "f", Dir: "d",
+ Steps: []UndoStep{
+ // Src does not exist, so the rename underneath fails.
+ {Action: "undo-move", Src: filepath.Join(h, "no-such-source"), Dst: filepath.Join(h, "sub", "dst.txt")},
+ {Action: "undo-copy", Src: keep},
+ },
+ }
+ fr, err := e.undoFile(uf, j, "run1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fr.Steps[0].Status != "failed" {
+ t.Fatalf("step 0 = %+v, want failed", fr.Steps[0])
+ }
+ if fr.Steps[1].Status != "skipped" {
+ t.Fatalf("step 1 = %+v, want skipped after the file-affecting failure", fr.Steps[1])
+ }
+ if _, err := os.Stat(keep); err != nil {
+ t.Errorf("the skipped undo-copy still touched its file: %v", err)
+ }
+}
+
+// TestUndoFileContinuesPastFailedMkdir: fix round 1, item 2's other half -
+// a failed undo-mkdir (directory not empty) must NOT stop the rest of the
+// file's reversal, unlike every other action.
+func TestUndoFileContinuesPastFailedMkdir(t *testing.T) {
+ h := sandbox(t)
+ nonEmpty := filepath.Join(h, "nonempty")
+ if err := os.MkdirAll(nonEmpty, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(nonEmpty, "still-here.txt"), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ keep := filepath.Join(h, "keep.txt")
+ if err := os.WriteFile(keep, []byte("trash me, that's fine"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ j, err := journal.Open(filepath.Join(h, "state", "krino.log"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j.Close()
+ e := &Engine{Now: time.Now}
+
+ uf := UndoFile{
+ File: "f", Dir: "d",
+ Steps: []UndoStep{
+ {Action: "undo-mkdir", Src: nonEmpty},
+ {Action: "undo-copy", Src: keep},
+ },
+ }
+ fr, err := e.undoFile(uf, j, "run2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fr.Steps[0].Status != "failed" {
+ t.Fatalf("step 0 = %+v, want failed (not empty)", fr.Steps[0])
+ }
+ if fr.Steps[1].Status != "ok" {
+ t.Fatalf("step 1 = %+v, want ok - a failed undo-mkdir must not stop the rest of the file", fr.Steps[1])
+ }
+ if _, err := os.Stat(keep); !os.IsNotExist(err) {
+ t.Error("undo-copy after the failed mkdir did not run")
+ }
+}
+
+// --- Fix round 2 ---
+
+// TestApplyUndoRefusesWhenDestinationReappearsBeforeExecution: fix round 2,
+// item 1 (Critical). Spec §10 says an undo plan is shown and approved like
+// any other, so there is a real, human-length window between PlanUndo's
+// refuseIfSrcExists check and ApplyUndo actually running - long enough for
+// something else to create a file at the reversal's destination in between.
+// undo-move/undo-rename must re-check at execution time rather than let a
+// bare os.Rename silently replace it and report the step "ok".
+func TestApplyUndoRefusesWhenDestinationReappearsBeforeExecution(t *testing.T) {
+ h, e, dp, j, run := applyFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if up.Files[0].Refused != "" {
+ t.Fatalf("undo refused at planning time: %s", up.Files[0].Refused)
+ }
+
+ // The window spec §10 describes: something creates a file at the
+ // reversal's destination after planning, before execution.
+ reappeared := filepath.Join(h, "dl", "a.pdf")
+ if err := os.WriteFile(reappeared, []byte("someone else's file"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ logPath := filepath.Join(h, ".local", "state", "krino", "krino.log")
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j2.Close()
+ res, err := e.ApplyUndo(context.Background(), up, j2, journal.NewRunID(time.Now()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Failed != 1 || res.Applied != 0 {
+ t.Errorf("undo result = %+v; want the step to fail rather than silently overwrite", res)
+ }
+ if got, err := os.ReadFile(reappeared); err != nil || string(got) != "someone else's file" {
+ t.Errorf("the reappeared file was overwritten: content=%q err=%v", got, err)
+ }
+ moved := filepath.Join(h, "dl", "Work", "a.pdf")
+ if got, err := os.ReadFile(moved); err != nil || string(got) != "one" {
+ t.Errorf("the moved file did not stay where it was: content=%q err=%v", got, err)
+ }
+}
+
+// TestApplyUndoDoesNotCountFailedMkdirAsFailed: fix round 2, item 3. A file
+// whose only failure is an undo-mkdir (a shared directory not yet empty)
+// must not flip ApplyResult.Failed - Task 7 maps that to krino undo's exit
+// code, and ruling 4 (fix round 1, item 1) established that this specific
+// refusal is tidiness, not a hazard.
+func TestApplyUndoDoesNotCountFailedMkdirAsFailed(t *testing.T) {
+ h := sandbox(t)
+ dir := filepath.Join(h, "Work")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ dst := filepath.Join(dir, "a.pdf")
+ if err := os.WriteFile(dst, []byte("moved"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // A sibling file still occupies the directory, so its undo-mkdir must
+ // fail with "not empty" once undo-move has already vacated dst.
+ if err := os.WriteFile(filepath.Join(dir, "sibling.pdf"), []byte("still here"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ src := filepath.Join(h, "a.pdf")
+
+ j, err := journal.Open(filepath.Join(h, "state", "krino.log"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j.Close()
+ e := &Engine{Now: time.Now}
+
+ up := &UndoPlan{Run: "r", Files: []UndoFile{
+ {File: "a.pdf", Dir: "d", Steps: []UndoStep{
+ {Action: "undo-move", Src: dst, Dst: src},
+ {Action: "undo-mkdir", Src: dir},
+ }},
+ }}
+ res, err := e.ApplyUndo(context.Background(), up, j, "run1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Applied != 1 {
+ t.Errorf("Applied = %d, want 1 (the move succeeded)", res.Applied)
+ }
+ if res.Failed != 0 {
+ t.Errorf("Failed = %d, want 0 - a failed undo-mkdir alone must not count as a failure", res.Failed)
+ }
+}
+
+// --- Fix wave (2026-09-12) ---
+
+// overwriteFixture builds a directory where a forward move under
+// (on-conflict overwrite) will displace a pre-existing file at its
+// destination: dl/incoming.pdf moves to dl/Work/incoming.pdf, which already
+// holds a different file (the "victim") the move must trash first. This is
+// the one shape that makes a step's Displaces and another step's Dst name
+// the exact same path (internal/plan/conflict.go's resolveConflict,
+// deliberately), which is what fix wave item 1 (Critical) is about.
+func overwriteFixture(t *testing.T) (string, *Engine, *DirPlan, *journal.Writer, string) {
+ t.Helper()
+ h := sandbox(t)
+ dl := filepath.Join(h, "dl")
+ work := filepath.Join(dl, "Work")
+ if err := os.MkdirAll(work, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ old := time.Now().Add(-time.Hour)
+
+ incoming := filepath.Join(dl, "incoming.pdf")
+ if err := os.WriteFile(incoming, []byte("incoming content"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(incoming, old, old); err != nil {
+ t.Fatal(err)
+ }
+ victim := filepath.Join(work, "incoming.pdf")
+ if err := os.WriteFile(victim, []byte("original victim content"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(victim, old, old); err != nil {
+ t.Fatal(err)
+ }
+
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{
+ "dl": `(path "~/dl")` + "\n" + `(on-conflict overwrite)` + "\n" + `(rule "pdfs" (when (type pdf)) (move "Work"))`,
+ })
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims())
+ if err != nil {
+ t.Fatal(err)
+ }
+ j, err := journal.Open(filepath.Join(h, ".local", "state", "krino", "krino.log"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { j.Close() })
+ return h, e, dp, j, journal.NewRunID(time.Now())
+}
+
+// TestApplyUndoReversesOverwriteRoundTrip is fix wave item 1 (CRITICAL): the
+// end-to-end reproduction of the defect the final-plan review found -
+// `krino undo` could not reverse a run that used (on-conflict overwrite) at
+// all, by construction. reverseStep's planning-time occupancy check judged
+// the displace reversal against the world exactly as it stood before any
+// reversal had run, while the move-back that frees the contested path is
+// ordered to execute first (reversal is last-original-step-first), so the
+// displace reversal was refused every time and, being file-affecting,
+// aborted the whole file's reversal - including the otherwise-safe
+// move-back. This is the first coverage of undo-displace anywhere in the
+// repo (grep undo-displace across every prior test returns nothing), and it
+// is built from a REAL forward run through overwriteFixture's real
+// displacing apply, per the brief: a hand-assembled journal.Entry is
+// exactly what would let a narrower, wrong fix pass while still being
+// wrong.
+func TestApplyUndoReversesOverwriteRoundTrip(t *testing.T) {
+ h, e, dp, j, run := overwriteFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"incoming.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ dest := filepath.Join(h, "dl", "Work", "incoming.pdf")
+ if got, err := os.ReadFile(dest); err != nil || string(got) != "incoming content" {
+ t.Fatalf("forward run did not land as expected: content=%q err=%v", got, err)
+ }
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(up.Files) != 1 {
+ t.Fatalf("undo plan covers %d files, want 1", len(up.Files))
+ }
+ if up.Files[0].Refused != "" {
+ t.Fatalf("undo refused an (on-conflict overwrite) round trip that should be fully reversible: %s", up.Files[0].Refused)
+ }
+ var sawDisplace bool
+ for _, s := range up.Files[0].Steps {
+ if s.Action == "undo-displace" {
+ sawDisplace = true
+ if s.Refused != "" {
+ t.Errorf("undo-displace step itself refused: %s", s.Refused)
+ }
+ }
+ }
+ if !sawDisplace {
+ t.Fatal("no undo-displace step in the plan; the fixture did not exercise the displace path")
+ }
+
+ logPath := filepath.Join(h, ".local", "state", "krino", "krino.log")
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j2.Close()
+ res, err := e.ApplyUndo(context.Background(), up, j2, journal.NewRunID(time.Now()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Applied != 1 || res.Failed != 0 || res.Declined != 0 {
+ t.Fatalf("undo result = %+v; want the one file fully reversed", res)
+ }
+
+ orig := filepath.Join(h, "dl", "incoming.pdf")
+ if got, err := os.ReadFile(orig); err != nil || string(got) != "incoming content" {
+ t.Errorf("the incoming file did not come back to its original path: content=%q err=%v", got, err)
+ }
+ if got, err := os.ReadFile(dest); err != nil || string(got) != "original victim content" {
+ t.Errorf("the displaced original was not restored from the Trash: content=%q err=%v", got, err)
+ }
+}
+
+// TestApplyUndoStillRefusesGenuineOccupant is fix wave item 1's second
+// required test: the projection must only excuse a path an earlier step of
+// THIS SAME chain is about to vacate, never turn every occupancy refusal
+// into a pass. Here something outside the chain entirely - not the
+// displaced original, not the incoming file itself - now occupies the
+// path the move-back needs, and no step of this file's reversal will ever
+// free it.
+func TestApplyUndoStillRefusesGenuineOccupant(t *testing.T) {
+ h, e, dp, j, run := overwriteFixture(t)
+ if _, err := e.Apply(context.Background(), dp, map[string]bool{"incoming.pdf": true}, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+
+ reappeared := filepath.Join(h, "dl", "incoming.pdf")
+ if err := os.WriteFile(reappeared, []byte("someone else's file"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if up.Files[0].Refused == "" {
+ t.Fatal("undo did not refuse a path genuinely occupied by something outside this file's own chain")
+ }
+ if !strings.Contains(up.Files[0].Refused, "already exists") {
+ t.Errorf("Refused = %q, want it to say the path already exists", up.Files[0].Refused)
+ }
+ if got, err := os.ReadFile(reappeared); err != nil || string(got) != "someone else's file" {
+ t.Errorf("the genuine occupant was disturbed just by planning: content=%q err=%v", got, err)
+ }
+}
+
+// TestTallyFileCountsAnAllSkippedFileAsDeclined is fix wave item 4 / Minor
+// 6: a file every one of whose steps came back "skipped" - the shape an
+// approved all-skipped chain used to take - set none of ok/failed/declined
+// in tallyFile, so it fell out of the outcome tally entirely: "0 applied ·
+// 0 failed · 0 declined" for a file the user was asked about and approved.
+// tallyFile must land every file it is given in exactly one bucket; nothing
+// ran and nothing failed, so it belongs in Declined.
+func TestTallyFileCountsAnAllSkippedFileAsDeclined(t *testing.T) {
+ result := &ApplyResult{}
+ steps := []apply.StepResult{
+ {Status: "skipped", Detail: "target exists"},
+ }
+ tallyFile(result, steps, nil)
+ if result.Applied != 0 || result.Failed != 0 || result.Declined != 1 {
+ t.Errorf("result = %+v, want the all-skipped file counted once, as declined", result)
+ }
+}
+
+// TestTallyFileCountsMixedOutcomesOnceEach pins the existing "not mutually
+// exclusive" contract alongside the new all-skipped fallback: a file with
+// one ok, one failed and one declined step must still count toward all
+// three (unchanged behaviour), and the fallback added for the all-skipped
+// case must never fire when any real status is present.
+func TestTallyFileCountsMixedOutcomesOnceEach(t *testing.T) {
+ result := &ApplyResult{}
+ steps := []apply.StepResult{
+ {Status: "ok"},
+ {Status: "failed"},
+ {Status: "declined"},
+ }
+ tallyFile(result, steps, nil)
+ if result.Applied != 1 || result.Failed != 1 || result.Declined != 1 {
+ t.Errorf("result = %+v, want one of each", result)
+ }
+}