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
+38 -16
View File
@@ -180,33 +180,40 @@ the folders on disk and the index; tags from every tag in use plus any named in
which is what stops `groceries` acquiring a `grocery` twin.
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. Legs
of a matched transfer are left out too: they are already accounted for by the
definition that paired them, and the report leaves them out anyway.
narrows the preview to that account. The preview lists only transactions no
existing rule has already tagged, so it answers "what is still waiting for a
rule" rather than "what would this rule win". Those differ when the new rule is
narrower than an existing one: a `*LIDL SOFIA*` written while `*LIDL*` is
already tagging those rows previews as nothing and still takes them on save,
because the more specific rule wins. Legs of a matched transfer are left out
too: they are already accounted for by the definition that paired them, and the
report leaves them out anyway.
### Rules (`5`)
Every rule in file order, with the number of transactions it actually claims.
Rules that claim none are marked `✗`.
Every rule in file order — the order they are written in, not the order they
are tried in — with the number of transactions it actually claims. Rules that
claim none are marked `✗`.
```
money · rules · 5 rules · 2 match nothing
# Pattern Account Tag Txns Note
1 *LIDL* (all) groceries 3 the weekly shop
2 ✗ *LIDL SOFIA* (all) shadowed 0
1 *LIDL SOFIA* (all) groceries 3 the weekly shop
2 ✗ *LIDL* (all) shadowed 0
3 ✗ *OLD BANK NAME* (all) dead 0 closed in 2025
4 *ZARA* (all) clothes 1
5 *КАУФЛАНД* checking groceries 1 4412 is the branch
```
The count is how many transactions the rule *wins*, not how many its glob could
match. Rule 2 above matches `LIDL SOFIA 4412` perfectly well, but rule 1 claims
it first, so rule 2 is dead weight — which is the point of the screen. A rule
can therefore reach zero either by matching nothing or by being shadowed, and
both are worth deleting.
match. Rule 2 above matches `LIDL SOFIA 4412` perfectly well, but rule 1 spells
out more of it and so claims it first; with nothing else here that says `LIDL`,
rule 2 is dead weight — which is the point of the screen. A rule can therefore
reach zero either by matching nothing or by being shadowed, and both are worth
deleting. Note that shadowing has nothing to do with the numbering: rule 2 would
be just as dead written above rule 1, because precedence is decided by how
specific a rule is and not by where it sits.
`d` deletes the selected rule, `p` deletes every rule marked `✗` at once, and
both ask for a `y` first. `r` recounts against what is currently in the index,
@@ -317,9 +324,24 @@ it. `r` re-pairs against what is currently in the index.
## rules.toml
Rules are evaluated in file order and the **first match wins**, so put specific
rules above general ones. Patterns are globs (`*` and `?`) matched
case-insensitively, with whitespace collapsed.
**The most specific rule wins**, so `*NIKOLA*` claims what it names even with a
broad `*NIK*` sitting above it, and a catch-all can be written anywhere without
swallowing the file. Patterns are globs (`*` and `?`) matched case-insensitively,
with whitespace collapsed.
Specificity is measured on what a rule spells out, in this order:
1. how many literal characters its patterns pin down — `*LIDL SOFIA*` (10)
beats `*LIDL*` (4), and a bare `*` (0) is tried last of all. A rule setting
both `match` and `type` has to satisfy both, so both count.
2. how few `*` it uses, the only wildcard that swallows a run of any length:
`LIDL` fixes both ends where `*LIDL*` does not, so it goes first. (`?` is
not counted either way: it fixes a length, not a character.)
3. whether it names an `account`, which is a narrowing the unscoped rule with
the same patterns does not have.
Rules that tie on all three fall back to file order, and the earlier one wins —
which is what keeps appending a rule from disturbing one already written.
A rule matches on `match` (the description) and `type` (the bank's own
classification). Setting both is an "and": both must match.