diff options
Diffstat (limited to 'gui/internal/model')
| -rw-r--r-- | gui/internal/model/settings.go | 135 | ||||
| -rw-r--r-- | gui/internal/model/settings_test.go | 154 |
2 files changed, 289 insertions, 0 deletions
diff --git a/gui/internal/model/settings.go b/gui/internal/model/settings.go new file mode 100644 index 0000000..7c31997 --- /dev/null +++ b/gui/internal/model/settings.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "fmt" + "strings" + + "krino/internal/sexp" +) + +// DirSettings are the forms a directory file may carry above its rules, in +// the order the form shows them. They are the settings krino.conf(5) +// documents for a directory; anything else in the file is a rule, an +// exclude, or not krino's (GUI design ยง5.1). +var DirSettings = []string{ + "path", "recursive", "max-depth", "min-age", "max-read", "max-size", + "busy", "case", "fold", "on-conflict", "ignore", +} + +// Setting reads one directory setting as it is written - the form's +// arguments, not their meaning - and whether the file sets it at all. +func (r *Rules) Setting(head string) (args string, set bool, err error) { + if !isDirSetting(head) { + return "", false, fmt.Errorf("model: %s is not a directory setting", head) + } + node, err := r.settingNode(head) + if err != nil || node == nil { + return "", false, err + } + return argsOf(node, r.Text), true, nil +} + +// SetSetting writes one directory setting: args as they are to be written, +// or "" to take the setting out of the file. A setting already in the file +// is rewritten where it stands; a new one goes at the end of the header, +// above the first exclude or rule, so a directory file keeps its shape. +// The path is the one setting that cannot be cleared - without it the file +// does not load. +func (r *Rules) SetSetting(head, args string) error { + if !isDirSetting(head) { + return fmt.Errorf("model: %s is not a directory setting", head) + } + args = strings.TrimSpace(args) + if args == "" && head == "path" { + return fmt.Errorf("model: a directory needs its (path ...)") + } + node, err := r.settingNode(head) + if err != nil { + return err + } + switch { + case node != nil && args == "": + start, end := r.lineSpan(node) + r.Text = join(r.Text[:start], r.Text[end:]) + case node != nil: + r.Text = r.Text[:node.Pos.Offset] + "(" + head + " " + args + ")" + r.Text[node.End.Offset:] + default: + at, err := r.headerEnd() + if err != nil { + return err + } + r.Text = r.Text[:at] + "(" + head + " " + args + ")\n" + r.Text[at:] + } + return nil +} + +// isDirSetting reports whether head is one of the directory settings. +func isDirSetting(head string) bool { + for _, s := range DirSettings { + if s == head { + return true + } + } + return false +} + +// settingNode is the form for head in the current text, or nil. A file that +// does not parse has no settings to read. +func (r *Rules) settingNode(head string) (*sexp.Node, error) { + nodes, err := sexp.Parse(r.File, []byte(r.Text)) + if err != nil { + return nil, err + } + for _, n := range nodes { + if n.Kind == sexp.List && n.Head() == head { + return n, nil + } + } + return nil, nil +} + +// headerEnd is where a new setting goes: after the last setting already +// written, and in any case above the first exclude or rule. +func (r *Rules) headerEnd() (int, error) { + nodes, err := sexp.Parse(r.File, []byte(r.Text)) + if err != nil { + return 0, err + } + at := 0 + for _, n := range nodes { + if n.Kind != sexp.List { + continue + } + switch { + case isDirSetting(n.Head()): + at = lineEnd(r.Text, n.End.Offset) + case n.Head() == "exclude" || n.Head() == "rule": + if at == 0 { + at = lineStart(r.Text, n.Pos.Offset) + } + return at, nil + } + } + if at == 0 { + at = len(r.Text) + } + return at, nil +} + +// lineSpan is the whole line a setting is written on, so clearing it takes +// the comment that trails it rather than leaving it to dangle. +func (r *Rules) lineSpan(n *sexp.Node) (start, end int) { + return lineStart(r.Text, n.Pos.Offset), lineEnd(r.Text, n.End.Offset) +} + +// argsOf is a form's arguments exactly as the file writes them. +func argsOf(n *sexp.Node, text string) string { + if len(n.Children) < 2 { + return "" + } + from := n.Children[1].Pos.Offset + to := n.Children[len(n.Children)-1].End.Offset + return strings.TrimSpace(text[from:to]) +} diff --git a/gui/internal/model/settings_test.go b/gui/internal/model/settings_test.go new file mode 100644 index 0000000..46f8b75 --- /dev/null +++ b/gui/internal/model/settings_test.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "strings" + "testing" +) + +// settingsFile has a header with some settings set and others left out, and +// comments around them that must survive every edit. +const settingsFile = `;; the directory krino sorts +(path "~/dl") +(min-age 2m) ; leave fresh files alone +(ignore "*.part" ".*") + +(rule "all" + (move "Out")) +` + +// TestSettingReads: a directory setting is read as it is written, and one +// that is not in the file reads as absent. +func TestSettingReads(t *testing.T) { + r := openForms(t, settingsFile) + for _, c := range []struct { + head, want string + set bool + }{ + {"path", `"~/dl"`, true}, + {"min-age", "2m", true}, + {"ignore", `"*.part" ".*"`, true}, + {"recursive", "", false}, + {"on-conflict", "", false}, + } { + got, ok, err := r.Setting(c.head) + if err != nil { + t.Fatal(err) + } + if ok != c.set || got != c.want { + t.Errorf("Setting(%q) = %q, %v; want %q, %v", c.head, got, ok, c.want, c.set) + } + } +} + +// TestSettingWrites: changing a setting rewrites that form and nothing +// else; the comment on its line stays. +func TestSettingWrites(t *testing.T) { + r := openForms(t, settingsFile) + if err := r.SetSetting("min-age", "1d"); err != nil { + t.Fatal(err) + } + if !strings.Contains(r.Text, "(min-age 1d)") { + t.Errorf("the new value is not in the file:\n%s", r.Text) + } + if strings.Contains(r.Text, "(min-age 2m)") { + t.Errorf("the old value is still there:\n%s", r.Text) + } + for _, keep := range []string{";; the directory krino sorts", + "; leave fresh files alone", `(ignore "*.part" ".*")`, `(rule "all"`} { + if !strings.Contains(r.Text, keep) { + t.Errorf("writing one setting lost %q:\n%s", keep, r.Text) + } + } + if diags := r.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } +} + +// TestSettingAdds: a setting the file does not have is written into the +// header, above the rules, and the file still loads. +func TestSettingAdds(t *testing.T) { + r := openForms(t, settingsFile) + if err := r.SetSetting("on-conflict", "skip"); err != nil { + t.Fatal(err) + } + if !strings.Contains(r.Text, "(on-conflict skip)") { + t.Errorf("the setting was not written:\n%s", r.Text) + } + if strings.Index(r.Text, "(on-conflict skip)") > strings.Index(r.Text, `(rule "all"`) { + t.Errorf("the setting landed below the rules:\n%s", r.Text) + } + if diags := r.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } + got, ok, err := r.Setting("on-conflict") + if err != nil || !ok || got != "skip" { + t.Errorf("reading it back = %q, %v, %v", got, ok, err) + } +} + +// TestSettingAddsAboveTheRules: a setting written below the rules - legal, +// since order does not matter to krino - does not drag a new setting down +// with it. The header is where settings go. +func TestSettingAddsAboveTheRules(t *testing.T) { + r := openForms(t, "(path \"~/dl\")\n\n(rule \"all\"\n (move \"Out\"))\n\n(min-age 2m)\n") + if err := r.SetSetting("on-conflict", "skip"); err != nil { + t.Fatal(err) + } + if strings.Index(r.Text, "(on-conflict skip)") > strings.Index(r.Text, `(rule "all"`) { + t.Errorf("the new setting landed below the rules:\n%s", r.Text) + } + if diags := r.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } +} + +// TestSettingRemoves: clearing a setting takes its whole line, leaving the +// rest of the header as it was. +func TestSettingRemoves(t *testing.T) { + r := openForms(t, settingsFile) + if err := r.SetSetting("min-age", ""); err != nil { + t.Fatal(err) + } + if strings.Contains(r.Text, "min-age") { + t.Errorf("the setting is still there:\n%s", r.Text) + } + if strings.Contains(r.Text, "leave fresh files alone") { + t.Errorf("the comment on its line was left stranded:\n%s", r.Text) + } + if !strings.Contains(r.Text, `(path "~/dl")`) || !strings.Contains(r.Text, ";; the directory krino sorts") { + t.Errorf("removing one setting took more than its line:\n%s", r.Text) + } + if diags := r.Check(); len(diags) > 0 { + t.Errorf("the file no longer loads: %v", diags) + } + if _, ok, _ := r.Setting("min-age"); ok { + t.Error("the removed setting still reads as set") + } +} + +// TestSettingRefusesThePathAway: a directory file without (path ...) does +// not load, so clearing it is refused rather than written. +func TestSettingRefusesThePathAway(t *testing.T) { + r := openForms(t, settingsFile) + before := r.Text + if err := r.SetSetting("path", ""); err == nil { + t.Error("clearing the path was accepted") + } + if r.Text != before { + t.Error("the refused edit changed the file") + } +} + +// TestSettingRefusesAnUnknownHead: only the forms krino.conf(5) documents +// as directory settings can be written this way. +func TestSettingRefusesAnUnknownHead(t *testing.T) { + r := openForms(t, settingsFile) + if err := r.SetSetting("nonsense", "1"); err == nil { + t.Error("an unknown setting was accepted") + } + if _, _, err := r.Setting("nonsense"); err == nil { + t.Error("an unknown setting was read") + } +} |
