A tag could come from two places: rules.toml, or the TUI's t key, which wrote manual_tag with COALESCE(manual_tag, rule_tag) deciding the winner. That split paid for itself in the first invariant of the codebase, in ClearOverrides and the c key, in the * marker on the tag column, and in the one exception to a disposable index -- a tag set by hand was the only thing in index.db that the statements could not reproduce. Now rules.toml decides every tag. The index is derived entirely from the statements plus that file, so deleting it and re-importing gets back exactly what was there, and retag has nothing to be careful of. Tagging a one-off means writing a narrow rule on screen 4, which previews what the glob catches before it is saved. A manual tag in an existing index is dropped along with the column the first time this build opens it, and those rows read as whatever the rules say, or as untagged. TestManualTagSurvivesRetag guarded the invariant that has just been removed; TestRetagRewritesEveryTag replaces it with the one that took its place, and keeps Retag itself covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
3.3 KiB
Go
112 lines
3.3 KiB
Go
// Package rules applies the ordered glob rules from rules.toml to
|
|
// transactions, deciding their tag. It is the only thing that decides a tag,
|
|
// so rule_tag is derived state and Retag can rewrite it wholesale at any time.
|
|
package rules
|
|
|
|
import (
|
|
"git.petrovv.com/nikola/money/internal/config"
|
|
"git.petrovv.com/nikola/money/internal/glob"
|
|
"git.petrovv.com/nikola/money/internal/model"
|
|
"git.petrovv.com/nikola/money/internal/store"
|
|
)
|
|
|
|
// Engine evaluates rules in file order; the first match wins.
|
|
type Engine struct {
|
|
rules []config.Rule
|
|
}
|
|
|
|
// New builds an engine from the parsed rules file.
|
|
func New(r *config.Rules) *Engine {
|
|
return &Engine{rules: r.Rule}
|
|
}
|
|
|
|
// Rules returns the ordered rules, as loaded from rules.toml.
|
|
func (e *Engine) Rules() []config.Rule { return e.rules }
|
|
|
|
// Match returns the first rule matching a transaction on the given account, or
|
|
// nil if none does.
|
|
func (e *Engine) Match(accountSlug string, t model.Transaction) *config.Rule {
|
|
if i := e.MatchIndex(accountSlug, t); i >= 0 {
|
|
return &e.rules[i]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MatchIndex returns the position of the first rule matching a transaction, or
|
|
// -1 if none does. Every pattern a rule sets must match: a rule with both
|
|
// match and type is an "and", not an "or".
|
|
//
|
|
// The position matters as well as the rule: because the first match wins, a
|
|
// rule that is fully shadowed by an earlier one never applies to anything, and
|
|
// only the index reveals that.
|
|
func (e *Engine) MatchIndex(accountSlug string, t model.Transaction) int {
|
|
var (
|
|
description = model.NormalizeDescription(t.Description)
|
|
kind = model.NormalizeDescription(t.Type)
|
|
)
|
|
for i := range e.rules {
|
|
r := &e.rules[i]
|
|
if r.Account != "" && r.Account != accountSlug {
|
|
continue
|
|
}
|
|
if r.Match != "" && !glob.Match(r.Match, description) {
|
|
continue
|
|
}
|
|
if r.Type != "" && !glob.Match(r.Type, kind) {
|
|
continue
|
|
}
|
|
return i
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// Usage counts how many transactions each rule actually claims. A rule with a
|
|
// count of zero is dead: either nothing matches it, or an earlier rule takes
|
|
// everything it would have caught.
|
|
func (e *Engine) Usage(txns []model.Transaction) []int {
|
|
counts := make([]int, len(e.rules))
|
|
for _, t := range txns {
|
|
if i := e.MatchIndex(t.AccountSlug, t); i >= 0 {
|
|
counts[i]++
|
|
}
|
|
}
|
|
return counts
|
|
}
|
|
|
|
// ApplyTxn returns the tag for a transaction, empty if no rule matches.
|
|
func (e *Engine) ApplyTxn(accountSlug string, t model.Transaction) string {
|
|
if r := e.Match(accountSlug, t); r != nil {
|
|
return r.Tag
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Apply is the description-only shorthand, for callers that have nothing else.
|
|
func (e *Engine) Apply(accountSlug, description string) string {
|
|
return e.ApplyTxn(accountSlug, model.Transaction{Description: description})
|
|
}
|
|
|
|
// Retag recomputes rule verdicts for every transaction in the index and
|
|
// writes them back. It returns how many rows changed.
|
|
func (e *Engine) Retag(db *store.DB) (int, error) {
|
|
txns, err := db.Transactions(store.Filter{})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var changed []store.RuleAssignment
|
|
for _, t := range txns {
|
|
tag := e.ApplyTxn(t.AccountSlug, t)
|
|
if tag == t.RuleTag {
|
|
continue
|
|
}
|
|
changed = append(changed, store.RuleAssignment{ID: t.ID, Tag: tag})
|
|
}
|
|
if len(changed) == 0 {
|
|
return 0, nil
|
|
}
|
|
if err := db.ApplyRuleResults(changed); err != nil {
|
|
return 0, err
|
|
}
|
|
return len(changed), nil
|
|
}
|