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
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
parsed rules**, because comments and formatting are not recoverable from
`[]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
note
╭────────────────────────────╮
│ optional │
╰────────────────────────────╯
why this rule exists
2 of 7 descriptions match
```
Only `gro` was typed in the tag field; `ceries` is the ghosted completion.
`tab` / `↑↓` move between the glob, account and tag fields, `pgup` / `pgdn`
scroll the list, and `enter` appends the rule to `rules.toml` and retags
`tab` / `↑↓` move between the glob, account, tag and note fields, `pgup` /
`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.
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
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
# Pattern Account Tag T Txns
1 *LIDL* (all) groceries 3
# Pattern Account Tag T Txns Note
1 *LIDL* (all) groceries 3 the weekly shop
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
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
@@ -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
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
[[rule]]
tag = "groceries"
@@ -169,6 +183,7 @@ match = "*LIDL*"
[[rule]]
tag = "salary"
match = "*ACME PAYROLL*"
note = "paid on the 4th; the December one lands early"
# Money moved between your own accounts. Both legs need a 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.
// Optional; when set, it must match as well as Match.
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.
@@ -256,6 +261,9 @@ func formatRule(r Rule) string {
if r.Transfer {
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()
}
+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.
# Order matters: the first match wins.
+47 -20
View File
@@ -83,7 +83,8 @@ type Model struct {
ruleGlob textinput.Model
ruleAccount 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
ruleReturn view // the view to go back to on esc
untagged []descGroup
@@ -196,6 +197,7 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En
ruleGlob: newInput("*LIDL*"),
ruleAccount: completing(newInput("blank = every account")),
ruleTag: completing(newInput("groceries")),
ruleNote: newInput("optional"),
ruleTable: newTable([]table.Column{
{Title: " ", Width: 1},
{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: "T", Width: 1},
{Title: "Txns", Width: 6},
{Title: "Note", Width: 24},
}),
reportTable: newTable([]table.Column{
{Title: "Tag", Width: 20},
@@ -416,6 +419,7 @@ func (m *Model) saveRule() error {
Match: strings.TrimSpace(m.ruleGlob.Value()),
Account: strings.TrimSpace(m.ruleAccount.Value()),
Tag: strings.TrimSpace(m.ruleTag.Value()),
Note: strings.TrimSpace(m.ruleNote.Value()),
}
if r.Match == "" {
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.ruleGlob.SetValue("")
m.ruleTag.SetValue("")
m.ruleNote.SetValue("")
m.setRuleFocus(0)
if err := m.reloadSuggestions(); err != nil {
return err
@@ -578,7 +583,7 @@ func (m *Model) reloadRuleList() error {
}
rows = append(rows, table.Row{
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.
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.
@@ -957,20 +962,28 @@ func (m *Model) resize() {
m.ruleTable.SetColumns(cols)
}
// Give the description column whatever is left over.
if m.width > 0 {
cols := m.txnTable.Columns()
fixed := 0
for _, c := range cols[:len(cols)-1] {
fixed += c.Width + 2
}
desc := m.width - fixed - 4
if desc < 20 {
desc = 20
}
cols[len(cols)-1].Width = desc
m.txnTable.SetColumns(cols)
// The free-text columns come last and get whatever is left over.
m.stretchLastColumn(&m.txnTable, 20)
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
for _, c := range cols[:len(cols)-1] {
fixed += c.Width + 2
}
last := m.width - fixed - 4
if last < min {
last = min
}
cols[len(cols)-1].Width = last
t.SetColumns(cols)
}
// updateInput handles typing into the tag or search prompt.
@@ -1238,6 +1251,15 @@ func (m *Model) rulesView() 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 {
name := labelStyle.Render(" " + label)
box := boxStyle.Render(m.ruleInputs()[i].View())
@@ -1245,16 +1267,21 @@ func (m *Model) ruleFormView() string {
name = focusedLabelStyle.Render("▸ " + label)
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
b.WriteString(field(0, "glob", "vs. the description"))
b.WriteString("\n")
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("\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
// 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, "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)
}
}
}