aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/model
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 14:13:26 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 14:13:26 +0200
commit85d65ebe0adf1b156324a3a4c220e415a79ba9ce (patch)
tree4e648edce13cbc370e2685475944cafc2236f44a /gui/internal/model
parent6ef83d6bdfb6120f9e1fbd145e0bc463196103d1 (diff)
downloadkrino-85d65ebe0adf1b156324a3a4c220e415a79ba9ce.tar.gz
krino-85d65ebe0adf1b156324a3a4c220e415a79ba9ce.zip
gui: Rules tab - the file as text, checked as you type, tested and saved
Diffstat (limited to 'gui/internal/model')
-rw-r--r--gui/internal/model/rules.go261
-rw-r--r--gui/internal/model/rules_test.go241
2 files changed, 502 insertions, 0 deletions
diff --git a/gui/internal/model/rules.go b/gui/internal/model/rules.go
new file mode 100644
index 0000000..1b2eec8
--- /dev/null
+++ b/gui/internal/model/rules.go
@@ -0,0 +1,261 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+
+ "krino/internal/cond"
+ "krino/internal/config"
+ "krino/internal/engine"
+ "krino/internal/xdg"
+)
+
+// ErrChangedOnDisk is Save's refusal when the file was edited elsewhere
+// since the editor read it: saving would throw that edit away, so the
+// caller is asked what to do (GUI design §5.3).
+var ErrChangedOnDisk = errors.New("the file changed on disk since it was opened")
+
+// Rules is one directory's own configuration file, open for editing. Only
+// dirs/NAME.conf is edited; krino.conf is not, in version 1 (GUI design §5).
+type Rules struct {
+ Name string // the directory
+ File string // its file, absolute
+ Text string // what the editor holds, saved or not
+ Diags []*config.Diag
+
+ e *engine.Engine
+ saved string // the text as last read from or written to disk
+ stamp [32]byte // what was on disk then, to catch another editor
+}
+
+// RuleFiles is every included directory that has a file of its own, in the
+// order krino.conf includes them.
+func RuleFiles(e *engine.Engine) []string {
+ var out []string
+ for _, d := range e.Dirs {
+ if _, err := os.Stat(config.DirFile(e.MainFile, d.Name)); err == nil {
+ out = append(out, d.Name)
+ }
+ }
+ return out
+}
+
+// OpenRules reads the file of the included directory called name.
+func OpenRules(e *engine.Engine, name string) (*Rules, error) {
+ found := false
+ for _, d := range e.Dirs {
+ if d.Name == name {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return nil, fmt.Errorf("model: %s is not an included directory", name)
+ }
+ file := config.DirFile(e.MainFile, name)
+ text, err := os.ReadFile(file)
+ if err != nil {
+ return nil, err
+ }
+ r := &Rules{Name: name, File: file, Text: string(text), e: e,
+ saved: string(text), stamp: sha256.Sum256(text)}
+ return r, nil
+}
+
+// SetText replaces what the editor holds. Nothing is written until Save.
+func (r *Rules) SetText(s string) { r.Text = s }
+
+// Modified reports whether the text differs from what is on disk.
+func (r *Rules) Modified() bool { return r.Text != r.saved }
+
+// Revert throws the unsaved text away.
+func (r *Rules) Revert() { r.Text = r.saved }
+
+// Reload re-reads the file, dropping unsaved text - what to do when
+// something else has edited it.
+func (r *Rules) Reload() error {
+ text, err := os.ReadFile(r.File)
+ if err != nil {
+ return err
+ }
+ r.Text, r.saved, r.stamp = string(text), string(text), sha256.Sum256(text)
+ return nil
+}
+
+// Check loads the whole configuration with this file's unsaved text in
+// place of what is on disk, and reports every problem it finds - in this
+// file or in another, since one file's text can break another's include.
+// Nothing is written.
+func (r *Rules) Check() []*config.Diag {
+ _, diags := r.load()
+ r.Diags = diags
+ return diags
+}
+
+// ErrorsHere is the subset of the last Check's diagnostics that belong to
+// this file, for marking lines in the editor.
+func (r *Rules) ErrorsHere() []*config.Diag {
+ var out []*config.Diag
+ for _, d := range r.Diags {
+ if d.File == r.File {
+ out = append(out, d)
+ }
+ }
+ return out
+}
+
+// load builds an engine from the unsaved text.
+func (r *Rules) load() (*engine.Engine, []*config.Diag) {
+ over := map[string][]byte{r.File: []byte(r.Text)}
+ e, diags := engine.LoadWith(r.e.MainFile, over)
+ if e != nil {
+ e.CacheDir = r.e.CacheDir
+ }
+ return e, diags
+}
+
+// Explain answers what the unsaved rules would do to one file - "Test on
+// file" - as text. It can take seconds: content is extracted, so callers
+// run it off the main loop. Nothing is written, and the file is not
+// touched (GUI design §5.3).
+func (r *Rules) Explain(ctx context.Context, path string) (string, error) {
+ e, diags := r.load()
+ if len(diags) > 0 {
+ return "", fmt.Errorf("%s", diags[0])
+ }
+ x, err := e.ExplainWithChain(ctx, xdg.Expand(path))
+ if err != nil {
+ return "", err
+ }
+ return explainText(x), nil
+}
+
+// Save writes the text, keeping what was there as NAME.conf.bak. It
+// refuses a file that will not load - a window must not leave krino unable
+// to run - and refuses to overwrite an edit made elsewhere since the file
+// was opened (GUI design §5.3).
+func (r *Rules) Save() error {
+ if diags := r.Check(); len(diags) > 0 {
+ return fmt.Errorf("%s", diags[0])
+ }
+ on, err := os.ReadFile(r.File)
+ if err != nil {
+ return err
+ }
+ if sha256.Sum256(on) != r.stamp {
+ return ErrChangedOnDisk
+ }
+ // The backup is the text being replaced, under the file's own
+ // permissions: a rules file can hold real tax numbers, and a backup
+ // readable by everyone would leak them.
+ mode := os.FileMode(0o600)
+ if fi, err := os.Stat(r.File); err == nil {
+ mode = fi.Mode().Perm()
+ }
+ if err := os.WriteFile(r.File+".bak", on, mode); err != nil {
+ return err
+ }
+ text := []byte(r.Text)
+ if err := config.Replace(r.File, text); err != nil {
+ return err
+ }
+ r.saved, r.stamp = r.Text, sha256.Sum256(text)
+ return nil
+}
+
+// SaveOverwriting saves over an edit made elsewhere - the Overwrite the
+// caller offers after ErrChangedOnDisk. The other edit is not lost: it
+// becomes NAME.conf.bak, as any replaced text does.
+func (r *Rules) SaveOverwriting() error {
+ on, err := os.ReadFile(r.File)
+ if err != nil {
+ return err
+ }
+ r.stamp = sha256.Sum256(on)
+ return r.Save()
+}
+
+// explainText renders an explanation for the Test on file pane: every
+// exclude and rule with each test's answer, the captures a matching rule
+// took, and the chain the file alone would get. It is the GUI's own
+// rendering - krino explain(1) prints the same traces but no captures or
+// chain.
+func explainText(x *engine.Explanation) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "%s (directory %s)\n", xdg.Abbrev(x.File.Path), x.Dir.Name)
+ if x.Skip != "" {
+ fmt.Fprintf(&b, "krino would not look at this file: %s\n", x.Skip)
+ }
+ if x.Excluded != "" {
+ fmt.Fprintf(&b, "krino would set this file aside: %s\n", x.Excluded)
+ }
+ b.WriteString("\n")
+ for _, xt := range x.Excludes {
+ status := "no"
+ if xt.Match {
+ status = "MATCH"
+ }
+ fmt.Fprintf(&b, "%s: %s\n", xt.Text, status)
+ writeTrace(&b, xt.Trace)
+ }
+ for _, rt := range x.Rules {
+ if rt.Stopped != "" {
+ fmt.Fprintf(&b, "rule %s: not evaluated, %s\n", rt.Rule.Name, rt.Stopped)
+ continue
+ }
+ status := "no"
+ switch {
+ case rt.Match:
+ status = "MATCH"
+ case rt.Trace != nil && rt.Trace.Unknown:
+ status = "undecided"
+ }
+ fmt.Fprintf(&b, "rule %s: %s\n", rt.Rule.Name, status)
+ writeTrace(&b, rt.Trace)
+ writeCaptures(&b, rt.Captures)
+ }
+ if x.NoDelete != "" {
+ fmt.Fprintf(&b, "\nkrino would skip deleting this file: %s\n", x.NoDelete)
+ }
+ if len(x.Chain) > 0 {
+ b.WriteString("\nthis file alone would get:\n")
+ for _, s := range x.Chain {
+ switch {
+ case s.Skip != "":
+ fmt.Fprintf(&b, " %s skipped: %s\n", s.Kind, s.Skip)
+ case s.Dst == "":
+ fmt.Fprintf(&b, " %s\n", s.Kind)
+ default:
+ fmt.Fprintf(&b, " %s -> %s\n", s.Kind, xdg.Abbrev(s.Dst))
+ }
+ }
+ }
+ return b.String()
+}
+
+// writeTrace writes a condition's tree, indented two spaces.
+func writeTrace(b *strings.Builder, t *cond.Trace) {
+ if t == nil {
+ return
+ }
+ var buf bytes.Buffer
+ t.Format(&buf)
+ for _, line := range strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") {
+ b.WriteString(" " + line + "\n")
+ }
+}
+
+// writeCaptures lists what a matching rule's patterns captured, numbered as
+// its actions' {1}, {2} ... see them.
+func writeCaptures(b *strings.Builder, captures []string) {
+ for i, c := range captures {
+ fmt.Fprintf(b, " {%d} = %s\n", i+1, c)
+ }
+}
diff --git a/gui/internal/model/rules_test.go b/gui/internal/model/rules_test.go
new file mode 100644
index 0000000..c7fa384
--- /dev/null
+++ b/gui/internal/model/rules_test.go
@@ -0,0 +1,241 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "krino/internal/engine"
+)
+
+// TestOpenAndCheck: the editor opens a directory's own file, reports the
+// mistakes in unsaved text with their positions, and says so when there are
+// none (GUI design §5.3).
+func TestOpenAndCheck(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+
+ r, err := OpenRules(e, "dl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.Text != conf {
+ t.Fatalf("text = %q, want the file as written", r.Text)
+ }
+ if diags := r.Check(); len(diags) != 0 {
+ t.Fatalf("a good file reports %v", diags)
+ }
+ if r.Modified() {
+ t.Error("an untouched file counts as modified")
+ }
+
+ // An unknown placeholder is refused at check time (plan 12 task 3), and
+ // the position is the action's, inside this file.
+ r.SetText("(path \"~/dl\")\n(rule \"all\" (move \"Out/{nope}\"))\n")
+ if !r.Modified() {
+ t.Error("edited text does not count as modified")
+ }
+ diags := r.Check()
+ if len(diags) != 1 {
+ t.Fatalf("diags = %v, want one", diags)
+ }
+ if diags[0].File != r.File || diags[0].Pos.Line != 2 {
+ t.Errorf("diag = %s, want it in %s at line 2", diags[0], r.File)
+ }
+ if !strings.Contains(diags[0].Msg, "nope") {
+ t.Errorf("message does not name the placeholder: %s", diags[0].Msg)
+ }
+ // Checking never writes: the file on disk still holds the good text.
+ on, _ := os.ReadFile(r.File)
+ if string(on) != conf {
+ t.Errorf("check wrote to the file: %q", on)
+ }
+}
+
+// TestSaveKeepsABackup: saving writes the text, keeps what was there as
+// NAME.conf.bak, and the engine then loads the new rules (GUI design §5.3).
+func TestSaveKeepsABackup(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ r, err := OpenRules(e, "dl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ next := "(path \"~/dl\")\n(rule \"all\" (move \"Elsewhere\"))\n"
+ r.SetText(next)
+ if err := r.Save(); err != nil {
+ t.Fatal(err)
+ }
+ if on, _ := os.ReadFile(r.File); string(on) != next {
+ t.Errorf("file = %q, want the new text", on)
+ }
+ bak := filepath.Join(filepath.Dir(r.File), "dl.conf.bak")
+ if on, _ := os.ReadFile(bak); string(on) != conf {
+ t.Errorf("backup = %q, want the previous text", on)
+ }
+ if r.Modified() {
+ t.Error("a saved file still counts as modified")
+ }
+ // The saved rules are what krino now reads.
+ fresh := reload(t, h)
+ tab, err := Plan(context.Background(), fresh, fresh.Dirs[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tab.Close()
+ if len(tab.Rows) != 1 || len(tab.Rows[0].Steps) == 0 ||
+ !strings.Contains(tab.Rows[0].Steps[0].Dst, "Elsewhere") {
+ t.Errorf("the saved rule is not in effect: %+v", tab.Rows)
+ }
+}
+
+// TestSaveRefusesBrokenRules: a file that will not load is not written, so
+// a window cannot leave krino unable to run (GUI design §5.3).
+func TestSaveRefusesBrokenRules(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ r, err := OpenRules(e, "dl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.SetText("(path \"~/dl\")\n(rule \"all\" (move \"Out/{nope}\"))\n")
+ err = r.Save()
+ if err == nil {
+ t.Fatal("Save accepted a file with errors")
+ }
+ if !strings.Contains(err.Error(), "nope") {
+ t.Errorf("the refusal does not say what is wrong: %v", err)
+ }
+ if on, _ := os.ReadFile(r.File); string(on) != conf {
+ t.Errorf("the refused save wrote anyway: %q", on)
+ }
+ if _, err := os.Stat(filepath.Join(filepath.Dir(r.File), "dl.conf.bak")); !os.IsNotExist(err) {
+ t.Error("the refused save left a backup")
+ }
+}
+
+// TestSaveRefusesAFileChangedOnDisk: something else edited the file while
+// the window had it open, so saving would lose that edit; the caller is
+// told, and can Reload (GUI design §5.3).
+func TestSaveRefusesAFileChangedOnDisk(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ r, err := OpenRules(e, "dl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ elsewhere := "(path \"~/dl\")\n(rule \"all\" (move \"Elsewhere\"))\n"
+ if err := os.WriteFile(r.File, []byte(elsewhere), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ r.SetText("(path \"~/dl\")\n(rule \"all\" (move \"Mine\"))\n")
+ if err := r.Save(); !errors.Is(err, ErrChangedOnDisk) {
+ t.Fatalf("Save = %v, want ErrChangedOnDisk", err)
+ }
+ if on, _ := os.ReadFile(r.File); string(on) != elsewhere {
+ t.Errorf("the refused save overwrote the other edit: %q", on)
+ }
+ // Overwrite keeps the other edit as the backup rather than losing it.
+ if err := r.SaveOverwriting(); err != nil {
+ t.Fatal(err)
+ }
+ if on, _ := os.ReadFile(r.File); !strings.Contains(string(on), "Mine") {
+ t.Errorf("overwrite did not save: %q", on)
+ }
+ if on, _ := os.ReadFile(r.File + ".bak"); string(on) != elsewhere {
+ t.Errorf("backup = %q, want the edit that was overwritten", on)
+ }
+
+ // And from the other direction: reload takes the other edit and drops
+ // ours; saving then works.
+ if err := os.WriteFile(r.File, []byte(elsewhere), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ if r.Text != elsewhere {
+ t.Errorf("reload = %q, want what is on disk", r.Text)
+ }
+ r.SetText("(path \"~/dl\")\n(rule \"all\" (move \"Mine\"))\n")
+ if err := r.Save(); err != nil {
+ t.Errorf("save after reload: %v", err)
+ }
+}
+
+// TestExplainWithUnsavedText: Test on file answers for the text in the
+// editor, not for what is on disk (GUI design §5.3).
+func TestExplainWithUnsavedText(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"pdfs\" (when (type pdf)) (move \"Out\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ r, err := OpenRules(e, "dl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ file := filepath.Join(h, "dl", "a.pdf")
+
+ text, err := r.Explain(context.Background(), file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(text, "rule pdfs: MATCH") || !strings.Contains(text, "Out") {
+ t.Errorf("explanation of the saved rules = %q", text)
+ }
+
+ r.SetText("(path \"~/dl\")\n(rule \"pdfs\" (when (type image)) (move \"Out\"))\n")
+ text, err = r.Explain(context.Background(), file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(text, "MATCH") {
+ t.Errorf("the unsaved rule was ignored: %q", text)
+ }
+ // The unsaved text is never written by a test run.
+ if on, _ := os.ReadFile(r.File); string(on) != conf {
+ t.Errorf("Test on file wrote to the config: %q", on)
+ }
+}
+
+// TestExplainReportsBrokenRules: testing a file against text that will not
+// load says so instead of answering from the saved rules.
+func TestExplainReportsBrokenRules(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"pdfs\" (when (type pdf)) (move \"Out\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ r, err := OpenRules(e, "dl")
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.SetText("(path \"~/dl\")\n(rule \"pdfs\" (when (type pdf)) (move \"Out/{nope}\"))\n")
+ if _, err := r.Explain(context.Background(), filepath.Join(h, "dl", "a.pdf")); err == nil {
+ t.Fatal("Test on file answered from a file that will not load")
+ }
+}
+
+// TestRuleFilesLister: the picker offers every included directory that has
+// a file of its own.
+func TestRuleFilesLister(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, _ := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ if got := RuleFiles(e); len(got) != 1 || got[0] != "dl" {
+ t.Errorf("RuleFiles = %v, want [dl]", got)
+ }
+ if _, err := OpenRules(e, "nosuch"); err == nil {
+ t.Error("OpenRules accepted a directory that is not included")
+ }
+}
+
+// reload builds a fresh engine from the sandbox's config, the way the next
+// krino run would read it.
+func reload(t *testing.T, home string) *engine.Engine {
+ t.Helper()
+ e, errs := engine.Load(filepath.Join(home, ".config", "krino", "krino.conf"))
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ return e
+}