From 83d842d92e56a73fa18bf86d6eae8cfe81f209dd Mon Sep 17 00:00:00 2001 From: Nikola Petrov Date: Sun, 9 Aug 2026 23:23:29 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 5 ++ README.md | 27 ++++++++--- internal/config/config.go | 8 ++++ internal/config/config_test.go | 57 ++++++++++++++++++++++ internal/tui/tui.go | 67 ++++++++++++++++++-------- internal/tui/tui_test.go | 87 ++++++++++++++++++++++++++++++++-- 6 files changed, 222 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index be6d034..452907f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 7c07a8e..71d23d3 100644 --- a/README.md +++ b/README.md @@ -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]] diff --git a/internal/config/config.go b/internal/config/config.go index eebdf8c..15888a9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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() } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0cb9343..f0a28fe 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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. diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 24bca02..d85ae4c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -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. diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 57565d3..f9c6f75 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -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) + } } }