aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/model
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 08:24:54 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 08:24:54 +0200
commit4f98392c9a062e9d4dd8363a2ede9a919ad14006 (patch)
tree6adc4058f1bdc8108c645ef1cbf952f824e1f6be /gui/internal/model
parent69e942371823638fbe24a94202189729f2f17ebc (diff)
downloadkrino-4f98392c9a062e9d4dd8363a2ede9a919ad14006.tar.gz
krino-4f98392c9a062e9d4dd8363a2ede9a919ad14006.zip
gui module: the plan tab's model, with tests
Diffstat (limited to 'gui/internal/model')
-rw-r--r--gui/internal/model/plan.go221
-rw-r--r--gui/internal/model/plan_test.go187
2 files changed, 408 insertions, 0 deletions
diff --git a/gui/internal/model/plan.go b/gui/internal/model/plan.go
new file mode 100644
index 0000000..607fa29
--- /dev/null
+++ b/gui/internal/model/plan.go
@@ -0,0 +1,221 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Package model is krino-gui's state and operations: what each tab shows,
+// and what a click does. It holds no GTK, so it can be tested the way the
+// engine's own packages are (GUI design §1.2).
+package model
+
+import (
+ "context"
+ "fmt"
+ "sort"
+
+ "krino/internal/engine"
+ "krino/internal/plan"
+)
+
+// Row is one line of the Plan tab: a file krino would act on, or one it
+// could not decide about.
+type Row struct {
+ Rel string // the file, relative to the directory's root
+ Steps []plan.Step
+ Rule string // the rule that matched first, for the Rule column
+ Warnings []string
+ // Selected is the checkbox. A row that cannot be applied - every step
+ // skipped, or nothing but warnings - is never selected and has no box.
+ Selected bool
+ Actable bool
+ // Outcome is what applying did to this file: "", "done", "failed: ...",
+ // "declined".
+ Outcome string
+}
+
+// PlanTab is the state of one directory's plan.
+type PlanTab struct {
+ Dir *engine.Dir
+ Rows []Row
+ Counts Counts
+ Warnings []string // directory-level
+ Applied bool // this plan has been applied and is now history
+
+ sess *engine.Session
+ dp *engine.DirPlan
+}
+
+// Counts is the plan's summary line.
+type Counts struct {
+ Scanned, Acting, Excluded, Skipped, Unmatched, Warned int
+}
+
+// Plan locks dir and plans it, returning the tab to show. The lock is held
+// until Apply or Close: what the user sees stays the truth while they
+// choose (GUI design §3).
+func Plan(ctx context.Context, sess *engine.Session, d *engine.Dir) (*PlanTab, error) {
+ dp, err := sess.Plan(ctx, d)
+ if err != nil {
+ return nil, err
+ }
+ t := &PlanTab{Dir: d, sess: sess, dp: dp}
+ t.fill()
+ return t, nil
+}
+
+// fill turns the engine's plan into rows and counts.
+func (t *PlanTab) fill() {
+ r := t.dp.Result
+ t.Warnings = append([]string(nil), r.Warnings...)
+ warnings := map[string][]string{}
+ for _, fms := range [][]engine.FileMatch{r.Matched, r.Unmatched} {
+ for _, fm := range fms {
+ if len(fm.Warnings) > 0 {
+ warnings[fm.File.Rel] = fm.Warnings
+ }
+ }
+ }
+ excluded := 0
+ for _, fm := range r.Matched {
+ if fm.Excluded != "" {
+ excluded++
+ }
+ }
+ t.Rows = nil
+ for _, c := range t.dp.Chains {
+ row := Row{Rel: c.File.Rel, Steps: c.Steps, Warnings: warnings[c.File.Rel]}
+ for _, s := range c.Steps {
+ if s.Skip == "" {
+ row.Actable = true
+ }
+ if row.Rule == "" {
+ row.Rule = s.Rule
+ }
+ }
+ row.Selected = row.Actable
+ if len(c.Steps) > 0 {
+ t.Rows = append(t.Rows, row)
+ }
+ delete(warnings, c.File.Rel)
+ }
+ // Files no rule acted on that still raised a warning: krino could not
+ // decide about them, and they are shown but never selectable.
+ var rest []string
+ for rel := range warnings {
+ rest = append(rest, rel)
+ }
+ sort.Strings(rest)
+ for _, rel := range rest {
+ t.Rows = append(t.Rows, Row{Rel: rel, Warnings: warnings[rel]})
+ }
+ t.Counts = Counts{
+ Scanned: len(r.Matched) + len(r.Unmatched) + len(r.Skipped),
+ Excluded: excluded,
+ Skipped: len(r.Skipped),
+ Unmatched: len(r.Unmatched),
+ }
+ for _, row := range t.Rows {
+ if row.Actable {
+ t.Counts.Acting++
+ }
+ if len(row.Warnings) > 0 {
+ t.Counts.Warned++
+ }
+ }
+}
+
+// SelectAll selects every row that can be applied; SelectNone clears them.
+func (t *PlanTab) SelectAll() { t.setAll(true) }
+func (t *PlanTab) SelectNone() { t.setAll(false) }
+
+func (t *PlanTab) setAll(on bool) {
+ for i := range t.Rows {
+ t.Rows[i].Selected = on && t.Rows[i].Actable
+ }
+}
+
+// Toggle flips row i's checkbox; a row that cannot be applied stays off.
+func (t *PlanTab) Toggle(i int) {
+ if i < 0 || i >= len(t.Rows) || !t.Rows[i].Actable {
+ return
+ }
+ t.Rows[i].Selected = !t.Rows[i].Selected
+}
+
+// SelectedCount is how many files Apply would act on.
+func (t *PlanTab) SelectedCount() int {
+ n := 0
+ for _, r := range t.Rows {
+ if r.Selected {
+ n++
+ }
+ }
+ return n
+}
+
+// Replace swaps a row's planned steps for the one step the user chose
+// instead - "Trash instead", "Delete permanently instead" - as the terminal
+// review's t and d keys do, and selects it.
+func (t *PlanTab) Replace(i int, kind plan.Kind) error {
+ if i < 0 || i >= len(t.Rows) {
+ return fmt.Errorf("model: no row %d", i)
+ }
+ if kind != plan.Trash && kind != plan.DeletePermanent {
+ return fmt.Errorf("model: %s is not a replacement action", kind)
+ }
+ rel := t.Rows[i].Rel
+ for j, c := range t.dp.Chains {
+ if c.File.Rel != rel {
+ continue
+ }
+ t.dp.Chains[j].Steps = []plan.Step{{
+ Kind: kind,
+ Rule: "(review)",
+ Src: c.File.Path,
+ Reason: "chosen in review",
+ }}
+ t.Rows[i].Steps = t.dp.Chains[j].Steps
+ t.Rows[i].Rule = "(review)"
+ t.Rows[i].Actable = true
+ t.Rows[i].Selected = true
+ return nil
+ }
+ return fmt.Errorf("model: %s is not in this plan", rel)
+}
+
+// Apply acts on the selected files and logs the rest as declined, exactly
+// as choosing per file in the terminal does. Each row then carries its
+// outcome.
+func (t *PlanTab) Apply(ctx context.Context) (*engine.ApplyResult, error) {
+ approved := map[string]bool{}
+ for _, r := range t.Rows {
+ if r.Selected {
+ approved[r.Rel] = true
+ }
+ }
+ res, err := t.sess.Apply(ctx, t.dp, approved)
+ t.Applied = true
+ if res != nil {
+ t.record(res)
+ }
+ return res, err
+}
+
+// record writes each file's outcome onto its row.
+func (t *PlanTab) record(res *engine.ApplyResult) {
+ byRel := map[string]string{}
+ for _, fr := range res.Files {
+ outcome := "done"
+ for _, sr := range fr.Steps {
+ switch sr.Status {
+ case "failed":
+ outcome = "failed: " + sr.Detail
+ case "declined":
+ outcome = "declined"
+ }
+ }
+ byRel[fr.File.Rel] = outcome
+ }
+ for i, r := range t.Rows {
+ if o, ok := byRel[r.Rel]; ok {
+ t.Rows[i].Outcome = o
+ }
+ }
+}
diff --git a/gui/internal/model/plan_test.go b/gui/internal/model/plan_test.go
new file mode 100644
index 0000000..c157b1d
--- /dev/null
+++ b/gui/internal/model/plan_test.go
@@ -0,0 +1,187 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "krino/internal/engine"
+ "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, *engine.Session) {
+ t.Helper()
+ s, err := e.NewSession(false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { s.Close() })
+ tab, err := Plan(context.Background(), s, e.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ return tab, s
+}
+
+// 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)
+ }
+}