Edit a rule from the rules screen

A rule could be written and deleted but never changed, so fixing a glob meant
deleting the rule and typing it again -- losing its note, the comments around
it, and its position, which still breaks ties between equally specific rules.
e now opens the selected rule in the builder and enter rewrites it where it
sits. config.ReplaceRule edits rules.toml textually, as deleting does, and
keeps the comments above the rule: they say why it is there, which editing its
glob rarely changes.

The round trip must not lose what the form does not show. The builder has four
fields and a Rule has five, so an edit carries the type pattern through
untouched and says so under the glob; a type-only rule saves without one. The
preview needed the same care in reverse: a working rule's transactions are
tagged, so an untagged-only preview would be empty for it. Its own rows are
added back, and the ones a narrowed glob stops catching stay on screen marked
-- giving one up is the decision being made, and it must not happen silently.

The builder and its list shared one return view, so opening the builder from
the list left esc pointing back into the form. Each screen now remembers its
own way out; transfers had the same trap and the same fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 19:40:55 +02:00
co-authored by Claude Opus 5
parent 442684be60
commit 6477147988
6 changed files with 739 additions and 105 deletions
+210
View File
@@ -1914,3 +1914,213 @@ func TestRuleBuilderPreviewShowsOnlyMatches(t *testing.T) {
t.Errorf("summary = %q, want 0 of 4", m.ruleFormView())
}
}
// loadRulesFile puts a rules.toml under the model's root and makes the model
// read it, as though it had been there all along.
func loadRulesFile(t *testing.T, m *Model, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(m.root, config.RulesFile), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if err := m.reloadConfig(); err != nil {
t.Fatal(err)
}
if _, err := m.engine.Retag(m.db); err != nil {
t.Fatal(err)
}
if err := m.reload(); err != nil {
t.Fatal(err)
}
}
const editableRules = `[[rule]]
match = "*ZZZ*"
tag = "misc"
# The weekly shop.
[[rule]]
match = "*LIDL*"
tag = "groceries"
note = "both branches"
`
// Editing rewrites the rule where it sits rather than appending a second,
// almost identical one, and lands back on the list it started from.
func TestRuleListEditsRuleInPlace(t *testing.T) {
m, db, root := newRuleModel(t)
loadRulesFile(t, m, editableRules)
key(t, m, "5")
m.ruleListTable.SetCursor(1)
key(t, m, "e")
if m.view != viewRules || !m.ruleEditing {
t.Fatalf("e opened view %v (editing %v), want the builder on the rule", m.view, m.ruleEditing)
}
if m.ruleGlob.Value() != "*LIDL*" || m.ruleTag.Value() != "groceries" || m.ruleNote.Value() != "both branches" {
t.Fatalf("form = %q / %q / %q, want the rule filled in",
m.ruleGlob.Value(), m.ruleTag.Value(), m.ruleNote.Value())
}
m.ruleTag.SetValue("food")
key(t, m, "enter")
loaded, err := config.LoadRules(root)
if err != nil {
t.Fatal(err)
}
want := config.Rule{Match: "*LIDL*", Tag: "food", Note: "both branches"}
if len(loaded.Rule) != 2 {
t.Fatalf("rules = %+v, want the same 2: an edit must not append", loaded.Rule)
}
if loaded.Rule[0].Match != "*ZZZ*" {
t.Errorf("rule 1 = %+v, want it where it was", loaded.Rule[0])
}
if loaded.Rule[1] != want {
t.Errorf("rule 2 = %+v, want %+v", loaded.Rule[1], want)
}
// The file is edited textually, so the comment documenting the rule stays.
raw, err := os.ReadFile(filepath.Join(root, config.RulesFile))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "# The weekly shop.") {
t.Errorf("the rule's comment was lost:\n%s", raw)
}
// Saving retags immediately, as it does for a new rule.
txns, err := db.Transactions(store.Filter{})
if err != nil {
t.Fatal(err)
}
tagged := 0
for _, txn := range txns {
if txn.RuleTag == "food" {
tagged++
}
}
if tagged != 3 {
t.Errorf("%d transactions tagged food, want 3", tagged)
}
// An edit is a round trip: it ends on the list, with the new counts.
if m.view != viewRuleList {
t.Fatalf("view = %v after saving, want the rules list", m.view)
}
if rows := m.ruleListTable.Rows(); len(rows) != 2 || rows[1][4] != "food" {
t.Errorf("rows = %v, want the edited tag on the list", rows)
}
if !strings.Contains(m.status, "rule 2 is now") {
t.Errorf("status = %q, want it to name the rule that changed", m.status)
}
}
// The preview lists what the rule already claims, not only what is untagged:
// for a rule that works those are the same rows, and an empty preview would say
// nothing about the edit. Narrowing the glob then shows what it gives up.
func TestRuleEditPreviewsWhatItDrops(t *testing.T) {
m, _, _ := newRuleModel(t)
loadRulesFile(t, m, editableRules)
key(t, m, "5")
m.ruleListTable.SetCursor(1)
key(t, m, "e")
all, matched := previewRows(m)
if len(all) != 2 || len(matched) != 2 {
t.Fatalf("preview = %v (matched %v), want the two descriptions the rule claims", all, matched)
}
m.ruleGlob.SetValue("*SOFIA*")
m.refreshRulePreview()
all, matched = previewRows(m)
if len(matched) != 1 || matched[0] != "LIDL SOFIA 4412" {
t.Errorf("matched = %v, want only the Sofia branch", matched)
}
// The row it stops claiming stays on screen: losing one is the thing worth
// seeing before saving, so it is marked rather than dropped silently.
if len(all) != 2 || m.ruleDropped != 1 {
t.Fatalf("preview = %v, dropped = %d; want the Varna row kept and counted", all, m.ruleDropped)
}
var dropped []string
for _, row := range m.ruleTable.Rows() {
if row[0] == "−" {
dropped = append(dropped, row[1])
}
}
if len(dropped) != 1 || dropped[0] != "LIDL VARNA 9911" {
t.Errorf("dropped rows = %v, want the Varna branch", dropped)
}
if !strings.Contains(m.ruleFormView(), "1 no longer claimed") {
t.Errorf("summary = %q, want the dropped count", m.ruleFormView())
}
}
// The form has no type field, so an edit carries the pattern through instead of
// silently widening the rule to every transaction the glob matches.
func TestRuleEditKeepsTypePattern(t *testing.T) {
m, _, root := newRuleModel(t)
loadRulesFile(t, m, "[[rule]]\nmatch = \"*ZZZ*\"\ntype = \"CARD_PAYMENT\"\ntag = \"misc\"\n")
key(t, m, "5")
key(t, m, "e")
if !strings.Contains(m.ruleFormView(), "type:CARD_PAYMENT") {
t.Errorf("form = %q, want it to name the type it is carrying", m.ruleFormView())
}
m.ruleTag.SetValue("cards")
key(t, m, "enter")
loaded, err := config.LoadRules(root)
if err != nil {
t.Fatal(err)
}
want := config.Rule{Match: "*ZZZ*", Type: "CARD_PAYMENT", Tag: "cards"}
if len(loaded.Rule) != 1 || loaded.Rule[0] != want {
t.Errorf("rules = %+v, want %+v", loaded.Rule, want)
}
}
// Leaving an edit must not leave the form holding that rule, or the next thing
// saved would be a near-duplicate of the rule just edited.
func TestNewRuleAfterAnEditStartsEmpty(t *testing.T) {
m, _, _ := newRuleModel(t)
loadRulesFile(t, m, editableRules)
key(t, m, "5")
m.ruleListTable.SetCursor(1)
key(t, m, "e")
key(t, m, "esc")
key(t, m, "4")
if m.ruleEditing {
t.Error("4 opened the builder still in edit mode")
}
for _, in := range m.ruleInputs() {
if in.Value() != "" {
t.Errorf("form field = %q, want a blank form for a new rule", in.Value())
}
}
}
// The builder is reached from the list, so it is not somewhere the list can
// return to: esc must walk back out, not bounce between the two screens.
func TestEscFromRuleListDoesNotBounceIntoTheBuilder(t *testing.T) {
m, _, _ := newRuleModel(t)
loadRulesFile(t, m, editableRules)
key(t, m, "2")
key(t, m, "5")
key(t, m, "e")
key(t, m, "esc")
if m.view != viewRuleList {
t.Fatalf("esc from the builder went to %v, want the list it was opened from", m.view)
}
key(t, m, "esc")
if m.view != viewTxns {
t.Errorf("esc from the list went to %v, want the transactions it was opened from", m.view)
}
}