// Package rules applies the ordered glob rules from rules.toml to // transactions, deciding their automatic tag. // // Only rule_tag is ever written. A tag set by hand in the TUI lives in its own // column and survives any number of re-runs. 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 }