// SPDX-License-Identifier: GPL-3.0-or-later package model import ( "context" "errors" "os" "path/filepath" "sort" "strings" "testing" "time" "git.labunix.xyz/krino/internal/engine" "git.labunix.xyz/krino/internal/lock" "git.labunix.xyz/krino/internal/plan" ) // sandboxDir builds a home with one configured directory holding files, and // returns the loaded engine and the home. HOME and every XDG_* live inside // the test's temporary directory, as the engine's own tests do. func sandboxDir(t *testing.T, conf string, files map[string]string) (*engine.Engine, string) { t.Helper() h := t.TempDir() t.Setenv("HOME", h) for _, v := range []string{"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"} { t.Setenv(v, "") } old := time.Now().Add(-2 * time.Hour) for name, body := range files { p := filepath.Join(h, "dl", 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) } os.Chtimes(p, old, old) } cdir := filepath.Join(h, ".config", "krino") if err := os.MkdirAll(filepath.Join(cdir, "dirs"), 0o755); err != nil { t.Fatal(err) } main := filepath.Join(cdir, "krino.conf") os.WriteFile(main, []byte("(include \"dl\")\n"), 0o644) os.WriteFile(filepath.Join(cdir, "dirs", "dl.conf"), []byte(conf), 0o644) e, errs := engine.Load(main) if len(errs) > 0 { t.Fatal(errs) } return e, h } func planTab(t *testing.T, e *engine.Engine) *PlanTab { t.Helper() tab, err := Plan(context.Background(), e, e.Dirs[0]) if err != nil { t.Fatal(err) } t.Cleanup(func() { tab.Close() }) return tab } // TestPlanRowsAndCounts: the tab shows one row per file with steps, a row // for a file krino could not decide about, the rule that matched, and the // counts of the plan's summary line. func TestPlanRowsAndCounts(t *testing.T) { conf := "(path \"~/dl\")\n(max-read 1K)\n(exclude (name \"^keep-\"))\n" + "(rule \"pdfs\" (when (type pdf)) (move \"Docs\"))\n" + "(rule \"secret\" (when (content \"classified\")) (move \"Secret\"))\n" e, _ := sandboxDir(t, conf, map[string]string{ "a.pdf": "one", "keep-b.pdf": "two", "c.txt": strings.Repeat("x", 2048), // over max-read: content unknown "plain.txt": "nothing", }) tab := planTab(t, e) if tab.Counts.Scanned != 4 || tab.Counts.Acting != 1 || tab.Counts.Excluded != 1 { t.Errorf("counts = %+v", tab.Counts) } var acting, attention int for _, r := range tab.Rows { if r.Actable { acting++ if r.Rel != "a.pdf" || r.Rule != "pdfs" || !r.Selected { t.Errorf("acting row = %+v", r) } } // A file no rule could act on that still raised a warning is shown // and never selectable; a file with steps stays selectable even // when another rule warned about it. if len(r.Steps) == 0 { attention++ if r.Rel != "c.txt" || len(r.Warnings) == 0 || r.Selected || r.Actable { t.Errorf("needs-attention row = %+v", r) } } } if acting != 1 || attention != 1 { t.Errorf("rows = %+v", tab.Rows) } } // TestSelectionAndApply: only the selected files are acted on, the rest are // logged as declined, and every row carries its outcome afterwards. func TestSelectionAndApply(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one", "b.pdf": "two"}) tab := planTab(t, e) if tab.SelectedCount() != 2 { t.Fatalf("rows do not start selected: %+v", tab.Rows) } tab.SelectNone() if tab.SelectedCount() != 0 { t.Fatal("SelectNone left rows selected") } for i, r := range tab.Rows { if r.Rel == "a.pdf" { tab.Toggle(i) } } res, err := tab.Apply(context.Background()) if err != nil { t.Fatal(err) } if res.Applied != 1 || res.Declined != 1 { t.Errorf("result = %+v", res) } if _, err := os.Stat(filepath.Join(h, "dl", "Out", "a.pdf")); err != nil { t.Errorf("the selected file was not moved: %v", err) } if _, err := os.Stat(filepath.Join(h, "dl", "b.pdf")); err != nil { t.Errorf("the unselected file was moved: %v", err) } for _, r := range tab.Rows { want := "done" if r.Rel == "b.pdf" { want = "declined" } if r.Outcome != want { t.Errorf("%s: outcome %q, want %q", r.Rel, r.Outcome, want) } } } // TestReplaceWithTrash: "Trash instead" swaps the planned steps for one // trash step, as the terminal review's t key does, and applying it trashes // the file. func TestReplaceWithTrash(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one"}) tab := planTab(t, e) if err := tab.Replace(0, plan.Trash); err != nil { t.Fatal(err) } if tab.Rows[0].Steps[0].Kind != plan.Trash || tab.Rows[0].Rule != "(review)" { t.Fatalf("row = %+v", tab.Rows[0]) } if _, err := tab.Apply(context.Background()); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(h, "dl", "a.pdf")); !os.IsNotExist(err) { t.Errorf("the file was not trashed: %v", err) } if entries, _ := os.ReadDir(filepath.Join(h, ".local", "share", "Trash", "files")); len(entries) != 1 { t.Errorf("the Trash holds %d entries, want 1", len(entries)) } if err := tab.Replace(0, plan.Move); err == nil { t.Error("Replace accepted a move") } } // TestPlanLeavesTheDirectoryAlone: planning changes no file, so a window // can sit open on a plan. func TestPlanLeavesTheDirectoryAlone(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one"}) planTab(t, e) if _, err := os.Stat(filepath.Join(h, "dl", "a.pdf")); err != nil { t.Errorf("planning moved the file: %v", err) } if _, err := os.Stat(filepath.Join(h, "dl", "Out")); !os.IsNotExist(err) { t.Errorf("planning created the destination: %v", err) } } // TestPlanHoldsTheLock: while a plan is open nothing else may work in that // directory - what the window shows stays the truth while the user chooses - // and closing the tab lets the next run in (GUI design §3). func TestPlanHoldsTheLock(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"}) tab := planTab(t, e) if _, err := lock.Acquire(context.Background(), e.Config.LockFile("dl"), false); !errors.Is(err, lock.ErrHeld) { t.Fatalf("an open plan does not hold the lock: %v", err) } if err := tab.Close(); err != nil { t.Fatal(err) } l, err := lock.Acquire(context.Background(), e.Config.LockFile("dl"), false) if err != nil { t.Fatalf("closing the tab did not release the lock: %v", err) } l.Release() if err := tab.Close(); err != nil { t.Errorf("closing twice: %v", err) } } // TestApplyReleasesTheLock: once a plan has been applied it is history, so // the directory is free again without closing the window (GUI design §3). func TestApplyReleasesTheLock(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"}) tab := planTab(t, e) if _, err := tab.Apply(context.Background()); err != nil { t.Fatal(err) } l, err := lock.Acquire(context.Background(), e.Config.LockFile("dl"), false) if err != nil { t.Fatalf("applying did not release the lock: %v", err) } l.Release() if err := tab.Close(); err != nil { t.Errorf("closing an applied tab: %v", err) } } // TestPlanRefusesAHeldDirectory: a directory another krino is working in is // not planned at all, and the message names it. func TestPlanRefusesAHeldDirectory(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"}) held, err := lock.Acquire(context.Background(), e.Config.LockFile("dl"), false) if err != nil { t.Fatal(err) } defer held.Release() if _, err := Plan(context.Background(), e, e.Dirs[0]); !errors.Is(err, lock.ErrHeld) { t.Fatalf("Plan = %v, want lock.ErrHeld", err) } else if !strings.Contains(err.Error(), "dl") { t.Errorf("the message does not name the directory: %v", err) } } // TestRescanForgetsUnusedNames: a destination the last plan claimed but // never used is free again on the next scan, so looking twice at the same // directory does not creep up through name-1, name-2 (spec §7.4). func TestRescanForgetsUnusedNames(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\") (rename \"same.pdf\"))\n" e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one", "b.pdf": "two"}) tab := planTab(t, e) // Each file moves into Out and is then renamed; the second file's name // is taken, so the plan reserves a suffixed one for it. want := destinations(t, tab) if len(want) != 4 || filepath.Base(want[2]) != "same.pdf" || filepath.Base(want[3]) != "same_1.pdf" { t.Fatalf("first plan = %v", want) } // Looking again without applying anything: the names the closed plan // reserved are free, so the second look reads the same as the first. if err := tab.Close(); err != nil { t.Fatal(err) } again, err := Plan(context.Background(), e, e.Dirs[0]) if err != nil { t.Fatal(err) } if got := destinations(t, again); !equal(got, want) { t.Errorf("second plan = %v, want %v", got, want) } // And again after an apply that applied nothing. again.SelectNone() if _, err := again.Apply(context.Background()); err != nil { t.Fatal(err) } if err := again.Close(); err != nil { t.Fatal(err) } third, err := Plan(context.Background(), e, e.Dirs[0]) if err != nil { t.Fatal(err) } defer third.Close() if got := destinations(t, third); !equal(got, want) { t.Errorf("third plan = %v, want %v", got, want) } } // destinations is every step's destination in the tab, sorted. func destinations(t *testing.T, tab *PlanTab) []string { t.Helper() var out []string for _, r := range tab.Rows { for _, st := range r.Steps { if st.Dst != "" { out = append(out, st.Dst) } } } sort.Strings(out) return out } func equal(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // TestReplaceSelected: one choice for every checked file at once - trash // them, or delete them - changing the plan and nothing on disk until Apply. func TestReplaceSelected(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one", "b.pdf": "two", "c.pdf": "three"}) tab := planTab(t, e) tab.SelectNone() for i, r := range tab.Rows { if r.Rel != "c.pdf" { tab.Toggle(i) } } n, err := tab.ReplaceSelected(plan.Trash) if err != nil { t.Fatal(err) } if n != 2 { t.Errorf("changed %d files, want the 2 checked", n) } for _, r := range tab.Rows { want := plan.Trash if r.Rel == "c.pdf" { want = plan.Move } if len(r.Steps) == 0 || r.Steps[0].Kind != want { t.Errorf("%s: steps = %+v, want %s", r.Rel, r.Steps, want) } } // Still nothing on disk. for _, name := range []string{"a.pdf", "b.pdf", "c.pdf"} { if _, err := os.Stat(filepath.Join(h, "dl", name)); err != nil { t.Errorf("%s was touched before Apply: %v", name, err) } } if _, err := tab.Apply(context.Background()); err != nil { t.Fatal(err) } if entries, _ := os.ReadDir(filepath.Join(h, ".local", "share", "Trash", "files")); len(entries) != 2 { t.Errorf("the Trash holds %d files, want 2", len(entries)) } // The unchecked file was declined, so it is where it was, and its plan // still says move - the choice was made for the checked files only. if _, err := os.Stat(filepath.Join(h, "dl", "c.pdf")); err != nil { t.Errorf("the unchecked file did not stay put: %v", err) } } // TestRowsCarrySizeAndAge: a plan's rows know how big each file is and when // it was last written, for the columns that show them. func TestRowsCarrySizeAndAge(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "three bytes and more"}) tab := planTab(t, e) if len(tab.Rows) != 1 { t.Fatalf("rows = %+v", tab.Rows) } fi, err := os.Stat(filepath.Join(h, "dl", "a.pdf")) if err != nil { t.Fatal(err) } if tab.Rows[0].Size != fi.Size() { t.Errorf("size = %d, want %d", tab.Rows[0].Size, fi.Size()) } if !tab.Rows[0].ModTime.Equal(fi.ModTime()) { t.Errorf("mtime = %v, want %v", tab.Rows[0].ModTime, fi.ModTime()) } } // TestAgeText: the units krino's own (age ...) test uses, and nothing for a // file whose time is unknown. func TestAgeText(t *testing.T) { now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) for _, c := range []struct { ago time.Duration want string }{ {30 * time.Minute, "30m"}, {5 * time.Hour, "5h"}, {3 * 24 * time.Hour, "3d"}, {3 * 7 * 24 * time.Hour, "3w"}, {3 * 365 * 24 * time.Hour, "3y"}, } { if got := AgeText(now.Add(-c.ago), now); got != c.want { t.Errorf("%v ago = %q, want %q", c.ago, got, c.want) } } if got := AgeText(time.Time{}, now); got != "" { t.Errorf("an unknown time = %q, want nothing", got) } if got := AgeText(now.Add(time.Hour), now); got != "0m" { t.Errorf("a file from the future = %q, want 0m", got) } } // TestKeepThisCopy: choosing the downloaded copy over the filed one puts it // in the other's place and sends the other to the Trash, where undo can // still reach it. func TestKeepThisCopy(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"dupes\" (when (duplicate \"~/docs\")) (move \"Dupes\"))\n" e, h := sandboxDir(t, conf, map[string]string{"report.pdf": "the same bytes"}) filed := filepath.Join(h, "docs", "work", "report.pdf") if err := os.MkdirAll(filepath.Dir(filed), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filed, []byte("the same bytes"), 0o644); err != nil { t.Fatal(err) } tab := planTab(t, e) if len(tab.Rows) != 1 || tab.Rows[0].DuplicateOf != filed { t.Fatalf("row = %+v, want it a duplicate of %s", tab.Rows[0], filed) } if err := tab.KeepThisCopy(0); err != nil { t.Fatal(err) } step := tab.Rows[0].Steps[0] if step.Kind != plan.Move || step.Dst != filed || step.Displaces != filed { t.Fatalf("step = %+v", step) } if _, err := tab.Apply(context.Background()); err != nil { t.Fatal(err) } // The download is now the filed copy, with its own bytes. if body, err := os.ReadFile(filed); err != nil || string(body) != "the same bytes" { t.Errorf("the kept copy is not in place: %v", err) } if _, err := os.Stat(filepath.Join(h, "dl", "report.pdf")); !os.IsNotExist(err) { t.Errorf("the download is still where it was: %v", err) } // The copy it replaced went to the Trash, not to oblivion. entries, _ := os.ReadDir(filepath.Join(h, ".local", "share", "Trash", "files")) if len(entries) != 1 { t.Errorf("the Trash holds %d files, want the replaced one", len(entries)) } } // TestKeepThisCopyRefusesANonDuplicate: the choice only means something for // a file krino found another copy of. func TestKeepThisCopyRefusesANonDuplicate(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n" e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"}) tab := planTab(t, e) if err := tab.KeepThisCopy(0); err == nil { t.Error("a file that duplicates nothing was accepted") } if err := tab.KeepThisCopy(9); err == nil { t.Error("a row that does not exist was accepted") } } // TestCloseDuringApplyIsRefused: Close releases the directory lock and // closes the log. Called while an apply is running - which the window // allows: saving rules, saving settings, adding a directory and closing the // window all reach it - the engine goes on moving files with the log shut // under it, so a file is moved that no krino undo can see, and the // directory is unlocked while krino is still working in it. func TestCloseDuringApplyIsRefused(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"pdfs\" (when (type pdf)) (move \"Docs\"))\n" e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one", "b.pdf": "two"}) tab, err := Plan(context.Background(), e, e.Dirs[0]) if err != nil { t.Fatal(err) } for i := range tab.Rows { tab.Rows[i].Selected = true } started := make(chan struct{}) done := make(chan struct{}) tab.testBeforeApply = func() { close(started) // Hold the apply open while Close is attempted. <-done } var applyErr error go func() { _, applyErr = tab.Apply(context.Background()) }() <-started if err := tab.Close(); err == nil { t.Error("Close during an apply was allowed: the lock and the log go out from under it") } close(done) // Let the apply finish before the sandbox is torn down. for i := 0; i < 200 && !tab.Applied; i++ { time.Sleep(10 * time.Millisecond) } if applyErr != nil { t.Errorf("the apply itself failed: %v", applyErr) } } // TestKeepThisCopyTwiceIsRefused: "keep this copy" writes a Displaces // straight into the chain. The engine refuses to plan two steps that // displace one path - the second would destroy what the first just put // there - but the window went round that code. Both rows then reported // "done" while the first file was in the Trash. func TestKeepThisCopyTwiceIsRefused(t *testing.T) { conf := "(path \"~/dl\")\n(rule \"dupes\" (when (duplicate)) (move \"Dupes\"))\n" e, h := sandboxDir(t, conf, map[string]string{ "x.pdf": "the same bytes", "y.pdf": "the same bytes", "a.pdf": "the same bytes", }) _ = h tab, err := Plan(context.Background(), e, e.Dirs[0]) if err != nil { t.Fatal(err) } t.Cleanup(func() { tab.Close() }) var dupes []int for i, r := range tab.Rows { if r.DuplicateOf != "" { dupes = append(dupes, i) } } if len(dupes) < 2 { t.Skipf("fixture produced %d duplicate rows, need two", len(dupes)) } if err := tab.KeepThisCopy(dupes[0]); err != nil { t.Fatalf("the first choice was refused: %v", err) } err = tab.KeepThisCopy(dupes[1]) if err == nil { t.Fatal("two files were allowed to replace the same one; the second would trash what the first filed") } if !strings.Contains(err.Error(), "already") { t.Errorf("refusal reads %q; it should say the place is already spoken for", err) } }