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()