Try the most specific rule first, not the topmost

File order decided precedence, so a narrow rule had to be written above the
broad one it carves an exception out of -- an ordering constraint the file
cannot show and the user has to remember. *NIKOLA* below *NIK* silently matched
nothing, and a catch-all * could only ever be the last line.

Engine.New now sorts once and MatchIndex walks that order: most literal
characters first, then fewest *, then account-scoped over unscoped. Literals
are what a rule commits to and a * is what it gives up, so a bare * is tried
last wherever it sits. The sort is stable, so equally specific rules keep file
order and the earlier one wins -- which is all position decides now, and why
AppendRule can keep appending without displacing a rule written by hand.

The two orders must not be confused: Rules(), Usage and MatchIndex still speak
in file positions, because that is what the rules screen numbers and what
DeleteRules deletes by. A shadowed rule still reports zero usage, but a zero no
longer says anything about where the rule sits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 19:30:04 +02:00
co-authored by Claude Opus 5
parent 5289250400
commit 442684be60
7 changed files with 217 additions and 65 deletions
+3 -2
View File
@@ -664,8 +664,9 @@ func acceptCompletion(in *textinput.Model) bool {
// reloadRuleList counts, for every rule, how many transactions it actually
// claims. A rule can match nothing because no description fits it, or because
// an earlier rule already took everything it would have caught; both show up
// here as a zero.
// a more specific rule already took everything it would have caught; both show
// up here as a zero. The rows stay in file order — that is the number the user
// can find in rules.toml, and precedence is not read off it any more.
func (m *Model) reloadRuleList() error {
txns, err := m.db.Transactions(store.Filter{})
if err != nil {