aboutsummaryrefslogtreecommitdiff
path: root/gui/internal/model
diff options
context:
space:
mode:
Diffstat (limited to 'gui/internal/model')
-rw-r--r--gui/internal/model/cond.go126
-rw-r--r--gui/internal/model/cond_test.go94
-rw-r--r--gui/internal/model/newdir.go20
-rw-r--r--gui/internal/model/newdir_test.go72
4 files changed, 312 insertions, 0 deletions
diff --git a/gui/internal/model/cond.go b/gui/internal/model/cond.go
new file mode 100644
index 0000000..04cc4d1
--- /dev/null
+++ b/gui/internal/model/cond.go
@@ -0,0 +1,126 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "fmt"
+ "strings"
+
+ "krino/internal/sexp"
+)
+
+// CondOps are the forms that hold other conditions.
+var CondOps = map[string]bool{"and": true, "or": true, "not": true}
+
+// CondTests are the tests a condition can make, in the order a picker
+// offers them.
+var CondTests = []string{"type", "name", "path", "content", "size", "age", "duplicate", "matched"}
+
+// Cond is one condition in the tree the forms editor shows: either a test,
+// with its arguments as the file writes them, or one of and / or / not
+// holding the conditions under it (GUI design ยง5.1).
+type Cond struct {
+ Kind string
+ Args string
+ Children []*Cond
+}
+
+// ParseCond reads a parsed form into the tree. Anything that is not a list
+// - which the config parser refuses anyway - comes back as a test with the
+// text as its arguments, so nothing is ever silently dropped.
+func ParseCond(n *sexp.Node) *Cond {
+ if n == nil {
+ return &Cond{Kind: "type"}
+ }
+ if n.Kind != sexp.List || len(n.Children) == 0 {
+ return &Cond{Kind: n.String()}
+ }
+ head := n.Head()
+ if CondOps[head] {
+ c := &Cond{Kind: head}
+ for _, child := range n.Args() {
+ c.Children = append(c.Children, ParseCond(child))
+ }
+ return c
+ }
+ var args []string
+ for _, a := range n.Args() {
+ args = append(args, a.String())
+ }
+ return &Cond{Kind: head, Args: strings.Join(args, " ")}
+}
+
+// Text writes the condition back as krino reads it.
+func (c *Cond) Text() string {
+ if c == nil {
+ return ""
+ }
+ if CondOps[c.Kind] {
+ parts := make([]string, 0, len(c.Children))
+ for _, child := range c.Children {
+ if t := child.Text(); t != "" {
+ parts = append(parts, t)
+ }
+ }
+ if len(parts) == 0 {
+ return ""
+ }
+ return "(" + c.Kind + " " + strings.Join(parts, " ") + ")"
+ }
+ args := strings.TrimSpace(c.Args)
+ if args == "" {
+ return "(" + c.Kind + ")"
+ }
+ return "(" + c.Kind + " " + args + ")"
+}
+
+// Parse checks that a condition is one krino can read, and says where it is
+// wrong if it is not.
+func (c *Cond) Parse() error {
+ text := c.Text()
+ if text == "" {
+ return fmt.Errorf("%s has nothing under it", c.Kind)
+ }
+ nodes, err := sexp.Parse("form", []byte(text))
+ if err != nil {
+ return err
+ }
+ if len(nodes) != 1 {
+ return fmt.Errorf("%s is not one condition", text)
+ }
+ return nil
+}
+
+// Add puts a new condition under an operator; a test gets none.
+func (c *Cond) Add(kind string) *Cond {
+ if !CondOps[c.Kind] {
+ return nil
+ }
+ child := &Cond{Kind: kind}
+ c.Children = append(c.Children, child)
+ return child
+}
+
+// Remove takes one condition out of the tree, wherever it is, and reports
+// whether it found it. The root itself is never removed by this.
+func (c *Cond) Remove(target *Cond) bool {
+ for i, child := range c.Children {
+ if child == target {
+ c.Children = append(c.Children[:i], c.Children[i+1:]...)
+ return true
+ }
+ if child.Remove(target) {
+ return true
+ }
+ }
+ return false
+}
+
+// Walk calls f for every condition in the tree, depth first, with how deep
+// it sits and the operator holding it (nil for the roots).
+func (c *Cond) Walk(depth int, parent *Cond, f func(node, parent *Cond, depth int)) {
+ f(c, parent, depth)
+ for _, child := range c.Children {
+ child.Walk(depth+1, c, f)
+ }
+}
diff --git a/gui/internal/model/cond_test.go b/gui/internal/model/cond_test.go
new file mode 100644
index 0000000..7aa4772
--- /dev/null
+++ b/gui/internal/model/cond_test.go
@@ -0,0 +1,94 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "strings"
+ "testing"
+
+ "krino/internal/sexp"
+)
+
+// parseOne reads one condition from text, for the tests below.
+func parseOne(t *testing.T, text string) *Cond {
+ t.Helper()
+ nodes, err := sexp.Parse("test", []byte(text))
+ if err != nil || len(nodes) != 1 {
+ t.Fatalf("%q does not parse: %v", text, err)
+ }
+ return ParseCond(nodes[0])
+}
+
+// TestCondRoundTrip: a condition read into the tree and written back is the
+// same condition, however deep it nests.
+func TestCondRoundTrip(t *testing.T) {
+ for _, text := range []string{
+ `(type pdf)`,
+ `(name "^faktura" "^fv")`,
+ `(duplicate)`,
+ `(and (type pdf) (content "invoice"))`,
+ `(or (and (type doc) (age > 90d)) (not (duplicate "~/docs")))`,
+ `(not (and (name "a;b") (size > 10M)))`,
+ } {
+ c := parseOne(t, text)
+ if got := c.Text(); got != text {
+ t.Errorf("round trip of %q gave %q", text, got)
+ }
+ if err := c.Parse(); err != nil {
+ t.Errorf("%q does not parse after the round trip: %v", text, err)
+ }
+ }
+}
+
+// TestCondTree: an operator holds its conditions, and the tree says how
+// deep each one sits.
+func TestCondTree(t *testing.T) {
+ c := parseOne(t, `(and (type pdf) (or (name "a") (name "b")))`)
+ if c.Kind != "and" || len(c.Children) != 2 {
+ t.Fatalf("tree = %+v", c)
+ }
+ var depths []string
+ c.Walk(0, nil, func(node, parent *Cond, depth int) {
+ depths = append(depths, strings.Repeat(" ", depth)+node.Kind)
+ })
+ want := []string{"and", " type", " or", " name", " name"}
+ if strings.Join(depths, "|") != strings.Join(want, "|") {
+ t.Errorf("walk = %v, want %v", depths, want)
+ }
+}
+
+// TestCondAddAndRemove: conditions go under an operator and come out again;
+// a test holds none.
+func TestCondAddAndRemove(t *testing.T) {
+ c := parseOne(t, `(and (type pdf))`)
+ added := c.Add("content")
+ if added == nil || len(c.Children) != 2 {
+ t.Fatalf("after adding: %+v", c)
+ }
+ added.Args = `"invoice"`
+ if got := c.Text(); got != `(and (type pdf) (content "invoice"))` {
+ t.Errorf("text = %q", got)
+ }
+ if !c.Remove(added) || len(c.Children) != 1 {
+ t.Errorf("removing left %+v", c)
+ }
+ if c.Children[0].Add("name") != nil {
+ t.Error("a test took a condition under it")
+ }
+ // Removing something that is not there changes nothing.
+ if c.Remove(&Cond{Kind: "name"}) {
+ t.Error("removed a condition that is not in the tree")
+ }
+}
+
+// TestCondEmptyOperator: an operator with nothing under it writes nothing
+// rather than "(and)", which krino would refuse, and says so when asked.
+func TestCondEmptyOperator(t *testing.T) {
+ c := &Cond{Kind: "and"}
+ if got := c.Text(); got != "" {
+ t.Errorf("text = %q, want nothing", got)
+ }
+ if err := c.Parse(); err == nil {
+ t.Error("an empty operator passed the check")
+ }
+}
diff --git a/gui/internal/model/newdir.go b/gui/internal/model/newdir.go
new file mode 100644
index 0000000..d0df6db
--- /dev/null
+++ b/gui/internal/model/newdir.go
@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "krino/internal/config"
+ "krino/internal/engine"
+)
+
+// AddDirectory writes dirs/NAME.conf from the template with path filled in
+// and adds NAME to krino.conf's include - exactly what `krino new NAME
+// PATH` does, through the same code, so a directory made in the window is
+// indistinguishable from one made on the command line (his report that the
+// window had no way to add one, 2026-09-17).
+//
+// It returns the new file's path. The caller reloads the engine: until it
+// does, the window knows nothing of the new directory.
+func AddDirectory(e *engine.Engine, name, path string) (string, error) {
+ return config.NewDir(e.MainFile, name, path)
+}
diff --git a/gui/internal/model/newdir_test.go b/gui/internal/model/newdir_test.go
new file mode 100644
index 0000000..19957d9
--- /dev/null
+++ b/gui/internal/model/newdir_test.go
@@ -0,0 +1,72 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package model
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "krino/internal/engine"
+)
+
+// TestAddDirectory: a directory added in the window is one krino loads, its
+// file written from the template and its name in the include.
+func TestAddDirectory(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ target := filepath.Join(h, "papers")
+ if err := os.MkdirAll(target, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ file, err := AddDirectory(e, "papers", target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(file); err != nil {
+ t.Fatalf("the file was not written: %v", err)
+ }
+ main, err := os.ReadFile(e.MainFile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(main), "papers") {
+ t.Errorf("the include does not name it:\n%s", main)
+ }
+
+ fresh, diags := engine.Load(e.MainFile)
+ if len(diags) > 0 {
+ t.Fatalf("the configuration no longer loads: %v", diags)
+ }
+ found := false
+ for _, d := range fresh.Dirs {
+ if d.Name == "papers" && d.Root == target {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("the new directory is not in the configuration: %+v", fresh.Dirs)
+ }
+ // And the window can open its rules straight away.
+ if _, err := OpenRules(fresh, "papers"); err != nil {
+ t.Errorf("its rules do not open: %v", err)
+ }
+}
+
+// TestAddDirectoryRefusals: the checks krino new makes are the checks the
+// window makes, because it is the same code.
+func TestAddDirectoryRefusals(t *testing.T) {
+ conf := "(path \"~/dl\")\n(rule \"all\" (move \"Out\"))\n"
+ e, h := sandboxDir(t, conf, map[string]string{"a.pdf": "one"})
+ for _, c := range []struct{ name, path, why string }{
+ {"has space", h, "a name with a space"},
+ {"check", h, "a krino command"},
+ {"dl", h, "a name already taken"},
+ } {
+ if _, err := AddDirectory(e, c.name, c.path); err == nil {
+ t.Errorf("%s was accepted", c.why)
+ }
+ }
+}