// SPDX-License-Identifier: GPL-3.0-or-later package main import ( "bytes" "context" "fmt" "os" "path/filepath" "strings" "testing" "time" "krino/internal/engine" "krino/internal/journal" ) func TestLogListsRunsAndUndoReverses(t *testing.T) { h := matchingFixture(t) if code, _, errOut := runCLI(t, "-y"); code != 0 { t.Fatalf("apply: %d %s", code, errOut) } filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt") if _, err := os.Stat(filed); err != nil { t.Fatalf("nothing was filed: %v", err) } code, out, errOut := runCLI(t, "log") if code != 0 { t.Fatalf("log: %d %s", code, errOut) } if !strings.Contains(out, "moved") || !strings.Contains(out, "dl") { t.Errorf("log output:\n%s", out) } if code, _, errOut = runCLI(t, "undo", "-y"); code != 0 { t.Fatalf("undo: %d %s", code, errOut) } if _, err := os.Stat(filepath.Join(h, "dl", "inv1.txt")); err != nil { t.Errorf("undo did not put the file back: %v", err) } if _, err := os.Stat(filed); !os.IsNotExist(err) { t.Error("the filed copy survived the undo") } if _, out, _ = runCLI(t, "log"); !strings.Contains(out, "undone") { t.Errorf("log does not mark the run undone:\n%s", out) } // Plain undo after an undo continues the run it undid (review M10): // everything came back, so nothing is left and nothing moves. if code, out, errOut := runCLI(t, "undo", "-y"); code != 0 || !strings.Contains(out, "0 applied") { t.Errorf("undo after a complete undo: %d\n%s\n%s", code, out, errOut) } if _, err := os.Stat(filepath.Join(h, "dl", "inv1.txt")); err != nil { t.Errorf("the restored file moved: %v", err) } // Naming the undo run itself is still refused. _, out, _ = runCLI(t, "log") undoRun := strings.Fields(out)[0] if code, _, errOut = runCLI(t, "undo", "-y", undoRun); code == 0 || !strings.Contains(errOut, "itself an undo") { t.Errorf("undoing undo run %s: exit %d %q", undoRun, code, errOut) } } func TestUndoDryRunChangesNothing(t *testing.T) { h := matchingFixture(t) runCLI(t, "-y") filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt") if code, out, _ := runCLI(t, "undo", "-n"); code != 0 || !strings.Contains(out, "undo-move") { t.Errorf("undo -n: %d\n%s", code, out) } if _, err := os.Stat(filed); err != nil { t.Error("undo -n moved a file") } } // TestUndoFailsImmediatelyWithHeldLock is fix round 2026-09-12, item 1: an // undo moves files just as an apply does, so it needs the same per-directory // guard sort's TestSecondRunFailsImmediatelyWithYes already pins for the // forward path (spec §11: "a second krino on the same directory ... fails // immediately with -y"). The lock file is held under the config NAME "dl", // not any filesystem path - UndoFile.Dir is the journal's `dir` column, // which is the directory's name from krino.conf, not its root. // // Ruling (fix round 2026-09-12, follow-up): the lock is acquired before the // plan is even shown, matching cmdSort's own window (acquired before // Plan/review, held across both) rather than only around ApplyUndo - so // this also asserts the refusal is noticed before any plan output reaches // stdout. A version of this test that only checked the exit code and // stderr would pass equally whether the lock were taken early or late, and // so would not be pinning the thing this ruling is actually about. func TestUndoFailsImmediatelyWithHeldLock(t *testing.T) { h := matchingFixture(t) if code, _, errOut := runCLI(t, "-y"); code != 0 { t.Fatalf("apply: %d %s", code, errOut) } filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt") if _, err := os.Stat(filed); err != nil { t.Fatalf("nothing was filed: %v", err) } held := filepath.Join(h, ".local", "state", "krino", "dl.lock") if err := os.MkdirAll(filepath.Dir(held), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(held, []byte(fmt.Sprintf("pid %d\n", os.Getpid())), 0o644); err != nil { t.Fatal(err) } code, out, errOut := runCLI(t, "undo", "-y") if code != 1 || !strings.Contains(errOut, "another krino") { t.Errorf("undo -y against a held lock: %d %q", code, errOut) } if strings.Contains(out, "to reverse") || strings.Contains(out, "undo-move") { t.Errorf("undo printed the plan before noticing the held lock:\n%s", out) } if _, err := os.Stat(filed); err != nil { t.Errorf("undo reversed the file despite the held lock: %v", err) } } // undoFiles builds minimal engine.UndoFile fixtures for reviewUndoFiles/ // reviewUndoPerFile, named by rel path only (Dir left blank - the tests // below never render it). func undoFiles(rels ...string) []engine.UndoFile { out := make([]engine.UndoFile, len(rels)) for i, r := range rels { out[i] = engine.UndoFile{File: r, Steps: []engine.UndoStep{{Action: "undo-move", Src: "/t/" + r, Dst: "/s/" + r}}} } return out } // TestFinalizeUndoPlanMarksUnapprovedAsDeclined is the wiring point for fix // round 2026-09-12, item 2: a refused file rides through untouched (its own // Refused reason is what ApplyUndo checks first), an approved file rides // through untouched too, and anything else - explicitly declined, or never // reached because [d]/[q] cut a per-file review short - comes out with // Declined set rather than being dropped from the plan. func TestFinalizeUndoPlanMarksUnapprovedAsDeclined(t *testing.T) { up := &engine.UndoPlan{Run: "r1", Files: []engine.UndoFile{ {File: "a"}, // index 0: approved {File: "b"}, // index 1: not approved -> declined {File: "c", Refused: "gone"}, // index 2: refused, never declined }} out, _ := finalizeUndoPlan(up, map[int]bool{0: true}, 'c') if len(out.Files) != 3 { t.Fatalf("files = %+v, want all three carried through", out.Files) } if out.Files[0].Declined || out.Files[0].Refused != "" { t.Errorf("approved file changed: %+v", out.Files[0]) } if !out.Files[1].Declined || out.Files[1].Refused != "" { t.Errorf("unapproved file not marked declined: %+v", out.Files[1]) } if out.Files[2].Declined { t.Errorf("a refused file must not also be marked declined: %+v", out.Files[2]) } if out.Files[2].Refused != "gone" { t.Errorf("refused file's reason changed: %+v", out.Files[2]) } } // TestReviewUndoApplyAll: [a] approves every reversible file by index. func TestReviewUndoApplyAll(t *testing.T) { approved, action, err := reviewUndoFiles(strings.NewReader("a"), new(strings.Builder), undoFiles("a", "b"), palette{}) if err != nil { t.Fatal(err) } if action != 'a' || len(approved) != 2 || !approved[0] || !approved[1] { t.Errorf("approved = %v action = %q; want both approved", approved, action) } } // TestReviewUndoSkipAndQuit: [s] and [q] both approve nothing. func TestReviewUndoSkipAndQuit(t *testing.T) { approved, action, _ := reviewUndoFiles(strings.NewReader("s"), new(strings.Builder), undoFiles("a", "b"), palette{}) if action != 's' || len(approved) != 0 { t.Errorf("[s] = %q %v; want nothing approved", action, approved) } approved, action, _ = reviewUndoFiles(strings.NewReader("q"), new(strings.Builder), undoFiles("a", "b"), palette{}) if action != 'q' || len(approved) != 0 { t.Errorf("[q] = %q %v; want nothing approved", action, approved) } } // TestReviewUndoChoosePerFile: [c] then per-file y/n, keyed by index. func TestReviewUndoChoosePerFile(t *testing.T) { approved, action, err := reviewUndoFiles(strings.NewReader("cyn"), new(strings.Builder), undoFiles("a", "b"), palette{}) if err != nil { t.Fatal(err) } if action != 'c' || !approved[0] || approved[1] { t.Errorf("approved = %v action = %q; want only index 0", approved, action) } } // TestReviewUndoWriteStopsAsking: [w] applies what was chosen so far; undo // offers no [t] or [d], so those are rejected keys there. func TestReviewUndoWriteStopsAsking(t *testing.T) { out := new(strings.Builder) approved, action, err := reviewUndoFiles(strings.NewReader("ctdyw"), out, undoFiles("a", "b", "c"), palette{}) if err != nil { t.Fatal(err) } if action != 'w' || !approved[0] || len(approved) != 1 { t.Errorf("approved = %v action = %q; want 'w' with only index 0 decided", approved, action) } if !strings.Contains(out.String(), "'t' is not y, n, a, w or q") || strings.Contains(out.String(), "[t]") { t.Errorf("undo review should reject t and not offer it:\n%s", out) } } // TestReviewUndoRefusedFileNotPrompted is spec §10: a file PlanUndo already // refused is shown (with its reason - covered end to end by // TestUndoRefusesChangedDestination-style flows through cmdUndo) but never // asked about, so a [c] session reading one key per file must not stall // waiting for a key that reviewUndoPerFile never asks for. Three files, one // key each for the two reversible ones ("y", "n"), none for the refused // middle one: if it were prompted, the second key ("n") would answer for it // instead of the third file, and this test would see index 2 approved // instead of unset. func TestReviewUndoRefusedFileNotPrompted(t *testing.T) { files := undoFiles("a", "b", "c") files[1].Refused = "b changed since the run" out := new(strings.Builder) approved, action, err := reviewUndoFiles(strings.NewReader("cyn"), out, files, palette{}) if err != nil { t.Fatal(err) } if action != 'c' || !approved[0] || approved[1] || approved[2] { t.Errorf("approved = %v action = %q; want only index 0 (1 is refused, 2 never reached)", approved, action) } if !strings.Contains(out.String(), "b changed since the run") { t.Errorf("refused file's reason not shown:\n%s", out) } } // TestPrintUndoPlan is fix wave item 3 (Important), rebuilding fix round // 2026-09-12's own golden test: that version hand-built its UndoSteps, // including a Dst on the undo-copy step the real code never sets (Dst is // deliberately left "" - trash.Put only chooses the entry name at execution // time), so it was structurally incapable of catching the bug it was meant // to guard against - an undo-copy row rendering as a bare // "undo-copy → " with nothing said about what it would do to the // user's backup copy, the single most destructive step an undo plan takes. // The same lesson as Task 9's review: a hand-assembled fixture hides a test // that cannot detect a broken copy-undo. This version runs a REAL forward // apply (copy then move, so mkdir, copy and move all appear in one file's // own chain) and a REAL PlanUndo, editing one file's result afterward so // the plan also carries a genuinely refused row, then renders that. func TestPrintUndoPlan(t *testing.T) { h := home(t) dl := filepath.Join(h, "dl") if err := os.MkdirAll(filepath.Join(dl, "Work"), 0o755); err != nil { t.Fatal(err) } old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for _, n := range []string{"inv1.pdf", "notes.pdf"} { p := filepath.Join(dl, n) if err := os.WriteFile(p, []byte("content of "+n), 0o644); err != nil { t.Fatal(err) } if err := os.Chtimes(p, old, old); err != nil { t.Fatal(err) } } if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { t.Fatal(errOut) } // "keep" mixes all three action kinds a single file's own chain can // carry: copy needs a fresh ~/backup (one mkdir), move lands in the // pre-created Work (no mkdir of its own). rules := "(path \"~/dl\")\n(min-age 0s)\n(rule \"keep\" (when (type pdf)) (copy \"~/backup\") (move \"Work\"))\n" if err := os.WriteFile(filepath.Join(h, ".config/krino/dirs/dl.conf"), []byte(rules), 0o644); err != nil { t.Fatal(err) } if code, _, errOut := runCLI(t, "-y"); code != 0 { t.Fatalf("apply: %d %s", code, errOut) } // notes.pdf's moved copy is edited after the run, so PlanUndo genuinely // refuses its reversal - the row this exercises must still say // something true, never render blank. moved := filepath.Join(dl, "Work", "notes.pdf") if err := os.WriteFile(moved, []byte("edited since the run"), 0o644); err != nil { t.Fatal(err) } e, errs := engine.Load(filepath.Join(h, ".config", "krino", "krino.conf")) if len(errs) > 0 { t.Fatal(errs) } runs, err := e.Runs(1) if err != nil || len(runs) != 1 { t.Fatalf("runs = %+v, err = %v", runs, err) } up, err := e.PlanUndo(runs[0].ID) if err != nil { t.Fatal(err) } var buf bytes.Buffer printUndoPlan(&buf, up) out := buf.String() for _, want := range []string{ "2 files · 1 to reverse · 1 refused\n", "undo-move → ~/dl/inv1.pdf\n", "undo-copy ~/backup/inv1.pdf → trash\n", "undo-mkdir ~/backup\n", // Minor 4 / fix wave item 5: the refusal reason must be abbreviated // against $HOME exactly like every step cell above it, not printed // as a raw absolute path. "refused: ~/dl/Work/notes.pdf changed since the run\n", } { if !strings.Contains(out, want) { t.Errorf("output lacks %q:\n%s", want, out) } } if strings.Contains(out, "undo-copy → ") { t.Errorf("undo-copy rendered a blank destination:\n%s", out) } if strings.Contains(out, h) { t.Errorf("output leaked a raw absolute path instead of abbreviating against $HOME:\n%s", out) } } // TestReviewUndoInvalidKeyReprompts mirrors review_test.go's // TestInvalidKeyReprompts for the undo-specific menu. func TestReviewUndoInvalidKeyReprompts(t *testing.T) { out := new(strings.Builder) approved, action, err := reviewUndoFiles(strings.NewReader("zs"), out, undoFiles("a"), palette{}) if err != nil { t.Fatal(err) } if action != 's' || len(approved) != 0 { t.Errorf("approved = %v action = %q; want [s] after the bad key", approved, action) } if !strings.Contains(out.String(), "z") { t.Errorf("no mention of the rejected key:\n%s", out) } } // TestGlobalDryRunBeforeUndo: -n written before the subcommand, the way // krino.1 teaches flags, is a dry run of the undo. It shows the plan, // exits 0, and moves nothing back. func TestGlobalDryRunBeforeUndo(t *testing.T) { h := matchingFixture(t) if code, _, errOut := runCLI(t, "-y"); code != 0 { t.Fatalf("apply: %d %s", code, errOut) } filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt") code, out, errOut := runCLI(t, "-n", "undo") if code != 0 || !strings.Contains(out, "undo-move") { t.Errorf("-n undo: %d %q\n%s", code, errOut, out) } if _, err := os.Stat(filed); err != nil { t.Errorf("-n undo moved a file: %v", err) } if _, out, _ = runCLI(t, "log"); strings.Contains(out, "undone") { t.Errorf("-n undo marked the run undone:\n%s", out) } } // TestGlobalDryRunBeforeUndoConflictsWithYes: -n before the subcommand and // -y after it is the same conflict as both after it: exit 2, nothing // changed. func TestGlobalDryRunBeforeUndoConflictsWithYes(t *testing.T) { h := matchingFixture(t) if code, _, errOut := runCLI(t, "-y"); code != 0 { t.Fatalf("apply: %d %s", code, errOut) } filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt") code, _, errOut := runCLI(t, "-n", "undo", "-y") if code != 2 || !strings.Contains(errOut, "-y and -n cannot be used together") { t.Errorf("-n undo -y: %d %q", code, errOut) } if _, err := os.Stat(filed); err != nil { t.Errorf("-n undo -y moved a file: %v", err) } if _, out, _ := runCLI(t, "log"); strings.Contains(out, "undone") { t.Errorf("-n undo -y marked the run undone:\n%s", out) } } // TestGlobalYesBeforeUndo: -y before the subcommand applies the undo. func TestGlobalYesBeforeUndo(t *testing.T) { h := matchingFixture(t) if code, _, errOut := runCLI(t, "-y"); code != 0 { t.Fatalf("apply: %d %s", code, errOut) } filed := filepath.Join(h, "dl", "Work", "Acme", "inv1.txt") if code, _, errOut := runCLI(t, "-y", "undo"); code != 0 { t.Fatalf("-y undo: %d %s", code, errOut) } if _, err := os.Stat(filepath.Join(h, "dl", "inv1.txt")); err != nil { t.Errorf("-y undo did not put the file back: %v", err) } if _, err := os.Stat(filed); !os.IsNotExist(err) { t.Error("the filed copy survived -y undo") } } // TestUndoWithoutRunContinuesTheLastUndo: when the most recent run is an // undo that could not finish, plain `krino undo` offers what that undo left // instead of refusing because the last run is an undo (review M10). func TestUndoWithoutRunContinuesTheLastUndo(t *testing.T) { h := home(t) dl := filepath.Join(h, "dl") if err := os.MkdirAll(dl, 0o755); err != nil { t.Fatal(err) } old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) p := filepath.Join(dl, "a.pdf") if err := os.WriteFile(p, []byte("one"), 0o644); err != nil { t.Fatal(err) } os.Chtimes(p, old, old) if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } if code, _, errOut := runCLI(t, "new", "dl", dl); code != 0 { t.Fatal(errOut) } rules := "(path \"~/dl\")\n(rule \"r\" (rename \"r-{name}\") (move \"Out\"))\n" os.WriteFile(filepath.Join(h, ".config", "krino", "dirs", "dl.conf"), []byte(rules), 0o644) if code, out, errOut := runCLI(t, "-y"); code != 0 { t.Fatalf("sort: %d\n%s\n%s", code, out, errOut) } // An undo that fails part way: planned, then something takes the // original name before it runs (driven through the engine, since the CLI // plans and applies in one go). e, errs := engine.Load(filepath.Join(h, ".config", "krino", "krino.conf")) if len(errs) > 0 { t.Fatal(errs) } runs, err := e.Runs(1) if err != nil || len(runs) != 1 { t.Fatalf("runs: %v %v", runs, err) } up, err := e.PlanUndo(runs[0].ID) if err != nil { t.Fatal(err) } os.WriteFile(p, []byte("in the way"), 0o644) j, err := journal.Open(e.Config.LogFile()) if err != nil { t.Fatal(err) } time.Sleep(1100 * time.Millisecond) // run ids are per second res, err := e.ApplyUndo(context.Background(), up, j, journal.NewRunID(time.Now())) j.Close() if err != nil || res.Failed != 1 { t.Fatalf("blocked undo: %+v, %v", res, err) } os.Remove(p) code, out, errOut := runCLI(t, "undo", "-n") if code != 0 || !strings.Contains(out, "undo-rename") || strings.Contains(out, "undo-move") { t.Fatalf("undo -n after a failed undo: exit %d\n%s\n%s", code, out, errOut) } } // TestReviewUndoMatchesReview: undo's per-file review behaves as review's // does (review cli F2): n is recorded, each choice is echoed in red, and w // leaves the files it never reached out of the plan, counted as not // reviewed rather than logged as declined. func TestReviewUndoMatchesReview(t *testing.T) { out := new(strings.Builder) files := undoFiles("a", "b", "c") approved, action, err := reviewUndoFiles(strings.NewReader("cynw"), out, files, palette{on: true}) if err != nil { t.Fatal(err) } if action != 'w' || !approved[0] || approved[1] { t.Fatalf("approved = %v action = %q", approved, action) } if v, ok := approved[1]; !ok || v { t.Errorf("b should be decided as no: %v", approved) } for _, want := range []string{"\x1b[31m→ yes\x1b[0m", "\x1b[31m→ no\x1b[0m"} { if !strings.Contains(out.String(), want) { t.Errorf("no %q echo in:\n%q", want, out) } } up := &engine.UndoPlan{Run: "r", Files: files} plan, notReviewed := finalizeUndoPlan(up, approved, action) if notReviewed != 1 || len(plan.Files) != 2 || plan.Files[0].Declined || !plan.Files[1].Declined { t.Errorf("finalize: notReviewed %d, files %+v; want a, declined b, c left out", notReviewed, plan.Files) } } // TestMinAgeRejectedOutsideSortAndExplain: --min-age only changes sorting // and explain; any other command refuses it rather than silently ignoring // a mistyped value, and an empty value is an error (review cli F4). func TestMinAgeRejectedOutsideSortAndExplain(t *testing.T) { matchingFixture(t) for _, args := range [][]string{ {"undo", "-n", "--min-age", "1d"}, {"--min-age", "garbage", "undo", "-n"}, {"check", "--min-age", "1d"}, {"log", "--min-age", "1d"}, {"-n", "--min-age="}, } { if code, _, errOut := runCLI(t, args...); code != 2 || !strings.Contains(errOut, "--min-age") { t.Errorf("krino %q: exit %d, stderr %q; want 2 naming --min-age", args, code, errOut) } } if code, _, errOut := runCLI(t, "-n", "--min-age", "0"); code != 0 { t.Errorf("-n --min-age 0: exit %d %s", code, errOut) } }