Let rules carry a note

A glob like *4412* says nothing about why it exists or who it catches, and
six months later neither does memory. Rules get an optional note: free text
that never takes part in matching, written as a TOML key rather than a
comment so it survives a round trip and can be shown back.

The rule builder grows a fourth field for it and the rules screen a last
column. Four fields spaced out are taller than a short window has room for,
so the form now drops its blank lines and then the hints on unfocused fields
before anything would run off the bottom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 23:23:29 +02:00
co-authored by Claude Opus 5
parent 73fefea96d
commit 83d842d92e
6 changed files with 222 additions and 29 deletions
+84 -3
View File
@@ -638,7 +638,7 @@ func TestRuleBuilderTabCyclesFieldsAndEscLeaves(t *testing.T) {
key(t, m, "2") // come from the transactions view
key(t, m, "4")
for i, want := range []int{1, 2, 0} {
for i, want := range []int{1, 2, 3, 0} {
m.Update(tea.KeyMsg{Type: tea.KeyTab})
if m.ruleFocus != want {
t.Errorf("tab %d moved focus to %d, want %d", i+1, m.ruleFocus, want)
@@ -783,8 +783,89 @@ func TestRuleBuilderKeepsUnmatchedInput(t *testing.T) {
if m.ruleTag.Value() != "zzz" {
t.Errorf("tag = %q, want the typed text untouched", m.ruleTag.Value())
}
if m.ruleFocus != 0 {
t.Errorf("focus = %d, want tab to wrap round to the glob field", m.ruleFocus)
if m.ruleFocus != 3 {
t.Errorf("focus = %d, want tab to move on to the note field", m.ruleFocus)
}
}
// A note is free text that rides along with the rule: written to rules.toml,
// read back, and shown on the rules screen so the reason survives longer than
// the memory of writing it.
func TestRuleBuilderSavesNote(t *testing.T) {
m, _, root := newRuleModel(t)
key(t, m, "4")
m.ruleGlob.SetValue("*LIDL*")
m.setRuleFocus(2)
m.ruleTag.SetValue("groceries")
m.setRuleFocus(3)
typeText(t, m, "the weekly shop")
key(t, m, "enter")
loaded, err := config.LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Rule) != 1 || loaded.Rule[0].Note != "the weekly shop" {
t.Fatalf("rules.toml holds %+v, want the note round-tripped", loaded.Rule)
}
// The note is documentation, not a pattern: it must not narrow what the
// rule catches.
if loaded.Rule[0].Match != "*LIDL*" || loaded.Rule[0].Tag != "groceries" {
t.Errorf("rule = %+v, want the glob and tag untouched", loaded.Rule[0])
}
if m.ruleNote.Value() != "" {
t.Errorf("note = %q, want the field cleared for the next rule", m.ruleNote.Value())
}
m.openRuleList()
row := m.ruleListTable.Rows()[0]
if row[len(row)-1] != "the weekly shop" {
t.Errorf("rules screen row = %v, want the note in the last column", row)
}
}
// A rule without a note is still a rule; nothing about saving changes.
func TestRuleBuilderNoteIsOptional(t *testing.T) {
m, _, root := newRuleModel(t)
key(t, m, "4")
m.ruleGlob.SetValue("*LIDL*")
m.ruleTag.SetValue("groceries")
key(t, m, "enter")
raw, err := os.ReadFile(filepath.Join(root, config.RulesFile))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "note") {
t.Errorf("rules.toml = %q, want no empty note key", raw)
}
}
// The form has to survive a short window: four fields spaced out are taller
// than the room a 24-line terminal leaves.
func TestRuleFormFitsShortTerminals(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
for _, height := range []int{40, 32, 30, 26, 24} {
m.Update(tea.WindowSizeMsg{Width: 140, Height: height})
form := m.ruleFormView()
// The same budget the tables get: the title, status and a help line
// that may wrap onto a second row.
if lines := strings.Count(form, "\n") + 1; lines > height-6 {
t.Errorf("at height %d the form is %d lines, want at most %d", height, lines, height-6)
}
// However tight it gets, every field keeps its label and its box.
for _, label := range []string{"glob", "account", "tag", "note"} {
if !strings.Contains(form, label) {
t.Errorf("at height %d the %s field disappeared", height, label)
}
}
if boxes := strings.Count(form, "╭"); boxes != 4 {
t.Errorf("at height %d there are %d input boxes, want 4", height, boxes)
}
}
}