// SPDX-License-Identifier: GPL-3.0-or-later package main import ( "bytes" "context" "os" "path/filepath" "strings" "testing" "time" "git.labunix.xyz/krino/internal/engine" "git.labunix.xyz/krino/internal/journal" "git.labunix.xyz/krino/internal/lock" ) 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: 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: 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. // // 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 // test is actually meant to catch. 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") // A real lock, not a file that looks like one: the lock is the // kernel's, so holding it means holding a descriptor. l, err := lock.Acquire(context.Background(), held, false) if err != nil { t.Fatal(err) } defer l.Release() 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: 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 }, Cleanup: []engine.UndoFile{{File: "d"}}} 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 len(out.Cleanup) != 1 || out.Cleanup[0].File != "d" { t.Errorf("Cleanup = %+v, want the plan's directory cleanup carried over", out.Cleanup) } 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 rebuilds an earlier golden test that 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. // 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", // 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. 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: 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. 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"}, {"init", "--min-age", "1d"}, {"new", "--min-age", "1d", "x", "/tmp"}, {"-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) } } // TestIgnoredGlobalFlagsAreRefused: a global flag a command does not use is // refused instead of silently ignored, so "krino -n new ..." or "krino -n // init" - meant as a preview - cannot write config. func TestIgnoredGlobalFlagsAreRefused(t *testing.T) { h := home(t) if code, _, errOut := runCLI(t, "-n", "init"); code != 2 || !strings.Contains(errOut, "-n") { t.Errorf("-n init: exit %d %q", code, errOut) } if _, err := os.Stat(filepath.Join(h, ".config", "krino", "krino.conf")); !os.IsNotExist(err) { t.Fatalf("-n init wrote the config: %v", err) } if code, _, errOut := runCLI(t, "init"); code != 0 { t.Fatal(errOut) } dl := filepath.Join(h, "dl") os.MkdirAll(dl, 0o755) for _, args := range [][]string{ {"-n", "new", "dl", dl}, {"-y", "check"}, {"--json", "log"}, {"-v", "log"}, {"--json", "undo", "-n"}, {"-v", "undo", "-n"}, {"-y", "explain", dl}, {"-n", "explain", dl}, {"--json", "explain", dl}, {"-v", "explain", dl}, } { if code, _, errOut := runCLI(t, args...); code != 2 || !strings.Contains(errOut, "does not take") { t.Errorf("krino %q: exit %d, stderr %q; want 2, refused", args, code, errOut) } } if _, err := os.Stat(filepath.Join(h, ".config", "krino", "dirs", "dl.conf")); !os.IsNotExist(err) { t.Errorf("-n new wrote a directory file: %v", err) } if code, _, errOut := runCLI(t, "log", "-n", "3"); code != 0 { t.Errorf("log -n 3 (its own count flag) was refused: %d %q", code, errOut) } } // TestWaitingForALockSaysSo: every run but -y waits for a held lock, which // the spec intends (§3, §11) - but waiting silently is indistinguishable // from a hang, and the wait has no timeout. A run about to wait must say // what it is waiting for before it blocks. func TestWaitingForALockSaysSo(t *testing.T) { h := matchingFixture(t) held := filepath.Join(h, ".local", "state", "krino", "dl.lock") l, err := lock.Acquire(context.Background(), held, false) if err != nil { t.Fatal(err) } go func() { time.Sleep(250 * time.Millisecond) l.Release() }() code, _, errOut := runCLI(t, "-n") if code != 0 { t.Fatalf("dry run after the lock was released: %d %s", code, errOut) } if !strings.Contains(errOut, "waiting") { t.Errorf("a run that waited for the lock said nothing about it:\n%q", errOut) } if !strings.Contains(errOut, held) { t.Errorf("the notice does not name the lock file %q:\n%q", held, errOut) } }