// SPDX-License-Identifier: GPL-3.0-or-later package engine import ( "context" "os" "path/filepath" "sort" "strings" "testing" "time" "git.labunix.xyz/krino/internal/journal" "git.labunix.xyz/krino/internal/plan" "git.labunix.xyz/krino/internal/trash" ) // appliedRun makes each directory of files under the sandbox home (a map of // directory name to file name to content, every file two hours old), writes // rules for each, and applies every chain of every directory in one run. It // returns the engine, the run id, the home directory and the log path. func appliedRun(t *testing.T, files map[string]map[string]string, rules map[string]string) (*Engine, string, string, string) { t.Helper() h := sandbox(t) old := time.Now().Add(-2 * time.Hour) var names []string for dir, fs := range files { names = append(names, dir) for name, body := range fs { p := filepath.Join(h, dir, name) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } 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) } } } sort.Strings(names) main := writeConfig(t, h, `(include "`+strings.Join(names, `" "`)+`")`, rules) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } logPath := filepath.Join(h, ".local", "state", "krino", "krino.log") j, err := journal.Open(logPath) if err != nil { t.Fatal(err) } defer j.Close() run := journal.NewRunID(time.Now()) claims := plan.NewClaims() for _, d := range e.Dirs { dp, err := e.Plan(context.Background(), d, claims) if err != nil { t.Fatal(err) } approved := map[string]bool{} for _, c := range dp.Chains { approved[c.File.Rel] = true } res, err := e.Apply(context.Background(), dp, approved, j, run) if err != nil || res.Failed != 0 { t.Fatalf("apply %s: %v, %+v", d.Name, err, res) } } return e, run, h, logPath } // undoFileNamed returns the plan's file for dir and name, failing the test // when there is none. func undoFileNamed(t *testing.T, up *UndoPlan, dir, name string) UndoFile { t.Helper() for _, f := range up.Files { if f.Dir == dir && f.File == name { return f } } t.Fatalf("no %s/%s in the undo plan: %+v", dir, name, up.Files) return UndoFile{} } // TestUndoRefusesAReusedTrashEntry: the Trash is emptied and another file of // the same name trashed after the run; undo must not restore that file in // place of the one the run trashed. func TestUndoRefusesAReusedTrashEntry(t *testing.T) { e, run, h, _ := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one"}}, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (rename \"r-{name}\") (delete))\n"}) if err := os.RemoveAll(trash.Dir()); err != nil { t.Fatal(err) } other := filepath.Join(h, "Documents", "r-a.pdf") if err := os.MkdirAll(filepath.Dir(other), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(other, []byte("an unrelated document"), 0o644); err != nil { t.Fatal(err) } if entry, err := trash.Put(other); err != nil || entry != "r-a.pdf" { t.Fatalf("trash.Put = %q, %v", entry, err) } up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } if f := undoFileNamed(t, up, "dl", "a.pdf"); !strings.Contains(f.Refused, "trash entry") { t.Errorf("Refused = %q; want a refusal naming the trash entry", f.Refused) } } // TestUndoRefusesATrashEntryThatChanged: a trash entry that is no longer the // file the run put there (its size changed) is refused, like a moved file // that changed. func TestUndoRefusesATrashEntryThatChanged(t *testing.T) { e, run, _, _ := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one"}}, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (delete))\n"}) if err := os.WriteFile(filepath.Join(trash.Dir(), "files", "a.pdf"), []byte("a longer, different body"), 0o644); err != nil { t.Fatal(err) } up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } if f := undoFileNamed(t, up, "dl", "a.pdf"); f.Refused == "" { t.Error("a changed trash entry was not refused") } } // TestUndoKeepsSameNamedFilesOfTwoDirectoriesApart: a.pdf from dl and // a.pdf from scans, moved in one run, are two files to undo, each refused or // restored on its own. func TestUndoKeepsSameNamedFilesOfTwoDirectoriesApart(t *testing.T) { e, run, h, logPath := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "from dl"}, "scans": {"a.pdf": "from scans"}}, map[string]string{ "dl": "(path \"~/dl\")\n(rule \"r\" (move \"~/Archive\"))\n", "scans": "(path \"~/scans\")\n(rule \"r\" (move \"~/Archive\"))\n", }) up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } if len(up.Files) != 2 { t.Fatalf("undo plan has %d files, want 2: %+v", len(up.Files), up.Files) } scansCopy := undoFileNamed(t, up, "scans", "a.pdf").Steps[0].Src if err := os.WriteFile(scansCopy, []byte("from scans, edited since"), 0o644); err != nil { t.Fatal(err) } up, err = e.PlanUndo(run) if err != nil { t.Fatal(err) } if undoFileNamed(t, up, "scans", "a.pdf").Refused == "" || undoFileNamed(t, up, "dl", "a.pdf").Refused != "" { t.Fatalf("want only the edited scans file refused: %+v", up.Files) } j, err := journal.Open(logPath) if err != nil { t.Fatal(err) } defer j.Close() if _, err := e.ApplyUndo(context.Background(), up, j, journal.NewRunID(time.Now())); err != nil { t.Fatal(err) } if b, err := os.ReadFile(filepath.Join(h, "dl", "a.pdf")); err != nil || string(b) != "from dl" { t.Errorf("dl/a.pdf after undo: %q, %v", b, err) } } // TestUndoRechecksAtExecution: a file edited after the undo was planned (for // example while its review was open) is not moved back. func TestUndoRechecksAtExecution(t *testing.T) { e, run, h, logPath := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one"}}, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (move \"Out\"))\n"}) up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } moved := filepath.Join(h, "dl", "Out", "a.pdf") if err := os.WriteFile(moved, []byte("edited during review"), 0o644); err != nil { t.Fatal(err) } j, err := journal.Open(logPath) if err != nil { t.Fatal(err) } defer j.Close() res, err := e.ApplyUndo(context.Background(), up, j, journal.NewRunID(time.Now())) if err != nil { t.Fatal(err) } if res.Failed != 1 { t.Errorf("Failed = %d, want 1", res.Failed) } if b, _ := os.ReadFile(moved); string(b) != "edited during review" { t.Errorf("the edited file was moved or changed: %q", b) } } // TestUndoLeavesNoDirectoriesBehind: undo passing a file back through a // directory it had already removed for another file recreates it; that // directory must be gone again when the undo ends. func TestUndoLeavesNoDirectoriesBehind(t *testing.T) { e, run, h, logPath := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one", "b.pdf": "two"}}, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (move \"Out/{mtime:%Y}\") (move \"Out\"))\n"}) up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } j, err := journal.Open(logPath) if err != nil { t.Fatal(err) } defer j.Close() if res, err := e.ApplyUndo(context.Background(), up, j, journal.NewRunID(time.Now())); err != nil || res.Failed != 0 { t.Fatalf("undo: %v, %+v", err, res) } if _, err := os.Stat(filepath.Join(h, "dl", "Out")); !os.IsNotExist(err) { t.Errorf("dl/Out is left behind: %v", err) } } // TestUndoCanBeFinishedAfterAFailure: an undo whose last reversal failed // (something took the original name) leaves the file part way back; once // the obstacle is gone, undoing the same run again offers only the step // that is left, and finishes it. func TestUndoCanBeFinishedAfterAFailure(t *testing.T) { e, run, h, logPath := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one"}}, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (rename \"r-{name}\") (move \"Out\"))\n"}) up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } blocker := filepath.Join(h, "dl", "a.pdf") if err := os.WriteFile(blocker, []byte("in the way"), 0o644); err != nil { t.Fatal(err) } undo := func(up *UndoPlan) *ApplyResult { t.Helper() j, err := journal.Open(logPath) if err != nil { t.Fatal(err) } defer j.Close() res, err := e.ApplyUndo(context.Background(), up, j, journal.NewRunID(time.Now())) if err != nil { t.Fatal(err) } return res } if res := undo(up); res.Failed != 1 { t.Fatalf("first undo: Failed = %d, want 1 (the rename back is blocked)", res.Failed) } if err := os.Remove(blocker); err != nil { t.Fatal(err) } time.Sleep(10 * time.Millisecond) // a new run id again, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } f := undoFileNamed(t, again, "dl", "a.pdf") if f.Refused != "" || len(f.Steps) != 1 || f.Steps[0].Action != "undo-rename" { t.Fatalf("second plan: %+v; want one undo-rename step", f) } if res := undo(again); res.Failed != 0 || res.Applied != 1 { t.Fatalf("second undo: %+v", res) } if b, err := os.ReadFile(filepath.Join(h, "dl", "a.pdf")); err != nil || string(b) != "one" { t.Errorf("dl/a.pdf = %q, %v; want the original back", b, err) } } // TestApplyReportsAStepThatCouldNotBeLogged: when the log stops accepting // writes mid-chain, the step that already ran is named in the error - file, // action and where the file is now - so the user can find what undo cannot // see. func TestApplyReportsAStepThatCouldNotBeLogged(t *testing.T) { h := sandbox(t) p := filepath.Join(h, "dl", "a.pdf") os.MkdirAll(filepath.Dir(p), 0o755) os.WriteFile(p, []byte("one"), 0o644) old := time.Now().Add(-2 * time.Hour) os.Chtimes(p, old, old) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (rename \"r-{name}\") (move \"Out\"))\n"}) 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, "state", "krino.log")) if err != nil { t.Fatal(err) } calls := 0 e.Now = func() time.Time { calls++ if calls == 3 { // run-start and the rename are logged; the move is not j.Close() } return time.Now() } _, err = e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, "R") if err == nil { t.Fatal("apply succeeded with a closed log") } for _, want := range []string{"a.pdf", "move", "could not be logged", "Out/r-a.pdf"} { if !strings.Contains(err.Error(), want) { t.Errorf("error %q does not mention %q", err, want) } } } // TestUndoRefusesOnlyTheFileWithADamagedLine: a crash that cuts one file's // log line refuses that file; the other files of the run are still undone. func TestUndoRefusesOnlyTheFileWithADamagedLine(t *testing.T) { e, run, h, logPath := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one", "b.pdf": "two"}}, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (move \"Out\"))\n"}) raw, err := os.ReadFile(logPath) if err != nil { t.Fatal(err) } lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") for i, l := range lines { if strings.Contains(l, "\tb.pdf\t") && strings.Contains(l, "\tmove\t") { lines[i] = l[:len(l)/2] // cut mid-write } } if err := os.WriteFile(logPath, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { t.Fatal(err) } up, err := e.PlanUndo(run) if err != nil { t.Fatalf("PlanUndo refused the whole run: %v", err) } if f := undoFileNamed(t, up, "dl", "b.pdf"); !strings.Contains(f.Refused, "damaged") { t.Errorf("b.pdf: Refused %q; want its damaged log named", f.Refused) } if f := undoFileNamed(t, up, "dl", "a.pdf"); f.Refused != "" { t.Errorf("a.pdf refused: %q", f.Refused) } j, err := journal.Open(logPath) if err != nil { t.Fatal(err) } defer j.Close() if _, err := e.ApplyUndo(context.Background(), up, j, journal.NewRunID(time.Now())); err != nil { t.Fatal(err) } if b, err := os.ReadFile(filepath.Join(h, "dl", "a.pdf")); err != nil || string(b) != "one" { t.Errorf("a.pdf not restored: %q %v", b, err) } } // TestResumedUndoStillRefusesAChangedFile: an undo that stopped after its // first reversal must not let that reversal vouch for the file later: the // file edited in between is refused when the undo is resumed. func TestResumedUndoStillRefusesAChangedFile(t *testing.T) { e, run, h, logPath := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one"}}, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (rename \"r-{name}\") (move \"Out\"))\n"}) up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } first := undoFileNamed(t, up, "dl", "a.pdf") if len(first.Steps) < 2 || first.Steps[0].Action != "undo-move" { t.Fatalf("unexpected plan: %+v", first) } // An undo that ran only its first reversal, then stopped. j, err := journal.Open(logPath) if err != nil { t.Fatal(err) } undoRun := journal.NewRunID(time.Now().Add(time.Second)) if err := j.Append(journal.Entry{Time: time.Now(), Run: undoRun, Action: "run-start", Status: "ok", Detail: journal.UndoOf(run)}); err != nil { t.Fatal(err) } partial := first partial.Steps = first.Steps[:1] if _, err := e.undoFile(partial, j, undoRun); err != nil { t.Fatal(err) } j.Close() // The file, back at its renamed name, is edited before the undo resumes. if err := os.WriteFile(filepath.Join(h, "dl", "r-a.pdf"), []byte("edited in between"), 0o644); err != nil { t.Fatal(err) } again, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } if f := undoFileNamed(t, again, "dl", "a.pdf"); f.Refused == "" { t.Errorf("the edited file is offered again: %+v", f) } } // TestUndoDoesNotOfferOnlyADirectoryRemoval: when a directory the run made // still holds a file of the user's, the restored file's remaining // directory removal is not offered on every later undo. func TestUndoDoesNotOfferOnlyADirectoryRemoval(t *testing.T) { e, run, h, logPath := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one"}}, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (move \"Out\"))\n"}) if err := os.WriteFile(filepath.Join(h, "dl", "Out", "notes.txt"), []byte("mine"), 0o644); err != nil { t.Fatal(err) } up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } j, err := journal.Open(logPath) if err != nil { t.Fatal(err) } if _, err := e.ApplyUndo(context.Background(), up, j, journal.NewRunID(time.Now())); err != nil { t.Fatal(err) } j.Close() again, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } if len(again.Files) != 0 { t.Errorf("after a complete undo, still offered: %+v", again.Files) } } // TestApplyLogsEachStepAsItCompletes: the first step's log entry is written // before the second step runs - observed from the clock the log asks for // each entry's time - so a run killed mid-chain leaves what it did undoable. func TestApplyLogsEachStepAsItCompletes(t *testing.T) { h := sandbox(t) p := filepath.Join(h, "dl", "a.pdf") os.MkdirAll(filepath.Dir(p), 0o755) os.WriteFile(p, []byte("one"), 0o644) old := time.Now().Add(-2 * time.Hour) os.Chtimes(p, old, old) main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (rename \"r-{name}\") (move \"Out\"))\n"}) 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, "state", "krino.log")) if err != nil { t.Fatal(err) } defer j.Close() calls := 0 e.Now = func() time.Time { calls++ if calls == 2 { // the rename's own entry is about to be written if _, err := os.Lstat(filepath.Join(h, "dl", "Out", "r-a.pdf")); err == nil { t.Error("the move had already run when the rename was logged: steps are logged after the whole chain") } } return time.Now() } if _, err := e.Apply(context.Background(), dp, map[string]bool{"a.pdf": true}, j, "R"); err != nil { t.Fatal(err) } } // TestUndoRefusesATrashEntryRecordedForAnotherPath: a trash entry with the // size and mtime the run logged, whose trashinfo now names another original // path, belongs to another file and is refused. func TestUndoRefusesATrashEntryRecordedForAnotherPath(t *testing.T) { e, run, h, _ := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one"}}, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (delete))\n"}) info := filepath.Join(trash.Dir(), "info", "a.pdf.trashinfo") body := "[Trash Info]\nPath=" + filepath.Join(h, "elsewhere", "a.pdf") + "\nDeletionDate=2026-09-14T10:00:00\n" if err := os.WriteFile(info, []byte(body), 0o600); err != nil { t.Fatal(err) } up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } if f := undoFileNamed(t, up, "dl", "a.pdf"); !strings.Contains(f.Refused, "belongs to another file") { t.Errorf("Refused = %q; want the trash entry named as another file's", f.Refused) } } // TestFinishedUndoRemovesADirectoryLeftEmpty: a directory made by one file's // chain and still holding another file is not offered on its own, but once // that other file's reversal empties it, the undo removes it. func TestFinishedUndoRemovesADirectoryLeftEmpty(t *testing.T) { e, run, h, logPath := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one", "b.pdf": "two"}}, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (move \"Out\"))\n"}) out := filepath.Join(h, "dl", "Out") undo := func(decline string) { t.Helper() up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } for i := range up.Files { up.Files[i].Declined = up.Files[i].File == decline } j, err := journal.Open(logPath) if err != nil { t.Fatal(err) } defer j.Close() if _, err := e.ApplyUndo(context.Background(), up, j, journal.NewRunID(time.Now())); err != nil { t.Fatal(err) } } up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } other := "" for _, f := range up.Files { made := false for _, s := range f.Steps { made = made || s.Action == "undo-mkdir" } if !made { other = f.File } } undo(other) // the file that made Out goes back; the other still holds Out if _, err := os.Stat(out); err != nil { t.Fatalf("Out went while %s still held it: %v", other, err) } undo("") // the other goes back, leaving Out empty if _, err := os.Lstat(out); !os.IsNotExist(err) { t.Errorf("Out is still there after the undo finished: %v", err) } } // TestUndoRunWithADamagedLineIsStillAnUndo: a damaged line in an undo run's // log does not make that run look like an ordinary one that can be undone. func TestUndoRunWithADamagedLineIsStillAnUndo(t *testing.T) { e, run, _, logPath := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one"}}, map[string]string{"dl": "(path \"~/dl\")\n(rule \"r\" (rename \"r-{name}\") (move \"Out\"))\n"}) up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } j, err := journal.Open(logPath) if err != nil { t.Fatal(err) } undoRun := journal.NewRunID(time.Now().Add(time.Second)) if _, err := e.ApplyUndo(context.Background(), up, j, undoRun); err != nil { t.Fatal(err) } j.Close() f, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0) if err != nil { t.Fatal(err) } f.WriteString(time.Now().UTC().Format(time.RFC3339) + "\t" + undoRun + "\tdl\ta.pdf\t9\tundo-mo\n") f.Close() if _, err := e.PlanUndo(undoRun); err == nil || !strings.Contains(err.Error(), "itself an undo") { t.Errorf("PlanUndo(undo run with a damaged line) = %v; want refused as an undo", err) } } // TestUndoOverwriteThenMove: a move that replaced an existing file and was // then moved on is undone whole - the moved file goes back, and the file it // replaced is restored to the path the later reversal vacates. func TestUndoOverwriteThenMove(t *testing.T) { e, run, h, logPath := appliedRun(t, map[string]map[string]string{"dl": {"a.pdf": "one", "Out/a.pdf": "old"}}, map[string]string{"dl": "(path \"~/dl\")\n(ignore \"Out/\")\n(recursive yes)\n(rule \"r\" (on-conflict overwrite) (move \"Out\") (move \"Out2\"))\n"}) if b, _ := os.ReadFile(filepath.Join(h, "dl", "Out2", "a.pdf")); string(b) != "one" { t.Fatalf("setup: the chain did not run as planned") } up, err := e.PlanUndo(run) if err != nil { t.Fatal(err) } for _, f := range up.Files { if f.Refused != "" { t.Fatalf("%s refused: %s (%+v)", f.File, f.Refused, f.Steps) } } j, err := journal.Open(logPath) if err != nil { t.Fatal(err) } defer j.Close() if _, err := e.ApplyUndo(context.Background(), up, j, journal.NewRunID(time.Now())); err != nil { t.Fatal(err) } for rel, want := range map[string]string{"a.pdf": "one", "Out/a.pdf": "old"} { if b, err := os.ReadFile(filepath.Join(h, "dl", rel)); err != nil || string(b) != want { t.Errorf("%s after undo: %q, %v; want %q", rel, b, err, want) } } } // TestProjectionSeesAPathAnEarlierStepWillFill: a path not on disk yet that // a queued reversal will put a file at is occupied for the steps after it, // and free again once a later one moves that file on (the "occupied" half // of the projection had no test that could fail). func TestProjectionSeesAPathAnEarlierStepWillFill(t *testing.T) { x := filepath.Join(t.TempDir(), "x.pdf") p := newUndoProjection() if p.occupiedNow(x) { t.Fatal("an empty path reads as occupied") } p.record(UndoStep{Action: "undo-move", Src: x + ".elsewhere", Dst: x}) if !p.occupiedNow(x) { t.Error("a path a queued undo-move fills reads as free") } p.record(UndoStep{Action: "undo-rename", Src: x, Dst: x + ".back"}) if p.occupiedNow(x) { t.Error("a path a later reversal vacates still reads as occupied") } }