// 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) } } // --- Plan 5, Task 1 --- // sharedDestUndoFixture builds a downloads directory with three pdf files // (a.pdf, b.pdf, c.pdf) and a single rule moving all of them into dest, // applies the move, and returns the sandbox home, the loaded engine, the // journal's path and the forward run's ID. // // All three files landing on one destination that this one run creates is // the shape that exercises the run-wide directory retry (Task 1, plan 5): // whichever file's chain first creates dest carries its undo-mkdir step(s), // and that file's own reversal typically runs while its siblings still // occupy dest - refusing the removal correctly, at first. dest may name a // nested path ("Work/Sub"): apply.mkdirAllTracked then records every // directory the move had to create, outermost first, and every one of them // still lands on that same first file's chain. // // Extracted per fix round 1 (Important 2): TestApplyUndoRemovesSharedDirectoryAfterEveryFileReverses // and TestApplyUndoRetryLogsBothMkdirEntriesAndStillMarksOriginalRunUndone // used to duplicate this setup verbatim; TestApplyUndoRetryRemovesNestedDirectoriesDeepestFirst // needed the identical shape with only dest varying, which is what named the // parameter rather than hard-coding "Filed" here. func sharedDestUndoFixture(t *testing.T, dest string) (h string, e *Engine, logPath string, run string) { t.Helper() h = sandbox(t) dl := filepath.Join(h, "dl") if err := os.MkdirAll(dl, 0o755); err != nil { t.Fatal(err) } // Three files that all move into ONE created destination. The bug this // task fixes is that only the file whose chain first creates it ever // carries the undo-mkdir step, and that step is attempted while its // siblings are still inside. for _, n := range []string{"a.pdf", "b.pdf", "c.pdf"} { if err := os.WriteFile(filepath.Join(dl, n), []byte(n), 0o640); err != nil { t.Fatal(err) } } main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": ` (path "~/dl") (min-age 0s) (rule "pdfs" (when (type pdf)) (move "` + dest + `")) `}) loaded, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } e = loaded dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims()) if err != nil { t.Fatal(err) } approved := map[string]bool{} for _, c := range dp.Chains { approved[c.File.Rel] = true } 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, approved, j, run); err != nil { t.Fatal(err) } j.Close() return h, e, logPath, run } func TestApplyUndoRemovesSharedDirectoryAfterEveryFileReverses(t *testing.T) { h, e, logPath, run := sharedDestUndoFixture(t, "Filed") dl := filepath.Join(h, "dl") filed := filepath.Join(dl, "Filed") if _, err := os.Stat(filed); err != nil { t.Fatalf("apply did not create the directory: %v", err) } up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } j2, err := journal.Open(logPath) if err != nil { t.Fatal(err) } res, err := e.ApplyUndo(context.Background(), up, j2, journal.NewRunID(time.Now())) if err != nil { t.Fatal(err) } j2.Close() if res.Failed != 0 { t.Fatalf("undo reported %d failures: %+v", res.Failed, res) } if _, err := os.Stat(filed); !os.IsNotExist(err) { t.Errorf("undo left the created directory behind: %v", err) } for _, n := range []string{"a.pdf", "b.pdf", "c.pdf"} { if _, err := os.Stat(filepath.Join(dl, n)); err != nil { t.Errorf("%s did not come back: %v", n, err) } } } // TestApplyUndoRetryRemovesNestedDirectoriesDeepestFirst pins fix round 1's // Important 1: retryDirRemovals must retry deepest path first. All three // files move into Work/Sub, so Apply's single mkdirAllTracked call creates // both Work and Work/Sub on the FIRST file's own chain (outermost first), // which means that one file's reversal carries two undo-mkdir steps, one for // each directory - and both are refused on that file's own turn, since the // other two files still sit in Work/Sub at that point. // // Retried deepest first, Work/Sub empties out and is removed, and Work - // now itself empty - is removed right after. Retried shallowest first // instead, Work is tried while Work/Sub (now empty, but not yet removed) // still sits inside it, so Work is refused as non-empty and never retried // again in this run; Work/Sub is then removed, leaving the outer Work // directory behind. So end state alone - no directory left over - already // distinguishes correct (deepest-first) ordering from inverted or dropped // ordering; unlike the flat-destination tests above, where only one // directory ever entered `retries`, this is the case built to tell the two // apart. func TestApplyUndoRetryRemovesNestedDirectoriesDeepestFirst(t *testing.T) { h, e, logPath, run := sharedDestUndoFixture(t, "Work/Sub") dl := filepath.Join(h, "dl") work := filepath.Join(dl, "Work") sub := filepath.Join(work, "Sub") if _, err := os.Stat(sub); err != nil { t.Fatalf("apply did not create the nested directory: %v", err) } up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } j2, err := journal.Open(logPath) if err != nil { t.Fatal(err) } res, err := e.ApplyUndo(context.Background(), up, j2, journal.NewRunID(time.Now())) if err != nil { t.Fatal(err) } j2.Close() if res.Failed != 0 { t.Fatalf("undo reported %d failures: %+v", res.Failed, res) } if _, err := os.Stat(sub); !os.IsNotExist(err) { t.Errorf("undo left the nested directory behind: %v", err) } if _, err := os.Stat(work); !os.IsNotExist(err) { t.Errorf("undo left the outer directory behind - retryDirRemovals is not retrying deepest path first: %v", err) } for _, n := range []string{"a.pdf", "b.pdf", "c.pdf"} { if _, err := os.Stat(filepath.Join(dl, n)); err != nil { t.Errorf("%s did not come back: %v", n, err) } } } // TestApplyUndoRetryLogsBothMkdirEntriesAndStillMarksOriginalRunUndone pins // the journal half of the ruling on the point Task 1's brief left open: when // retryDirRemovals succeeds in removing a directory, it appends an // ADDITIONAL journal entry for it - the original "failed" undo-mkdir entry, // recorded on whichever file's chain first created the directory, is never // rewritten or removed - and, because that entry's Action is still // "undo-mkdir" like the first, journal.ranAnyUndoStep continues to exclude // it from what marks a run "(undone)" (the brief's constraint: it "cannot // change whether a run shows as (undone); confirm that rather than assume // it"). func TestApplyUndoRetryLogsBothMkdirEntriesAndStillMarksOriginalRunUndone(t *testing.T) { _, e, logPath, run := sharedDestUndoFixture(t, "Filed") up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } 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.Failed != 0 { t.Fatalf("undo reported %d failures: %+v", res.Failed, res) } entries, err := journal.Entries(logPath, undoRun) if err != nil { t.Fatal(err) } var failed, ok *journal.Entry var mkdirCount int for i := range entries { en := &entries[i] if en.Action != "undo-mkdir" { continue } mkdirCount++ switch en.Status { case "failed": failed = en case "ok": ok = en } } if mkdirCount != 2 { t.Fatalf("undo-mkdir entries = %d, want exactly 2 (the original refusal plus the retry's addition): %+v", mkdirCount, entries) } if failed == nil { t.Fatal("the original refused undo-mkdir entry is missing - it must never be rewritten or removed") } if ok == nil { t.Fatal("no successful undo-mkdir entry was appended for the retry") } if failed.Src != ok.Src { t.Errorf("failed.Src = %q, ok.Src = %q; want the same directory", failed.Src, ok.Src) } if failed.File != ok.File { t.Errorf("failed.File = %q, ok.File = %q; want the retry entry to carry the file that owned the original undo-mkdir", failed.File, ok.File) } if failed.Dir != ok.Dir { t.Errorf("failed.Dir = %q, ok.Dir = %q; want the same directory name", failed.Dir, ok.Dir) } 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 not marked Undone, though every file came back", run) } if byID[undoRun] { t.Errorf("the undo run %q itself must never read as Undone", undoRun) } }