// SPDX-License-Identifier: GPL-3.0-or-later package engine import ( "context" "os" "path/filepath" "sort" "strings" "testing" "time" "krino/internal/journal" "krino/internal/plan" "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 (review M2). 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 (review M2). 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 (review M7). 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 (review undo F8). 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 (review undo F6). 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 (review M10). 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 (re-review N1). 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 // (re-review N1). 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) } }