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
+19 -6
View File
@@ -124,10 +124,23 @@ milliseconds. That is the checksum skip working, not a failure.
**Money is `int64` minor units**, never a float. Per-account currency, no
conversion, and totals are never summed across currencies.
**First matching rule wins**, so specific rules belong above general ones.
A rule setting both `match` and `type` requires both of them.
`config.AppendRule` therefore appends — never prepends — so saving from the
rule builder cannot shadow a rule the user wrote by hand.
**The most specific rule wins, not the topmost.** `rules.Engine` sorts the
rules once in `New` and matches in that order: most literal characters first,
then fewest `*`, then account-scoped over unscoped, with a *stable* sort so
equally specific rules keep file order and the earlier one still wins. That is
what lets `*NIKOLA*` carve an exception out of `*NIK*` from anywhere in the
file, and a catch-all `*` sit wherever it reads best. A rule setting both
`match` and `type` requires both of them, and both count towards its literals.
The two orders must not be confused. `Engine.rules` stays in file order and
`Rules()`, `Usage` and `MatchIndex` all speak in file positions, because that
is what the rules screen numbers, what `config.DeleteRules` deletes by, and
what the user can point at in rules.toml; only `Engine.order` is sorted.
Anything new that reports a rule must report its file position too.
`config.AppendRule` still appends rather than prepends, but that now only
settles ties: a saved rule cannot displace an equally specific one written by
hand, while a narrower one is meant to take precedence and does.
**The builders are forms, so the global keymap must not apply there.**
`Update` routes to `updateRules` / `updateTransfers` before `updateNormal`
@@ -145,8 +158,8 @@ field navigation, and lets `ctrl+n` / `ctrl+p` through to the input for cycling.
it afterwards or `ctrl+n` would offer candidates that no longer fit the value.
**A rule's usage count is how many transactions it wins, not how many its glob
could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by an
earlier one correctly reports zero. That is what makes the rules screen able to
could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by a
more specific one correctly reports zero. That is what makes the rules screen able to
find dead rules at all.
**A rule's `note` is documentation that round-trips.** It is a TOML key rather