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 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 01:41:24 +02:00
co-authored by Claude Opus 5
parent 3ba26cadae
commit aeca203855
6 changed files with 803 additions and 5 deletions
+274
View File
@@ -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()