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:
@@ -108,35 +108,72 @@ func TestRetagRewritesEveryTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstMatchWins(t *testing.T) {
|
||||
engine := New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*LIDL EXPRESS*", Tag: "snacks"},
|
||||
{Match: "*LIDL*", Tag: "groceries"},
|
||||
}})
|
||||
if tag := engine.Apply("checking", "CARD LIDL EXPRESS 12"); tag != "snacks" {
|
||||
t.Errorf("tag = %q, want snacks (earlier rule must win)", tag)
|
||||
// The narrower rule claims what it names wherever it sits in the file: the
|
||||
// catch-all above it must not swallow the exception written below.
|
||||
func TestMoreSpecificRuleWins(t *testing.T) {
|
||||
both := [][]config.Rule{
|
||||
{{Match: "NIK*", Tag: "misc"}, {Match: "NIKOLA*", Tag: "family"}},
|
||||
{{Match: "NIKOLA*", Tag: "family"}, {Match: "NIK*", Tag: "misc"}},
|
||||
}
|
||||
if tag := engine.Apply("checking", "CARD LIDL 12"); tag != "groceries" {
|
||||
t.Errorf("tag = %q, want groceries", tag)
|
||||
for _, rs := range both {
|
||||
engine := New(&config.Rules{Rule: rs})
|
||||
if tag := engine.Apply("checking", "NIKOLA PETROV"); tag != "family" {
|
||||
t.Errorf("%q first: tag = %q, want family", rs[0].Match, tag)
|
||||
}
|
||||
if tag := engine.Apply("checking", "NIKI TODOROV"); tag != "misc" {
|
||||
t.Errorf("%q first: tag = %q, want misc", rs[0].Match, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Specificity is measured on what the pattern spells out, so the wildcards
|
||||
// around a literal do not buy it precedence, and a bare "*" is always last.
|
||||
func TestSpecificityOrder(t *testing.T) {
|
||||
engine := New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*", Tag: "other"},
|
||||
{Match: "*LIDL*", Tag: "groceries"},
|
||||
{Match: "*LIDL EXPRESS*", Tag: "snacks"},
|
||||
}})
|
||||
for _, c := range []struct{ desc, want string }{
|
||||
{"CARD LIDL EXPRESS 12", "snacks"},
|
||||
{"CARD LIDL 12", "groceries"},
|
||||
{"SOMETHING ELSE", "other"},
|
||||
} {
|
||||
if tag := engine.Apply("checking", c.desc); tag != c.want {
|
||||
t.Errorf("%q: tag = %q, want %q", c.desc, tag, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two rules that are equally specific are still decided by the file: the
|
||||
// earlier one wins, which is what makes appending a rule safe.
|
||||
func TestEqualSpecificityKeepsFileOrder(t *testing.T) {
|
||||
engine := New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*ACME*", Tag: "first"},
|
||||
{Match: "*ACME*", Tag: "second"},
|
||||
}})
|
||||
if tag := engine.Apply("checking", "ACME LTD"); tag != "first" {
|
||||
t.Errorf("tag = %q, want first", tag)
|
||||
}
|
||||
if tag := engine.Apply("checking", "SOMETHING ELSE"); tag != "" {
|
||||
t.Errorf("tag = %q, want empty for an unmatched description", tag)
|
||||
}
|
||||
}
|
||||
|
||||
// A rule can be dead two ways: nothing matches it, or an earlier rule already
|
||||
// claimed everything it would have caught. Usage must report both as zero.
|
||||
func TestUsageCountsFirstMatchOnly(t *testing.T) {
|
||||
// A rule can be dead two ways: nothing matches it, or a rule that beats it
|
||||
// already claimed everything it would have caught. Usage must report both as
|
||||
// zero, indexed by file position however the rules are ordered for matching.
|
||||
func TestUsageCountsWinnersOnly(t *testing.T) {
|
||||
engine := New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*LIDL*", Tag: "groceries"}, // claims both LIDL rows
|
||||
{Match: "*LIDL SOFIA*", Tag: "shadowed"}, // fully shadowed by the above
|
||||
{Match: "*LIDL SOFIA*", Tag: "groceries"}, // the narrower of the two
|
||||
{Match: "*LIDL*", Tag: "shadowed"}, // every LIDL row here is Sofia
|
||||
{Match: "*NOTHING MATCHES ME*", Tag: "no"}, // matches nothing at all
|
||||
{Match: "*PAYROLL*", Tag: "salary"}, // claims one row
|
||||
}})
|
||||
|
||||
txns := []model.Transaction{
|
||||
{AccountSlug: "checking", Description: "LIDL SOFIA 4412"},
|
||||
{AccountSlug: "checking", Description: "LIDL VARNA 9911"},
|
||||
{AccountSlug: "checking", Description: "LIDL SOFIA 9911"},
|
||||
{AccountSlug: "checking", Description: "ACME PAYROLL"},
|
||||
{AccountSlug: "checking", Description: "UNMATCHED SHOP"},
|
||||
}
|
||||
@@ -153,27 +190,31 @@ func TestUsageCountsFirstMatchOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The index is the rule's position in rules.toml, not its position in the order
|
||||
// it was tried in — that is the number the rules screen shows and deletes by.
|
||||
func TestMatchIndex(t *testing.T) {
|
||||
engine := New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*LIDL EXPRESS*", Tag: "snacks"},
|
||||
{Match: "*LIDL*", Tag: "groceries"},
|
||||
{Match: "*LIDL EXPRESS*", Tag: "snacks"},
|
||||
}})
|
||||
|
||||
if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL EXPRESS 1"}); got != 0 {
|
||||
t.Errorf("index = %d, want 0", got)
|
||||
}
|
||||
if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL 1"}); got != 1 {
|
||||
if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL EXPRESS 1"}); got != 1 {
|
||||
t.Errorf("index = %d, want 1", got)
|
||||
}
|
||||
if got := engine.MatchIndex("checking", model.Transaction{Description: "LIDL 1"}); got != 0 {
|
||||
t.Errorf("index = %d, want 0", got)
|
||||
}
|
||||
if got := engine.MatchIndex("checking", model.Transaction{Description: "OTHER"}); got != -1 {
|
||||
t.Errorf("index = %d, want -1 for no match", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Naming an account is itself a narrowing, so the scoped rule beats the
|
||||
// identical unscoped one even when the unscoped one is written first.
|
||||
func TestAccountScopedRule(t *testing.T) {
|
||||
engine := New(&config.Rules{Rule: []config.Rule{
|
||||
{Match: "*TRANSFER*", Tag: "transfer", Account: "savings"},
|
||||
{Match: "*TRANSFER*", Tag: "misc"},
|
||||
{Match: "*TRANSFER*", Tag: "transfer", Account: "savings"},
|
||||
}})
|
||||
|
||||
if tag := engine.Apply("savings", "TRANSFER FROM CHECKING"); tag != "transfer" {
|
||||
|
||||
Reference in New Issue
Block a user