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:
@@ -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
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+43
-16
@@ -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()
|
||||
// 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
|
||||
}
|
||||
desc := m.width - fixed - 4
|
||||
if desc < 20 {
|
||||
desc = 20
|
||||
}
|
||||
cols[len(cols)-1].Width = desc
|
||||
m.txnTable.SetColumns(cols)
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user