From aeca2038557f31a777bbefd86f01fa43dd842d7a Mon Sep 17 00:00:00 2001 From: Nikola Petrov Date: Sun, 9 Aug 2026 01:41:24 +0200 Subject: [PATCH] Add a rule builder screen with a live glob preview Writing a glob by hand meant guessing what it would catch, then running retag to find out. The new screen, on 4, puts the glob, account and tag fields on the left and every still-untagged description on the right, sorted alphabetically and grouped by description with an occurrence count. Matches are marked as the glob is typed, along with a count, so the effect of a rule is visible before it is written. Enter appends it to rules.toml via config.AppendRule, reloads the engine from disk and retags, so the rows it caught leave the list immediately. Rules are appended rather than prepended, keeping the precedence of anything already in the file. Since the preview only lists transactions no existing rule has tagged, it reflects that precedence for free. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 7 + README.md | 29 ++- internal/config/config.go | 71 +++++++ internal/config/config_test.go | 81 ++++++++ internal/tui/tui.go | 346 ++++++++++++++++++++++++++++++++- internal/tui/tui_test.go | 274 ++++++++++++++++++++++++++ 6 files changed, 803 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b5284ed..b7a6d13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,13 @@ conversion, and totals are never summed across currencies. **First matching rule wins**, so transfer rules belong above general tag rules. A rule setting several of `match` / `counterparty` / `type` requires all of them. +`config.AppendRule` therefore appends — never prepends — so saving from the +rule builder cannot shadow a rule the user wrote by hand. + +**The rule builder is a form, so the global keymap must not apply there.** +`Update` routes to `updateRules` before `updateNormal` whenever the view is +`viewRules`, or typing `q` would quit and `i` would start an import. Any new +full-screen input needs the same treatment. ## Adding a bank parser diff --git a/README.md b/README.md index 00748b3..9458b62 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ imported — creating an `account.toml` is not enough on its own. Run | Key | Action | | --- | --- | -| `1` `2` `3` / `tab` | accounts · transactions · report | +| `1` `2` `3` `4` / `tab` | accounts · transactions · report · rule builder | | `enter` | open the selected account (accounts view) | | `t` | set the tag on the selected transaction | | `x` | toggle transfer on the selected transaction | @@ -79,6 +79,33 @@ imported — creating an `account.toml` is not enough on its own. Run | `a` | clear the account filter | | `i` | import · `r` re-apply rules · `q` quit | +### Rule builder (`4`) + +Writing rules by hand means guessing what a glob will catch. This screen shows +the answer as you type: the form is on the left, and on the right is every +still-untagged description in the data, sorted alphabetically and grouped, with +a `▸` against each one the glob currently matches and a running +"*n* of *m* descriptions match" count. + +``` +▸ glob Untagged description N +╭────────────────────────────╮ ACME PAYROLL JAN 1 +│ *LIDL* │ BOLT RIDE 1 +╰────────────────────────────╯ ▸ LIDL SOFIA 4412 2 + vs. the description ▸ LIDL VARNA 9911 1 + ZARA MLADOST 1 + 2 of 7 descriptions match +``` + +`tab` / `↑↓` move between the glob, account and tag 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. + +Leaving the account blank applies the rule everywhere; filling it in also +narrows the preview to that account. Rules are appended, so anything already in +`rules.toml` keeps precedence — the preview accounts for that automatically, +since it only ever lists transactions no existing rule has already tagged. + ## rules.toml Rules are evaluated in file order and the **first match wins**, so put transfer diff --git a/internal/config/config.go b/internal/config/config.go index 82e5a5b..6166bbf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "github.com/BurntSushi/toml" @@ -66,6 +67,76 @@ func LoadRules(root string) (*Rules, error) { return &r, nil } +// AppendRule adds a rule to the end of rules.toml, creating the file if it is +// not there yet. Appending rather than inserting means an existing rule always +// keeps precedence, since the first match wins. +// +// The file is rewritten through a temporary file so a failure part-way cannot +// leave the user with a truncated config. +func AppendRule(root string, r Rule) error { + if r.Match == "" && r.Counterparty == "" && r.Type == "" { + return fmt.Errorf("a rule needs a match, counterparty or type pattern") + } + if r.Tag == "" && !r.Transfer { + return fmt.Errorf("a rule needs a tag or transfer = true") + } + + path := filepath.Join(root, RulesFile) + existing, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", path, err) + } + + var b strings.Builder + b.Write(existing) + if len(existing) > 0 && !strings.HasSuffix(string(existing), "\n") { + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(formatRule(r)) + + tmp, err := os.CreateTemp(root, ".rules-*.toml") + if err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + defer os.Remove(tmp.Name()) + + if _, err := tmp.WriteString(b.String()); err != nil { + tmp.Close() + return fmt.Errorf("write %s: %w", path, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + if err := os.Chmod(tmp.Name(), 0o644); err != nil { + return err + } + if err := os.Rename(tmp.Name(), path); err != nil { + return fmt.Errorf("replace %s: %w", path, err) + } + return nil +} + +// formatRule renders a rule as a TOML table, omitting empty fields. +func formatRule(r Rule) string { + var b strings.Builder + b.WriteString("[[rule]]\n") + write := func(key, value string) { + if value != "" { + fmt.Fprintf(&b, "%-12s = %s\n", key, strconv.Quote(value)) + } + } + write("match", r.Match) + write("counterparty", r.Counterparty) + write("type", r.Type) + write("account", r.Account) + write("tag", r.Tag) + if r.Transfer { + b.WriteString("transfer = true\n") + } + return b.String() +} + // Column locates one field in a CSV row. type Column struct { Col int `toml:"col"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b535c1f..be58689 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -114,6 +114,87 @@ func TestResolveRootPrecedence(t *testing.T) { }) } +func TestAppendRule(t *testing.T) { + root := t.TempDir() + existing := "[[rule]]\ntag = \"salary\"\nmatch = \"*PAYROLL*\"\n" + if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + if err := AppendRule(root, Rule{Match: "*LIDL*", Tag: "groceries", Account: "checking"}); err != nil { + t.Fatal(err) + } + + loaded, err := LoadRules(root) + if err != nil { + t.Fatalf("the file no longer parses after appending: %v", err) + } + if len(loaded.Rule) != 2 { + t.Fatalf("got %d rules, want 2: %+v", len(loaded.Rule), loaded.Rule) + } + + // Appending, not prepending: the existing rule keeps precedence. + if loaded.Rule[0].Tag != "salary" { + t.Errorf("first rule = %+v, want the pre-existing salary rule", loaded.Rule[0]) + } + added := loaded.Rule[1] + if added.Match != "*LIDL*" || added.Tag != "groceries" || added.Account != "checking" { + t.Errorf("appended rule = %+v", added) + } +} + +func TestAppendRuleCreatesFile(t *testing.T) { + root := t.TempDir() + if err := AppendRule(root, Rule{Match: "*LIDL*", Tag: "groceries"}); err != nil { + t.Fatal(err) + } + loaded, err := LoadRules(root) + if err != nil { + t.Fatal(err) + } + if len(loaded.Rule) != 1 || loaded.Rule[0].Match != "*LIDL*" { + t.Errorf("rules = %+v, want the one appended rule", loaded.Rule) + } + // An omitted account must not be written as an empty string. + if loaded.Rule[0].Account != "" { + t.Errorf("account = %q, want empty", loaded.Rule[0].Account) + } +} + +func TestAppendRuleRejectsIncomplete(t *testing.T) { + root := t.TempDir() + cases := map[string]Rule{ + "no pattern": {Tag: "groceries"}, + "no verdict": {Match: "*LIDL*"}, + } + for name, r := range cases { + if err := AppendRule(root, r); err == nil { + t.Errorf("%s: expected an error", name) + } + } + if _, err := os.Stat(filepath.Join(root, RulesFile)); !os.IsNotExist(err) { + t.Error("a rejected rule must not create the file") + } +} + +// Globs and tags containing quotes must survive the round trip. +func TestAppendRuleQuotesValues(t *testing.T) { + root := t.TempDir() + if err := AppendRule(root, Rule{Match: `*"ODD" PAYEE*`, Tag: "misc", Transfer: true}); err != nil { + t.Fatal(err) + } + loaded, err := LoadRules(root) + if err != nil { + t.Fatalf("quoted values broke the file: %v", err) + } + if loaded.Rule[0].Match != `*"ODD" PAYEE*` { + t.Errorf("match = %q, want the quotes preserved", loaded.Rule[0].Match) + } + if !loaded.Rule[0].Transfer { + t.Error("transfer flag was not written") + } +} + func TestUserConfigPathFollowsXDG(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", "/custom/config") path, err := UserConfigPath() diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 9e36df1..aafb801 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -4,6 +4,7 @@ package tui import ( "fmt" + "sort" "strings" "github.com/charmbracelet/bubbles/spinner" @@ -13,6 +14,7 @@ import ( "github.com/charmbracelet/lipgloss" "git.petrovv.com/nikola/money/internal/config" + "git.petrovv.com/nikola/money/internal/glob" "git.petrovv.com/nikola/money/internal/importer" "git.petrovv.com/nikola/money/internal/model" "git.petrovv.com/nikola/money/internal/report" @@ -26,6 +28,9 @@ const ( viewAccounts view = iota viewTxns viewReport + viewRules + + viewCount = 4 ) // input is the modal state: the transaction list is read-only until the user @@ -60,6 +65,17 @@ type Model struct { txns []model.Transaction // rows currently shown in txnTable + // Rule builder: three inputs on the left, and a live preview on the right + // of which untagged descriptions the glob would catch. + ruleGlob textinput.Model + ruleAccount textinput.Model + ruleTag textinput.Model + ruleFocus int // which of the three inputs has the cursor + ruleTable table.Model + ruleReturn view // the view to go back to on esc + untagged []descGroup + ruleMatches int // untagged descriptions the current glob matches + filter store.Filter onlyUntagged bool @@ -75,6 +91,19 @@ var ( errStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")).Padding(0, 1) helpStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1) emptyStyle = lipgloss.NewStyle().Padding(1, 2) + + labelStyle = lipgloss.NewStyle().Faint(true) + focusedLabelStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62")) + // Both boxes are pinned to the same width: a focused input renders one + // extra cell for the cursor, which would otherwise shift the border as + // the cursor moves between fields. + boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")).Padding(0, 1).Width(ruleInputWidth + 2) + focusedBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")).Padding(0, 1).Width(ruleInputWidth + 2) + hintStyle = lipgloss.NewStyle().Faint(true).PaddingLeft(2) + matchCountStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(2) + ruleFormStyle = lipgloss.NewStyle().Width(ruleFormWidth) ) // Run starts the interface. @@ -93,6 +122,15 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En ti.Prompt = "" ti.CharLimit = 64 + newInput := func(placeholder string) textinput.Model { + in := textinput.New() + in.Prompt = "" + in.CharLimit = 80 + in.Width = ruleInputWidth + in.Placeholder = placeholder + return in + } + sp := spinner.New() sp.Spinner = spinner.Dot sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62")) @@ -128,6 +166,14 @@ func New(root string, db *store.DB, accounts []*config.Account, engine *rules.En {Title: "T", Width: 1}, {Title: "Description", Width: 40}, }), + ruleGlob: newInput("*LIDL*"), + ruleAccount: newInput("blank = every account"), + ruleTag: newInput("groceries"), + ruleTable: newTable([]table.Column{ + {Title: " ", Width: 1}, + {Title: "Untagged description", Width: 44}, + {Title: "N", Width: 4}, + }), reportTable: newTable([]table.Column{ {Title: "Tag", Width: 20}, {Title: "Cur", Width: 4}, @@ -236,6 +282,196 @@ func (m *Model) reloadReport(txns []model.Transaction) { m.reportTable.SetRows(out) } +// ruleFormWidth is the width of the rule builder's left-hand form, and +// ruleInputWidth the editable area inside its boxes. The form must stay wider +// than a box plus its border, padding and the block cursor, or lipgloss wraps +// the border and the panel falls apart. +const ( + ruleInputWidth = 26 + ruleFormWidth = ruleInputWidth + 12 +) + +// descGroup is one distinct untagged description and how often it occurs. +// Grouping matters: a statement holds the same payee dozens of times, and a +// rule is written against the description, not against individual rows. +type descGroup struct { + Description string + Accounts map[string]bool + Count int +} + +// reloadUntagged rebuilds the alphabetical list of untagged descriptions that +// the rule builder previews against. +func (m *Model) reloadUntagged() error { + txns, err := m.db.Transactions(store.Filter{Untagged: true}) + if err != nil { + return err + } + + byDesc := map[string]*descGroup{} + for _, t := range txns { + key := model.NormalizeDescription(t.Description) + g, ok := byDesc[key] + if !ok { + g = &descGroup{Description: t.Description, Accounts: map[string]bool{}} + byDesc[key] = g + } + g.Accounts[t.AccountSlug] = true + g.Count++ + } + + m.untagged = make([]descGroup, 0, len(byDesc)) + for _, g := range byDesc { + m.untagged = append(m.untagged, *g) + } + sort.Slice(m.untagged, func(i, j int) bool { + a := model.NormalizeDescription(m.untagged[i].Description) + b := model.NormalizeDescription(m.untagged[j].Description) + if a != b { + return a < b + } + return m.untagged[i].Description < m.untagged[j].Description + }) + + m.refreshRulePreview() + return nil +} + +// refreshRulePreview re-marks the preview against whatever is typed right now. +// It runs on every keystroke, so the glob is checked against descriptions only, +// never against the database. +func (m *Model) refreshRulePreview() { + var ( + pattern = strings.TrimSpace(m.ruleGlob.Value()) + account = strings.TrimSpace(m.ruleAccount.Value()) + rows = make([]table.Row, 0, len(m.untagged)) + ) + m.ruleMatches = 0 + + for _, g := range m.untagged { + // An account filter narrows the preview the same way the saved rule + // will narrow its matching. + if account != "" && !g.Accounts[account] { + continue + } + marker := " " + if pattern != "" && glob.Match(pattern, model.NormalizeDescription(g.Description)) { + marker = "▸" + m.ruleMatches++ + } + rows = append(rows, table.Row{marker, g.Description, fmt.Sprintf("%d", g.Count)}) + } + + cursor := m.ruleTable.Cursor() + m.ruleTable.SetRows(rows) + if cursor >= len(rows) { + cursor = len(rows) - 1 + } + if cursor < 0 { + cursor = 0 + } + m.ruleTable.SetCursor(cursor) +} + +// saveRule appends the composed rule to rules.toml, reloads the engine and +// retags, so the effect is visible immediately. +func (m *Model) saveRule() error { + r := config.Rule{ + Match: strings.TrimSpace(m.ruleGlob.Value()), + Account: strings.TrimSpace(m.ruleAccount.Value()), + Tag: strings.TrimSpace(m.ruleTag.Value()), + } + if r.Match == "" { + return fmt.Errorf("enter a glob first, e.g. *LIDL*") + } + if r.Tag == "" { + return fmt.Errorf("enter a tag to apply") + } + if r.Account != "" && !m.knownAccount(r.Account) { + return fmt.Errorf("no account called %q; leave it blank to apply to every account", r.Account) + } + + if err := config.AppendRule(m.root, r); err != nil { + return err + } + + // Re-read the file rather than appending to the in-memory engine, so what + // runs is exactly what is now on disk. + loaded, err := config.LoadRules(m.root) + if err != nil { + return fmt.Errorf("rule saved, but re-reading rules.toml failed: %w", err) + } + m.engine = rules.New(loaded) + + n, err := m.engine.Retag(m.db) + if err != nil { + return err + } + + m.status = fmt.Sprintf("saved rule %s → %s, %d transactions retagged", r.Match, r.Tag, n) + m.ruleGlob.SetValue("") + m.ruleTag.SetValue("") + m.setRuleFocus(0) + return m.reload() +} + +func (m *Model) knownAccount(slug string) bool { + for _, a := range m.accounts { + if a.Slug == slug { + return true + } + } + // Fall back to the index, so accounts work even when the caller passed no + // config (as the tests do). + accounts, err := m.db.Accounts() + if err != nil { + return false + } + for _, a := range accounts { + if a.Slug == slug { + return true + } + } + return false +} + +// openRuleBuilder switches to the rule builder, remembering where to return to +// and seeding the account field from whatever account is being browsed. +func (m *Model) openRuleBuilder() tea.Cmd { + if m.view != viewRules { + m.ruleReturn = m.view + } + m.view = viewRules + if m.ruleAccount.Value() == "" && m.filter.AccountSlug != "" { + m.ruleAccount.SetValue(m.filter.AccountSlug) + } + m.setRuleFocus(0) + if err := m.reloadUntagged(); err != nil { + m.err = err + } + return textinput.Blink +} + +// ruleInputs lists the form fields in tab order. +func (m *Model) ruleInputs() []*textinput.Model { + return []*textinput.Model{&m.ruleGlob, &m.ruleAccount, &m.ruleTag} +} + +// setRuleFocus moves the cursor between the form fields, wrapping around. +func (m *Model) setRuleFocus(i int) { + inputs := m.ruleInputs() + n := len(inputs) + m.ruleFocus = ((i % n) + n) % n + for j, in := range inputs { + if j == m.ruleFocus { + in.Focus() + in.CursorEnd() + continue + } + in.Blur() + } +} + // selected returns the transaction under the cursor, if any. func (m *Model) selected() (model.Transaction, bool) { i := m.txnTable.Cursor() @@ -284,11 +520,55 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.input != inputNone { return m.updateInput(msg) } + // The rule builder is a form: every printable key belongs to the + // focused input, so the global single-letter keymap cannot apply. + if m.view == viewRules { + return m.updateRules(msg) + } return m.updateNormal(msg) } return m, nil } +// updateRules drives the rule builder form. +func (m *Model) updateRules(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + m.view = m.ruleReturn + return m, nil + case "tab", "down": + m.setRuleFocus(m.ruleFocus + 1) + return m, nil + case "shift+tab", "up": + m.setRuleFocus(m.ruleFocus - 1) + return m, nil + case "pgdown": + m.ruleTable.MoveDown(10) + return m, nil + case "pgup": + m.ruleTable.MoveUp(10) + return m, nil + case "enter": + m.err = nil + if err := m.saveRule(); err != nil { + m.err = err + return m, nil + } + if err := m.reloadUntagged(); err != nil { + m.err = err + } + return m, nil + } + + var cmd tea.Cmd + inputs := m.ruleInputs() + *inputs[m.ruleFocus], cmd = inputs[m.ruleFocus].Update(msg) + m.refreshRulePreview() + return m, cmd +} + func (m *Model) finishImport(msg importDoneMsg) (tea.Model, tea.Cmd) { m.importing = false if msg.err != nil { @@ -339,6 +619,18 @@ func (m *Model) resize() { m.accountTable.SetHeight(h) m.txnTable.SetHeight(h) m.reportTable.SetHeight(h) + m.ruleTable.SetHeight(h) + + // The preview list gets whatever the form does not use. + if m.width > 0 { + cols := m.ruleTable.Columns() + desc := m.width - ruleFormWidth - cols[0].Width - cols[2].Width - 10 + if desc < 20 { + desc = 20 + } + cols[1].Width = desc + m.ruleTable.SetColumns(cols) + } // Give the description column whatever is left over. if m.width > 0 { @@ -417,8 +709,13 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "3": m.view = viewReport return m, nil + case "4": + return m, m.openRuleBuilder() case "tab": - m.view = (m.view + 1) % 3 + m.view = (m.view + 1) % viewCount + if m.view == viewRules { + return m, m.openRuleBuilder() + } return m, nil case "enter": @@ -572,6 +869,8 @@ func (m *Model) View() string { b.WriteString(m.txnTable.View()) case viewReport: b.WriteString(m.reportTable.View()) + case viewRules: + b.WriteString(m.rulesView()) } } b.WriteString("\n") @@ -593,6 +892,41 @@ func (m *Model) View() string { return b.String() } +// rulesView puts the form on the left and the live preview on the right. +func (m *Model) rulesView() string { + return lipgloss.JoinHorizontal(lipgloss.Top, m.ruleFormView(), m.ruleTable.View()) +} + +func (m *Model) ruleFormView() string { + field := func(i int, label, help string) string { + name := labelStyle.Render(" " + label) + box := boxStyle.Render(m.ruleInputs()[i].View()) + if i == m.ruleFocus { + name = focusedLabelStyle.Render("▸ " + label) + box = focusedBoxStyle.Render(m.ruleInputs()[i].View()) + } + return name + "\n" + box + "\n" + hintStyle.Render(help) + "\n" + } + + var b strings.Builder + b.WriteString(field(0, "glob", "vs. the description")) + b.WriteString("\n") + b.WriteString(field(1, "account", "blank = all accounts")) + b.WriteString("\n") + b.WriteString(field(2, "tag", "applied to matches")) + b.WriteString("\n") + + // The count is the whole point of the preview: it says what the rule will + // do before it is written to disk. + summary := fmt.Sprintf("%d of %d descriptions match", m.ruleMatches, len(m.ruleTable.Rows())) + if strings.TrimSpace(m.ruleGlob.Value()) == "" { + summary = fmt.Sprintf("%d untagged descriptions", len(m.ruleTable.Rows())) + } + b.WriteString(matchCountStyle.Render(summary)) + + return ruleFormStyle.Render(b.String()) +} + // emptyMessage explains an empty view and names the key that fixes it, or // returns "" when there is something to show. func (m *Model) emptyMessage() string { @@ -656,6 +990,8 @@ func (m *Model) title() string { switch m.view { case viewAccounts: return "money · accounts" + case viewRules: + return "money · rule builder · writes to rules.toml" case viewReport: return "money · report · " + scope + " · transfers excluded" default: @@ -681,10 +1017,12 @@ func (m *Model) help() string { } switch m.view { case viewAccounts: - return "enter open · 2 transactions · 3 report · i import · r retag · q quit" + return "enter open · 2 transactions · 3 report · 4 rules · i import · r retag · q quit" + case viewRules: + return "tab/↑↓ field · pgup/pgdn scroll list · enter save rule · esc back · ctrl+c quit" case viewReport: - return "1 accounts · 2 transactions · u untagged · a all accounts · q quit" + return "1 accounts · 2 transactions · 4 rules · u untagged · a all accounts · q quit" default: - return "t tag · x transfer · c clear · / search · u untagged · a all · i import · r retag · 1 accounts · 3 report · q quit" + return "t tag · x transfer · c clear · / search · u untagged · a all · i import · r retag · 4 rules · 1 accounts · 3 report · q quit" } } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index b2c17aa..05a0e52 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -2,6 +2,8 @@ package tui import ( "errors" + "fmt" + "os" "path/filepath" "strings" "testing" @@ -380,6 +382,278 @@ func TestImportWarningsSurface(t *testing.T) { } } +// newRuleModel builds a model whose data root is a real directory, so the rule +// builder can actually write rules.toml. +func newRuleModel(t *testing.T) (*Model, *store.DB, string) { + t.Helper() + root := t.TempDir() + + db, err := store.Open(filepath.Join(root, ".money", "index.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + accountID, err := db.UpsertAccount(model.Account{ + Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2, + }) + if err != nil { + t.Fatal(err) + } + savingsID, err := db.UpsertAccount(model.Account{ + Slug: "savings", Name: "Savings", Currency: "EUR", MinorDigits: 2, + }) + if err != nil { + t.Fatal(err) + } + sourceID, err := db.SourceFile(accountID, "checking/st.csv", "sha", "2026-01-01T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + // Deliberately unsorted, with LIDL appearing twice so the preview has + // something to group. + seed := []struct { + account int64 + desc string + }{ + {accountID, "ZZZ LAST ALPHABETICALLY"}, + {accountID, "LIDL SOFIA 4412"}, + {accountID, "LIDL SOFIA 4412"}, + {accountID, "AAA FIRST ALPHABETICALLY"}, + {savingsID, "LIDL VARNA 9911"}, + } + for i, s := range seed { + if _, err := db.InsertTransaction(model.Transaction{ + AccountID: s.account, + SourceFileID: sourceID, + Fingerprint: fmt.Sprintf("fp-%d", i), + Date: "2026-01-01", + Description: s.desc, + AmountMinor: -1000, + }); err != nil { + t.Fatal(err) + } + } + + m := New(root, db, []*config.Account{ + {Slug: "checking", Currency: "EUR", Parser: "csv"}, + {Slug: "savings", Currency: "EUR", Parser: "csv"}, + }, rules.New(&config.Rules{})) + if err := m.reload(); err != nil { + t.Fatal(err) + } + m.Update(tea.WindowSizeMsg{Width: 140, Height: 30}) + return m, db, root +} + +// descriptions returns the preview list as it is rendered, in order. +func previewRows(m *Model) (all []string, matched []string) { + for _, row := range m.ruleTable.Rows() { + all = append(all, row[1]) + if row[0] == "▸" { + matched = append(matched, row[1]) + } + } + return all, matched +} + +func TestRuleBuilderListsUntaggedAlphabetically(t *testing.T) { + m, _, _ := newRuleModel(t) + key(t, m, "4") + + if m.view != viewRules { + t.Fatal("expected 4 to open the rule builder") + } + + all, _ := previewRows(m) + want := []string{ + "AAA FIRST ALPHABETICALLY", + "LIDL SOFIA 4412", + "LIDL VARNA 9911", + "ZZZ LAST ALPHABETICALLY", + } + if len(all) != len(want) { + t.Fatalf("preview = %v, want %v (identical descriptions must be grouped)", all, want) + } + for i := range want { + if all[i] != want[i] { + t.Errorf("row %d = %q, want %q", i, all[i], want[i]) + } + } + + // The repeated LIDL row carries its occurrence count. + for _, row := range m.ruleTable.Rows() { + if row[1] == "LIDL SOFIA 4412" && row[2] != "2" { + t.Errorf("count for the repeated description = %q, want 2", row[2]) + } + } +} + +func TestRuleBuilderPreviewsGlobAsYouType(t *testing.T) { + m, _, _ := newRuleModel(t) + key(t, m, "4") + + if _, matched := previewRows(m); len(matched) != 0 { + t.Errorf("nothing should match before a glob is typed, got %v", matched) + } + + typeText(t, m, "*LIDL*") + _, matched := previewRows(m) + if len(matched) != 2 || m.ruleMatches != 2 { + t.Fatalf("matched = %v (count %d), want both LIDL rows", matched, m.ruleMatches) + } + + // Narrowing the glob narrows the preview. + typeText(t, m, "\b") // not a backspace key; replace the value instead + m.ruleGlob.SetValue("*SOFIA*") + m.refreshRulePreview() + if _, matched := previewRows(m); len(matched) != 1 || matched[0] != "LIDL SOFIA 4412" { + t.Errorf("matched = %v, want only the Sofia row", matched) + } +} + +// The account field scopes the preview the same way it will scope the rule. +func TestRuleBuilderAccountFiltersPreview(t *testing.T) { + m, _, _ := newRuleModel(t) + key(t, m, "4") + + m.ruleGlob.SetValue("*LIDL*") + m.ruleAccount.SetValue("savings") + m.refreshRulePreview() + + all, matched := previewRows(m) + if len(all) != 1 || all[0] != "LIDL VARNA 9911" { + t.Errorf("preview = %v, want only the savings row", all) + } + if len(matched) != 1 { + t.Errorf("matched = %v, want the savings row to match", matched) + } +} + +// Typing must never reach the global keymap: q, i and 1 are ordinary text here. +func TestRuleBuilderSwallowsGlobalKeys(t *testing.T) { + m, _, _ := newRuleModel(t) + key(t, m, "4") + + typeText(t, m, "q1i") + if m.view != viewRules { + t.Fatal("typing must not switch views") + } + if got := m.ruleGlob.Value(); got != "q1i" { + t.Errorf("glob = %q, want the typed characters", got) + } + if m.importing { + t.Error("typing i must not start an import") + } +} + +func TestRuleBuilderSavesRuleAndRetags(t *testing.T) { + m, db, root := newRuleModel(t) + key(t, m, "4") + + m.ruleGlob.SetValue("*LIDL*") + m.setRuleFocus(2) + m.ruleTag.SetValue("groceries") + key(t, m, "enter") + + if m.err != nil { + t.Fatalf("saving failed: %v", m.err) + } + + // It landed in rules.toml... + loaded, err := config.LoadRules(root) + if err != nil { + t.Fatal(err) + } + if len(loaded.Rule) != 1 || loaded.Rule[0].Match != "*LIDL*" || loaded.Rule[0].Tag != "groceries" { + t.Fatalf("rules.toml holds %+v", loaded.Rule) + } + + // ...and was applied straight away. + txns, err := db.Transactions(store.Filter{}) + if err != nil { + t.Fatal(err) + } + tagged := 0 + for _, txn := range txns { + if txn.Tag() == "groceries" { + tagged++ + } + } + if tagged != 3 { + t.Errorf("%d transactions tagged, want 3", tagged) + } + if !strings.Contains(m.status, "3 transactions retagged") { + t.Errorf("status = %q, want the retag count", m.status) + } + + // Those rows are no longer untagged, so the preview drops them. + all, _ := previewRows(m) + for _, d := range all { + if strings.Contains(d, "LIDL") { + t.Errorf("preview still lists %q after it was tagged", d) + } + } + // The glob and tag are cleared, ready for the next rule. + if m.ruleGlob.Value() != "" || m.ruleTag.Value() != "" { + t.Errorf("form not reset: glob=%q tag=%q", m.ruleGlob.Value(), m.ruleTag.Value()) + } +} + +func TestRuleBuilderRejectsIncompleteRule(t *testing.T) { + m, _, root := newRuleModel(t) + key(t, m, "4") + + // No glob, no tag. + m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if m.err == nil { + t.Error("expected an error when saving an empty form") + } + + // Glob but no tag. + m.err = nil + m.ruleGlob.SetValue("*LIDL*") + m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if m.err == nil || !strings.Contains(m.err.Error(), "tag") { + t.Errorf("err = %v, want a complaint about the missing tag", m.err) + } + + // An account that does not exist is caught before anything is written. + m.err = nil + m.ruleTag.SetValue("groceries") + m.ruleAccount.SetValue("nosuchaccount") + m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if m.err == nil || !strings.Contains(m.err.Error(), "nosuchaccount") { + t.Errorf("err = %v, want a complaint about the unknown account", m.err) + } + + if _, err := os.Stat(filepath.Join(root, config.RulesFile)); !os.IsNotExist(err) { + t.Error("a rejected rule must not write rules.toml") + } +} + +func TestRuleBuilderTabCyclesFieldsAndEscLeaves(t *testing.T) { + m, _, _ := newRuleModel(t) + key(t, m, "2") // come from the transactions view + key(t, m, "4") + + for i, want := range []int{1, 2, 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) + } + } + if !m.ruleGlob.Focused() { + t.Error("expected the glob input to be focused after wrapping around") + } + + m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + if m.view != viewTxns { + t.Errorf("esc returned to view %d, want the transactions view it came from", m.view) + } +} + // newEmptyModel builds a model over an index with nothing in it. func newEmptyModel(t *testing.T, accounts []*config.Account) *Model { t.Helper()