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
+5
View File
@@ -69,6 +69,11 @@ could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by a
earlier one correctly reports zero. That is what makes the rules screen able to earlier one correctly reports zero. That is what makes the rules screen able to
find dead rules at all. find dead rules at all.
**A rule's `note` is documentation that round-trips.** It is a TOML key rather
than a `#` comment so `LoadRules` can return it, the builder can write it and
the rules screen can show it. It never takes part in matching — `rules.Engine`
does not look at it — and it must stay that way.
**`config.DeleteRules` edits rules.toml textually, never by re-serialising the **`config.DeleteRules` edits rules.toml textually, never by re-serialising the
parsed rules**, because comments and formatting are not recoverable from parsed rules**, because comments and formatting are not recoverable from
`[]Rule`. A rule owns the comment lines directly above it; a comment separated `[]Rule`. A rule owns the comment lines directly above it; a comment separated
+21 -6
View File
@@ -100,14 +100,22 @@ a `▸` against each one the glob currently matches and a running
╰────────────────────────────╯ ╰────────────────────────────╯
tab completes · ctrl+n: 1 more tab completes · ctrl+n: 1 more
note
╭────────────────────────────╮
│ optional │
╰────────────────────────────╯
why this rule exists
2 of 7 descriptions match 2 of 7 descriptions match
``` ```
Only `gro` was typed in the tag field; `ceries` is the ghosted completion. Only `gro` was typed in the tag field; `ceries` is the ghosted completion.
`tab` / `↑↓` move between the glob, account and tag fields, `pgup` / `pgdn` `tab` / `↑↓` move between the glob, account, tag and note fields, `pgup` /
scroll the list, and `enter` appends the rule to `rules.toml` and retags `pgdn` scroll the list, and `enter` appends the rule to `rules.toml` and retags
immediately, so the rows it caught disappear from the list. `esc` goes back. immediately, so the rows it caught disappear from the list. `esc` goes back.
On a short window the form gives up its spacing and then its hints, so all four
fields stay on screen.
The account and tag fields complete as you type: the rest of the match is The account and tag fields complete as you type: the rest of the match is
ghosted in grey after the cursor, and `tab` (or `→` at the end of the line) ghosted in grey after the cursor, and `tab` (or `→` at the end of the line)
@@ -131,12 +139,12 @@ Rules that claim none are marked `✗`.
``` ```
money · rules · 5 rules · 2 match nothing money · rules · 5 rules · 2 match nothing
# Pattern Account Tag T Txns # Pattern Account Tag T Txns Note
1 *LIDL* (all) groceries 3 1 *LIDL* (all) groceries 3 the weekly shop
2 ✗ *LIDL SOFIA* (all) shadowed 0 2 ✗ *LIDL SOFIA* (all) shadowed 0
3 ✗ *OLD BANK NAME* (all) dead 0 3 ✗ *OLD BANK NAME* (all) dead 0 closed in 2025
4 *ZARA* (all) clothes 1 4 *ZARA* (all) clothes 1
5 *КАУФЛАНД* checking groceries 1 5 *КАУФЛАНД* checking groceries 1 4412 is the branch
``` ```
The count is how many transactions the rule *wins*, not how many its glob could The count is how many transactions the rule *wins*, not how many its glob could
@@ -161,6 +169,12 @@ A rule matches on `match` (the description), `counterparty` (the other side's
account number) and `type` (the bank's own classification). Setting several is account number) and `type` (the bank's own classification). Setting several is
an "and": all must match. an "and": all must match.
`note` is free text for you, never for the matcher: why the rule is there, or
what the unrecognisable payee behind the glob actually is. It shows in the last
column of the rules screen. Ordinary `#` comments still work and are preserved
on delete; a `note` differs in that it survives a round trip through the tool,
so the rule builder can write one and the rules screen can show it.
```toml ```toml
[[rule]] [[rule]]
tag = "groceries" tag = "groceries"
@@ -169,6 +183,7 @@ match = "*LIDL*"
[[rule]] [[rule]]
tag = "salary" tag = "salary"
match = "*ACME PAYROLL*" match = "*ACME PAYROLL*"
note = "paid on the 4th; the December one lands early"
# Money moved between your own accounts. Both legs need a rule. # Money moved between your own accounts. Both legs need a rule.
[[rule]] [[rule]]
+8
View File
@@ -38,6 +38,11 @@ type Rule struct {
// Type matches the bank's own classification, e.g. Revolut's CARD_PAYMENT. // Type matches the bank's own classification, e.g. Revolut's CARD_PAYMENT.
// Optional; when set, it must match as well as Match. // Optional; when set, it must match as well as Match.
Type string `toml:"type"` Type string `toml:"type"`
// Note is free text for the reader: why the rule exists, or what the
// unrecognisable payee behind the glob actually is. It never affects
// matching. It is a key rather than a comment so it survives a round trip
// through LoadRules and can be shown on the rules screen.
Note string `toml:"note"`
} }
// Rules is the parsed rules.toml. // Rules is the parsed rules.toml.
@@ -256,6 +261,9 @@ func formatRule(r Rule) string {
if r.Transfer { if r.Transfer {
b.WriteString("transfer = true\n") b.WriteString("transfer = true\n")
} }
// Last, so the patterns and the tag stay lined up above it however long
// the note runs.
write("note", r.Note)
return b.String() return b.String()
} }
+57
View File
@@ -196,6 +196,63 @@ func TestAppendRuleQuotesValues(t *testing.T) {
} }
} }
// A note is documentation carried with the rule. It is a key rather than a
// comment precisely so it survives the round trip.
func TestAppendRuleWithNote(t *testing.T) {
root := t.TempDir()
note := `the "corner shop" — 4412 is the branch code`
if err := AppendRule(root, Rule{Match: "*4412*", Tag: "groceries", Note: note}); err != nil {
t.Fatal(err)
}
loaded, err := LoadRules(root)
if err != nil {
t.Fatalf("the note broke the file: %v", err)
}
if loaded.Rule[0].Note != note {
t.Errorf("note = %q, want %q", loaded.Rule[0].Note, note)
}
// A note alone is not a rule; the usual validation still applies.
if err := AppendRule(root, Rule{Note: "just a thought"}); err == nil {
t.Error("expected a rule with only a note to be rejected")
}
}
// Deleting a rule takes its note with it and leaves everyone else's alone.
func TestDeleteRuleTakesItsNote(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, RulesFile)
original := `[[rule]]
match = "*LIDL*"
tag = "groceries"
note = "the weekly shop"
[[rule]]
match = "*ZARA*"
tag = "clothes"
note = "keep: for the January review"
`
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
if _, err := DeleteRules(root, []int{0}); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "weekly shop") {
t.Errorf("rules.toml = %q, want the deleted rule's note gone with it", raw)
}
loaded, err := LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Rule) != 1 || loaded.Rule[0].Note != "keep: for the January review" {
t.Errorf("rules = %+v, want the surviving rule to keep its note", loaded.Rule)
}
}
const rulesWithComments = `# Rules for my accounts. const rulesWithComments = `# Rules for my accounts.
# Order matters: the first match wins. # Order matters: the first match wins.
+43 -16
View File
@@ -83,7 +83,8 @@ type Model struct {
ruleGlob textinput.Model ruleGlob textinput.Model
ruleAccount textinput.Model ruleAccount textinput.Model
ruleTag textinput.Model ruleTag textinput.Model
ruleFocus int // which of the three inputs has the cursor ruleNote textinput.Model
ruleFocus int // which of the inputs has the cursor
ruleTable table.Model ruleTable table.Model
ruleReturn view // the view to go back to on esc ruleReturn view // the view to go back to on esc
untagged []descGroup untagged []descGroup
@@ -196,6 +197,7 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
ruleGlob: newInput("*LIDL*"), ruleGlob: newInput("*LIDL*"),
ruleAccount: completing(newInput("blank = every account")), ruleAccount: completing(newInput("blank = every account")),
ruleTag: completing(newInput("groceries")), ruleTag: completing(newInput("groceries")),
ruleNote: newInput("optional"),
ruleTable: newTable([]table.Column{ ruleTable: newTable([]table.Column{
{Title: " ", Width: 1}, {Title: " ", Width: 1},
{Title: "Untagged description", Width: 44}, {Title: "Untagged description", Width: 44},
@@ -209,6 +211,7 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
{Title: "Tag", Width: 14}, {Title: "Tag", Width: 14},
{Title: "T", Width: 1}, {Title: "T", Width: 1},
{Title: "Txns", Width: 6}, {Title: "Txns", Width: 6},
{Title: "Note", Width: 24},
}), }),
reportTable: newTable([]table.Column{ reportTable: newTable([]table.Column{
{Title: "Tag", Width: 20}, {Title: "Tag", Width: 20},
@@ -416,6 +419,7 @@ func (m *Model) saveRule() error {
Match: strings.TrimSpace(m.ruleGlob.Value()), Match: strings.TrimSpace(m.ruleGlob.Value()),
Account: strings.TrimSpace(m.ruleAccount.Value()), Account: strings.TrimSpace(m.ruleAccount.Value()),
Tag: strings.TrimSpace(m.ruleTag.Value()), Tag: strings.TrimSpace(m.ruleTag.Value()),
Note: strings.TrimSpace(m.ruleNote.Value()),
} }
if r.Match == "" { if r.Match == "" {
return fmt.Errorf("enter a glob first, e.g. *LIDL*") return fmt.Errorf("enter a glob first, e.g. *LIDL*")
@@ -447,6 +451,7 @@ func (m *Model) saveRule() error {
m.status = fmt.Sprintf("saved rule %s → %s, %d transactions retagged", r.Match, r.Tag, n) m.status = fmt.Sprintf("saved rule %s → %s, %d transactions retagged", r.Match, r.Tag, n)
m.ruleGlob.SetValue("") m.ruleGlob.SetValue("")
m.ruleTag.SetValue("") m.ruleTag.SetValue("")
m.ruleNote.SetValue("")
m.setRuleFocus(0) m.setRuleFocus(0)
if err := m.reloadSuggestions(); err != nil { if err := m.reloadSuggestions(); err != nil {
return err return err
@@ -578,7 +583,7 @@ func (m *Model) reloadRuleList() error {
} }
rows = append(rows, table.Row{ rows = append(rows, table.Row{
fmt.Sprintf("%d", i+1), marker, rulePattern(r), account, r.Tag, transfer, fmt.Sprintf("%d", i+1), marker, rulePattern(r), account, r.Tag, transfer,
fmt.Sprintf("%d", m.ruleUsage[i]), fmt.Sprintf("%d", m.ruleUsage[i]), r.Note,
}) })
} }
@@ -756,7 +761,7 @@ func (m *Model) openRuleBuilder() tea.Cmd {
// ruleInputs lists the form fields in tab order. // ruleInputs lists the form fields in tab order.
func (m *Model) ruleInputs() []*textinput.Model { func (m *Model) ruleInputs() []*textinput.Model {
return []*textinput.Model{&m.ruleGlob, &m.ruleAccount, &m.ruleTag} return []*textinput.Model{&m.ruleGlob, &m.ruleAccount, &m.ruleTag, &m.ruleNote}
} }
// setRuleFocus moves the cursor between the form fields, wrapping around. // setRuleFocus moves the cursor between the form fields, wrapping around.
@@ -957,20 +962,28 @@ func (m *Model) resize() {
m.ruleTable.SetColumns(cols) m.ruleTable.SetColumns(cols)
} }
// Give the description column whatever is left over. // The free-text columns come last and get whatever is left over.
if m.width > 0 { m.stretchLastColumn(&m.txnTable, 20)
cols := m.txnTable.Columns() m.stretchLastColumn(&m.ruleListTable, 12)
}
// stretchLastColumn widens a table's final column to fill the window, down to
// a floor below which truncation is worse than letting the row overflow.
func (m *Model) stretchLastColumn(t *table.Model, min int) {
if m.width <= 0 {
return
}
cols := t.Columns()
fixed := 0 fixed := 0
for _, c := range cols[:len(cols)-1] { for _, c := range cols[:len(cols)-1] {
fixed += c.Width + 2 fixed += c.Width + 2
} }
desc := m.width - fixed - 4 last := m.width - fixed - 4
if desc < 20 { if last < min {
desc = 20 last = min
}
cols[len(cols)-1].Width = desc
m.txnTable.SetColumns(cols)
} }
cols[len(cols)-1].Width = last
t.SetColumns(cols)
} }
// updateInput handles typing into the tag or search prompt. // updateInput handles typing into the tag or search prompt.
@@ -1238,6 +1251,15 @@ func (m *Model) rulesView() string {
} }
func (m *Model) ruleFormView() string { func (m *Model) ruleFormView() string {
// Spaced out, the four fields come to 25 lines, which is more than a short
// window has left once the same allowance the tables get is taken off.
// Give up the blank lines between fields first (21 lines) and the hints on
// unfocused fields second (18), rather than letting the last field run off
// the bottom. Below that the box borders are the floor.
room := m.height - 6
spaced := m.height <= 0 || room >= 25
hints := m.height <= 0 || room >= 21
field := func(i int, label, help string) string { field := func(i int, label, help string) string {
name := labelStyle.Render(" " + label) name := labelStyle.Render(" " + label)
box := boxStyle.Render(m.ruleInputs()[i].View()) box := boxStyle.Render(m.ruleInputs()[i].View())
@@ -1245,16 +1267,21 @@ func (m *Model) ruleFormView() string {
name = focusedLabelStyle.Render("▸ " + label) name = focusedLabelStyle.Render("▸ " + label)
box = focusedBoxStyle.Render(m.ruleInputs()[i].View()) box = focusedBoxStyle.Render(m.ruleInputs()[i].View())
} }
return name + "\n" + box + "\n" + hintStyle.Render(help) + "\n" out := name + "\n" + box + "\n"
if hints || i == m.ruleFocus {
out += hintStyle.Render(help) + "\n"
}
if spaced {
out += "\n"
}
return out
} }
var b strings.Builder var b strings.Builder
b.WriteString(field(0, "glob", "vs. the description")) b.WriteString(field(0, "glob", "vs. the description"))
b.WriteString("\n")
b.WriteString(field(1, "account", m.completionHint(1, "blank = all accounts"))) b.WriteString(field(1, "account", m.completionHint(1, "blank = all accounts")))
b.WriteString("\n")
b.WriteString(field(2, "tag", m.completionHint(2, "applied to matches"))) b.WriteString(field(2, "tag", m.completionHint(2, "applied to matches")))
b.WriteString("\n") b.WriteString(field(3, "note", "why this rule exists"))
// The count is the whole point of the preview: it says what the rule will // The count is the whole point of the preview: it says what the rule will
// do before it is written to disk. // do before it is written to disk.
+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, "2") // come from the transactions view
key(t, m, "4") 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}) m.Update(tea.KeyMsg{Type: tea.KeyTab})
if m.ruleFocus != want { if m.ruleFocus != want {
t.Errorf("tab %d moved focus to %d, want %d", i+1, 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" { if m.ruleTag.Value() != "zzz" {
t.Errorf("tag = %q, want the typed text untouched", m.ruleTag.Value()) t.Errorf("tag = %q, want the typed text untouched", m.ruleTag.Value())
} }
if m.ruleFocus != 0 { if m.ruleFocus != 3 {
t.Errorf("focus = %d, want tab to wrap round to the glob field", m.ruleFocus) 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)
}
} }
} }