// SPDX-License-Identifier: GPL-3.0-or-later package engine import ( "context" "os" "path/filepath" "testing" "time" ) // twoOverwritingDirs builds a and b, each holding x.pdf, both moving it to // ~/Out with (on-conflict overwrite). func twoOverwritingDirs(t *testing.T, h string) *Engine { t.Helper() old := time.Now().Add(-2 * time.Hour) dirs := map[string]string{} for _, n := range []string{"a", "b"} { p := filepath.Join(h, n, "x.pdf") if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte("from "+n), 0o644); err != nil { t.Fatal(err) } os.Chtimes(p, old, old) dirs[n] = "(path \"~/" + n + "\")\n(on-conflict overwrite)\n(rule \"out\" (move \"~/Out\"))\n" } main := writeConfig(t, h, `(include "a" "b")`, dirs) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } return e } // TestSessionKeepsOnlyAppliedDestinationsClaimed: one session covers a whole // run - its log, run id and claims - and after applying a directory only the // paths its files ended up at stay claimed, so a later directory's overwrite // takes a free name instead of trashing this run's own result (spec §7.4). func TestSessionKeepsOnlyAppliedDestinationsClaimed(t *testing.T) { h := sandbox(t) e := twoOverwritingDirs(t, h) s, err := e.NewSession(false) if err != nil { t.Fatal(err) } defer s.Close() if err := s.OpenLog(); err != nil { t.Fatal(err) } if s.Run() == "" { t.Error("a real session has no run id") } for _, d := range e.Dirs { dp, err := s.Plan(context.Background(), d) if err != nil { t.Fatal(err) } if _, err := s.Apply(context.Background(), dp, map[string]bool{"x.pdf": true}); err != nil { t.Fatal(err) } } for rel, want := range map[string]string{"Out/x.pdf": "from a", "Out/x_1.pdf": "from b"} { if b, err := os.ReadFile(filepath.Join(h, rel)); err != nil || string(b) != want { t.Errorf("%s: %q, %v; want %q", rel, b, err, want) } } if entries, _ := os.ReadDir(filepath.Join(h, ".local", "share", "Trash", "files")); len(entries) != 0 { t.Errorf("the Trash holds %d entries; nothing should have been displaced", len(entries)) } } // TestDrySessionWritesNoLog: a dry session opens no log, so a dry run never // creates the state directory's krino.log (spec §11). func TestDrySessionWritesNoLog(t *testing.T) { h := sandbox(t) e := twoOverwritingDirs(t, h) s, err := e.NewSession(true) if err != nil { t.Fatal(err) } defer s.Close() if err := s.OpenLog(); err != nil { t.Fatal(err) } if s.Run() != "" { t.Errorf("a dry session took a run id: %q", s.Run()) } if _, err := s.Plan(context.Background(), e.Dirs[0]); err != nil { t.Fatal(err) } if _, err := os.Stat(e.Config.LogFile()); !os.IsNotExist(err) { t.Errorf("a dry session touched the log: %v", err) } } // TestSessionLockIsTheDirectorysOwn: the session takes the same lock a // second krino waits for, and releasing it lets the next one in. func TestSessionLockIsTheDirectorysOwn(t *testing.T) { h := sandbox(t) e := twoOverwritingDirs(t, h) s, err := e.NewSession(true) if err != nil { t.Fatal(err) } defer s.Close() l, err := s.Lock(context.Background(), e.Dirs[0], false) if err != nil { t.Fatal(err) } if _, err := s.Lock(context.Background(), e.Dirs[0], false); err == nil { t.Error("the directory was locked twice") } if err := l.Release(); err != nil { t.Fatal(err) } l2, err := s.Lock(context.Background(), e.Dirs[0], false) if err != nil { t.Errorf("the lock was not released: %v", err) } else { l2.Release() } } // TestSessionUndoReversesTheRun: undo goes through the same session as // sorting - its locks, its log, its run id (GUI design §1.3). func TestSessionUndoReversesTheRun(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\" (move \"Out\"))\n"}) s, err := e.NewSession(false) if err != nil { t.Fatal(err) } defer s.Close() locks, err := s.LockDirs(context.Background(), []string{"dl"}, false) if err != nil { t.Fatal(err) } up, err := s.PlanUndo(run) if err != nil { t.Fatal(err) } if _, err := s.ApplyUndo(context.Background(), up); err != nil { t.Fatal(err) } for _, l := range locks { l.Release() } if _, err := os.Stat(filepath.Join(h, "dl", "a.pdf")); err != nil { t.Errorf("undo did not put the file back: %v", err) } } // TestSessionLockDirsReleasesOnFailure: when one directory's lock cannot be // had, the locks already taken are released, so a failed undo leaves none // held. func TestSessionLockDirsReleasesOnFailure(t *testing.T) { h := sandbox(t) e := twoOverwritingDirs(t, h) s, err := e.NewSession(true) if err != nil { t.Fatal(err) } defer s.Close() held, err := s.Lock(context.Background(), e.Dirs[1], false) if err != nil { t.Fatal(err) } if _, err := s.LockDirs(context.Background(), []string{"a", "b"}, false); err == nil { t.Fatal("LockDirs took a lock another holder had") } held.Release() locks, err := s.LockDirs(context.Background(), []string{"a", "b"}, false) if err != nil { t.Fatalf("the first directory's lock was left held: %v", err) } for _, l := range locks { l.Release() } } // TestSessionKeepsEveryAppliedDestinationClaimed: what a directory's files // ended up at stays protected for the whole run, even across a directory // that applies nothing: a later (on-conflict overwrite) takes a free name // instead of trashing an earlier directory's result. func TestSessionKeepsEveryAppliedDestinationClaimed(t *testing.T) { h := sandbox(t) old := time.Now().Add(-2 * time.Hour) dirs := map[string]string{ "alpha": "(path \"~/alpha\")\n(rule \"out\" (move \"~/shared\"))\n", "beta": "(path \"~/beta\")\n(rule \"none\" (when (type zzz)) (move \"~/shared\"))\n", "gamma": "(path \"~/gamma\")\n(on-conflict overwrite)\n(rule \"out\" (move \"~/shared\"))\n", } for _, n := range []string{"alpha", "beta", "gamma"} { p := filepath.Join(h, n, "x.pdf") os.MkdirAll(filepath.Dir(p), 0o755) os.WriteFile(p, []byte("from "+n), 0o644) os.Chtimes(p, old, old) } main := writeConfig(t, h, `(include "alpha" "beta" "gamma")`, dirs) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } s, err := e.NewSession(false) if err != nil { t.Fatal(err) } defer s.Close() for _, d := range e.Dirs { dp, err := s.Plan(context.Background(), d) if err != nil { t.Fatal(err) } if _, err := s.Apply(context.Background(), dp, map[string]bool{"x.pdf": true}); err != nil { t.Fatal(err) } s.FinishDirectory() } for rel, want := range map[string]string{"shared/x.pdf": "from alpha", "shared/x_1.pdf": "from gamma"} { if b, err := os.ReadFile(filepath.Join(h, rel)); err != nil || string(b) != want { t.Errorf("%s: %q, %v; want %q", rel, b, err, want) } } if entries, _ := os.ReadDir(filepath.Join(h, ".local", "share", "Trash", "files")); len(entries) != 0 { t.Errorf("the Trash holds %d entries; an earlier directory's result was displaced", len(entries)) } } // TestSessionDropsUnappliedClaims: a destination a directory planned but did // not apply (declined) is free for the next directory. func TestSessionDropsUnappliedClaims(t *testing.T) { h := sandbox(t) old := time.Now().Add(-2 * time.Hour) dirs := map[string]string{ "alpha": "(path \"~/alpha\")\n(rule \"out\" (move \"~/shared\"))\n", "beta": "(path \"~/beta\")\n(rule \"out\" (move \"~/shared\"))\n", } for _, n := range []string{"alpha", "beta"} { p := filepath.Join(h, n, "x.pdf") os.MkdirAll(filepath.Dir(p), 0o755) os.WriteFile(p, []byte("from "+n), 0o644) os.Chtimes(p, old, old) } main := writeConfig(t, h, `(include "alpha" "beta")`, dirs) e, errs := Load(main) if len(errs) > 0 { t.Fatal(errs) } s, err := e.NewSession(false) if err != nil { t.Fatal(err) } defer s.Close() dp, err := s.Plan(context.Background(), e.Dirs[0]) if err != nil { t.Fatal(err) } if _, err := s.Apply(context.Background(), dp, map[string]bool{}); err != nil { // declined t.Fatal(err) } s.FinishDirectory() dp, err = s.Plan(context.Background(), e.Dirs[1]) if err != nil { t.Fatal(err) } if got := dp.Chains[0].Steps[0].Dst; filepath.Base(got) != "x.pdf" { t.Errorf("beta planned %q; the declined destination should be free", got) } }