diff options
Diffstat (limited to 'gui/internal/model')
| -rw-r--r-- | gui/internal/model/plan.go | 221 | ||||
| -rw-r--r-- | gui/internal/model/plan_test.go | 187 |
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) + } +} |
