Files
money/internal/config/config.go
T
nikolaandClaude Opus 5 442684be60 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>
2026-08-17 19:30:04 +02:00

585 lines
19 KiB
Go

// Package config loads the two hand-edited files the tool reads: the
// data-root-wide rules.toml, and one account.toml per account folder.
package config
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/BurntSushi/toml"
)
const (
// RulesFile is the rules file at the root of the data directory.
RulesFile = "rules.toml"
// AccountFile is the per-account config inside each account folder.
AccountFile = "account.toml"
// IndexFile is the rebuildable SQLite index, at the root of the data
// directory alongside rules.toml.
IndexFile = "index.db"
)
// Rule is one entry in rules.toml. The most specific rule that matches wins,
// with file order breaking ties between equally specific ones; rules.Engine
// owns that ordering.
type Rule struct {
Match string `toml:"match"`
Tag string `toml:"tag"`
Account string `toml:"account"` // optional: restrict to one account slug
// Type matches the bank's own classification, e.g. Revolut's CARD_PAYMENT.
// Optional; when set, it must match as well as Match.
Type string `toml:"type"`
// Note is free text for the reader: why the rule exists, or what the
// unrecognisable payee behind the glob actually is. It never affects
// matching. It is a key rather than a comment so it survives a round trip
// through LoadRules and can be shown on the rules screen.
Note string `toml:"note"`
}
// Transfer is one entry in rules.toml describing money moved between two
// accounts the user owns. Both sides are named: a transfer is only ever a pair,
// which is what lets the report drop it without leaving half a movement behind.
//
// All four patterns are required. A one-sided definition would be a rule.
type Transfer struct {
FromAccount string `toml:"from_account"`
FromDesc string `toml:"from_desc"` // glob vs. the leaving leg's description
ToAccount string `toml:"to_account"`
ToDesc string `toml:"to_desc"` // glob vs. the arriving leg's description
// TolerancePct is how far short (or over) the arriving leg may be and still
// count as the same movement, as a percentage of the leg that left. It exists
// for routes where the bank takes a fee on the way, so the two statements
// genuinely disagree about the amount.
//
// Zero — the default, and what every definition written before this key
// existed means — requires the exact opposite amount. Keep it that way unless
// a route actually charges: the wider the tolerance, the more likely two
// unrelated movements in one window pair with each other.
//
// It applies within one currency only. Across currencies the amount is not
// checked at all, so there is nothing for a tolerance to loosen.
TolerancePct float64 `toml:"tolerance_pct"`
// Note is free text for the reader, exactly as on a Rule: it never takes
// part in matching.
Note string `toml:"note"`
}
// Rules is the parsed rules.toml: the tagging rules and the transfer
// definitions, which share the file because they are both hand-maintained
// statements about the same transactions.
type Rules struct {
Rule []Rule `toml:"rule"`
Transfer []Transfer `toml:"transfer"`
}
// LoadRules reads rules.toml from the data root. A missing file is not an
// error: it just means nothing is tagged automatically yet.
func LoadRules(root string) (*Rules, error) {
path := filepath.Join(root, RulesFile)
var r Rules
if _, err := toml.DecodeFile(path, &r); err != nil {
if os.IsNotExist(err) {
return &r, nil
}
return nil, fmt.Errorf("%s: %w", path, err)
}
for i, rule := range r.Rule {
if rule.Match == "" && rule.Type == "" {
return nil, fmt.Errorf("%s: rule %d has no match or type pattern", path, i+1)
}
if rule.Tag == "" {
return nil, fmt.Errorf("%s: rule %d (%q) sets no tag", path, i+1, rule.Match)
}
}
for i, t := range r.Transfer {
if err := checkTransfer(t); err != nil {
return nil, fmt.Errorf("%s: transfer %d: %w", path, i+1, err)
}
}
return &r, nil
}
// checkTransfer rejects a half-written definition. Both sides are needed to
// pair anything at all, so a missing one is refused on load rather than
// silently matching nothing.
func checkTransfer(t Transfer) error {
switch {
case t.FromAccount == "":
return fmt.Errorf("no from_account")
case t.FromDesc == "":
return fmt.Errorf("no from_desc pattern")
case t.ToAccount == "":
return fmt.Errorf("no to_account")
case t.ToDesc == "":
return fmt.Errorf("no to_desc pattern")
}
// A negative tolerance is a typo, and one at 100% or beyond would let any
// amount pair with any other — at which point the dates decide alone, which
// is the cross-currency rule and not something to arrive at by accident.
if t.TolerancePct < 0 || t.TolerancePct >= 100 {
return fmt.Errorf("tolerance_pct %g is not between 0 and 100", t.TolerancePct)
}
return nil
}
// AppendRule adds a rule to the end of rules.toml, creating the file if it is
// not there yet. Position no longer decides precedence — the most specific rule
// wins, see rules.Engine — but it still breaks ties, so appending rather than
// inserting keeps a saved rule from displacing an equally specific one the user
// wrote by hand.
//
// The file is rewritten through a temporary file so a failure part-way cannot
// leave the user with a truncated config.
func AppendRule(root string, r Rule) error {
if r.Match == "" && r.Type == "" {
return fmt.Errorf("a rule needs a match or type pattern")
}
if r.Tag == "" {
return fmt.Errorf("a rule needs a tag")
}
return appendBlock(root, formatRule(r))
}
// AppendTransfer adds a transfer definition to the end of rules.toml. Order
// matters for transfers as it does for rules: an earlier definition claims a
// transaction first, so appending cannot steal a leg from one already written.
func AppendTransfer(root string, t Transfer) error {
if err := checkTransfer(t); err != nil {
// A missing side reads better as what the form still wants; anything
// else already says what is wrong with what was typed.
if rest, ok := strings.CutPrefix(err.Error(), "no "); ok {
return fmt.Errorf("a transfer needs %s", rest)
}
return err
}
return appendBlock(root, formatTransfer(t))
}
// appendBlock adds a rendered TOML table to the end of rules.toml, creating the
// file if it is not there yet.
//
// The file is rewritten through a temporary file so a failure part-way cannot
// leave the user with a truncated config.
func appendBlock(root, block string) error {
path := filepath.Join(root, RulesFile)
existing, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read %s: %w", path, err)
}
var b strings.Builder
b.Write(existing)
if len(existing) > 0 && !strings.HasSuffix(string(existing), "\n") {
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(block)
return writeFileAtomic(root, path, b.String())
}
// DeleteRules removes the rules at the given positions (0-based, as loaded by
// LoadRules) from rules.toml.
func DeleteRules(root string, positions []int) (int, error) {
return deleteBlocks(root, "rule", positions)
}
// DeleteTransfers removes the transfer definitions at the given positions
// (0-based, as loaded by LoadRules) from rules.toml.
func DeleteTransfers(root string, positions []int) (int, error) {
return deleteBlocks(root, "transfer", positions)
}
// blockStart is where one array-of-tables entry begins in rules.toml.
type blockStart struct {
table string // "rule" or "transfer"
line int
}
// blockStarts finds every [[table]] header in the file. Every kind is
// collected, not just the one being deleted: a block ends where the *next*
// block of any kind begins, so deleting a rule that happens to sit above a
// transfer must not swallow it.
func blockStarts(lines []string) []blockStart {
var out []blockStart
for i, line := range lines {
s := strings.TrimSpace(line)
if strings.HasPrefix(s, "[[") && strings.HasSuffix(s, "]]") {
out = append(out, blockStart{table: strings.TrimSpace(s[2 : len(s)-2]), line: i})
}
}
return out
}
// deleteBlocks removes entries of one table from rules.toml.
//
// The file is edited textually rather than re-serialised from the parsed
// values, so comments, ordering and formatting the user put there by hand
// survive. A comment block sitting directly above a deleted entry goes with it,
// since it documents that entry; a comment separated by a blank line is treated
// as a section heading and left alone.
func deleteBlocks(root, table string, positions []int) (int, error) {
if len(positions) == 0 {
return 0, nil
}
doomed := map[int]bool{}
for _, p := range positions {
doomed[p] = true
}
path := filepath.Join(root, RulesFile)
raw, err := os.ReadFile(path)
if err != nil {
return 0, fmt.Errorf("read %s: %w", path, err)
}
lines := strings.Split(string(raw), "\n")
starts := blockStarts(lines)
// mine[p] is where the p-th entry of this table sits among all the blocks.
var mine []int
for k, s := range starts {
if s.table == table {
mine = append(mine, k)
}
}
for _, p := range positions {
if p < 0 || p >= len(mine) {
return 0, fmt.Errorf("%s %d is out of range; %s holds %d %ss",
table, p+1, path, len(mine), table)
}
}
// An entry owns the run of comment lines directly above it, with no blank
// line in between. Anything further up is a heading for what follows.
prefix := func(k int) int {
i := starts[k].line
for i > 0 && strings.HasPrefix(strings.TrimSpace(lines[i-1]), "#") {
i--
}
return i
}
drop := map[int]bool{}
for p := range doomed {
k := mine[p]
// The block runs up to the next block's comment prefix, so a comment
// introducing the following one is not swept up with this one.
end := len(lines)
if k+1 < len(starts) {
end = prefix(k + 1)
}
for i := prefix(k); i < end; i++ {
drop[i] = true
}
// Blank lines are the gap between blocks, not part of either; leaving
// them avoids gluing the neighbours together.
for i := end - 1; i >= starts[k].line && strings.TrimSpace(lines[i]) == ""; i-- {
delete(drop, i)
}
}
kept := make([]string, 0, len(lines))
for i, line := range lines {
if !drop[i] {
kept = append(kept, line)
}
}
out := collapseBlankRuns(kept)
// Never write something that will not load again, and never let deleting
// one kind of block take a different kind with it.
var check Rules
if _, err := toml.Decode(out, &check); err != nil {
return 0, fmt.Errorf("deleting from %s would produce invalid TOML: %w", path, err)
}
counts := map[string]int{"rule": len(check.Rule), "transfer": len(check.Transfer)}
for _, kind := range []string{"rule", "transfer"} {
want := 0
for _, s := range starts {
if s.table == kind {
want++
}
}
if kind == table {
want -= len(doomed)
}
if counts[kind] != want {
return 0, fmt.Errorf("deleting from %s would leave %d %ss, expected %d",
path, counts[kind], kind, want)
}
}
if err := writeFileAtomic(root, path, out); err != nil {
return 0, err
}
return len(doomed), nil
}
// collapseBlankRuns squeezes the runs of blank lines that deletion leaves
// behind down to one.
func collapseBlankRuns(lines []string) string {
out := make([]string, 0, len(lines))
blank := false
for _, line := range lines {
if strings.TrimSpace(line) == "" {
if blank {
continue
}
blank = true
} else {
blank = false
}
out = append(out, line)
}
// Drop leading blank lines outright.
for len(out) > 0 && strings.TrimSpace(out[0]) == "" {
out = out[1:]
}
text := strings.Join(out, "\n")
return strings.TrimRight(text, "\n") + "\n"
}
// writeFileAtomic replaces path via a temporary file in the same directory, so
// a failure part-way cannot truncate the user's config.
func writeFileAtomic(dir, path, content string) error {
tmp, err := os.CreateTemp(dir, ".rules-*.toml")
if err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
defer os.Remove(tmp.Name())
if _, err := tmp.WriteString(content); err != nil {
tmp.Close()
return fmt.Errorf("write %s: %w", path, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
return err
}
if err := os.Rename(tmp.Name(), path); err != nil {
return fmt.Errorf("replace %s: %w", path, err)
}
return nil
}
// writeKey renders one TOML key, skipping it when empty. The column is wide
// enough for the longest key either block uses, so the values line up.
func writeKey(b *strings.Builder, key, value string) {
if value != "" {
fmt.Fprintf(b, "%-12s = %s\n", key, strconv.Quote(value))
}
}
// formatTransfer renders a transfer as a TOML table, the two sides in the
// order money travels.
func formatTransfer(t Transfer) string {
var b strings.Builder
b.WriteString("[[transfer]]\n")
writeKey(&b, "from_account", t.FromAccount)
writeKey(&b, "from_desc", t.FromDesc)
writeKey(&b, "to_account", t.ToAccount)
writeKey(&b, "to_desc", t.ToDesc)
// Only when set: a zero written out would suggest the key is doing something
// when it is exactly the default every other definition already has.
if t.TolerancePct != 0 {
fmt.Fprintf(&b, "%-12s = %s\n", "tolerance_pct",
strconv.FormatFloat(t.TolerancePct, 'f', -1, 64))
}
writeKey(&b, "note", t.Note)
return b.String()
}
// formatRule renders a rule as a TOML table, omitting empty fields.
func formatRule(r Rule) string {
var b strings.Builder
b.WriteString("[[rule]]\n")
write := func(key, value string) { writeKey(&b, key, value) }
write("match", r.Match)
write("type", r.Type)
write("account", r.Account)
write("tag", r.Tag)
// Last, so the patterns and the tag stay lined up above it however long
// the note runs.
write("note", r.Note)
return b.String()
}
// Account is a parsed account.toml.
type Account struct {
Slug string // folder name, filled in by LoadAccounts
Dir string // absolute path to the account folder
Name string `toml:"name"`
Currency string `toml:"currency"`
MinorDigits *int `toml:"minor_digits"`
Parser string `toml:"parser"`
// Include restricts which files in the folder are treated as statements.
// Defaults to every regular file except account.toml and dotfiles.
Include []string `toml:"include"`
}
// Digits returns the configured minor-unit scale, defaulting to 2.
func (a *Account) Digits() int {
if a.MinorDigits != nil {
return *a.MinorDigits
}
return 2
}
// LoadAccounts finds every account folder under root. A folder is an account
// if it contains an account.toml.
func LoadAccounts(root string) ([]*Account, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, fmt.Errorf("read data root %s: %w", root, err)
}
var accounts []*Account
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
dir := filepath.Join(root, e.Name())
cfgPath := filepath.Join(dir, AccountFile)
if _, err := os.Stat(cfgPath); err != nil {
continue // not an account folder
}
a, err := loadAccount(dir, e.Name(), cfgPath)
if err != nil {
return nil, err
}
accounts = append(accounts, a)
}
sort.Slice(accounts, func(i, j int) bool { return accounts[i].Slug < accounts[j].Slug })
return accounts, nil
}
func loadAccount(dir, slug, cfgPath string) (*Account, error) {
var a Account
if _, err := toml.DecodeFile(cfgPath, &a); err != nil {
return nil, fmt.Errorf("%s: %w", cfgPath, err)
}
a.Slug = slug
a.Dir = dir
if a.Name == "" {
a.Name = slug
}
if a.Currency == "" {
return nil, fmt.Errorf("%s: currency is required", cfgPath)
}
if a.Parser == "" {
return nil, fmt.Errorf("%s: parser is required", cfgPath)
}
if a.Digits() < 0 || a.Digits() > 8 {
return nil, fmt.Errorf("%s: minor_digits must be between 0 and 8", cfgPath)
}
return &a, nil
}
// IndexPath returns the location of the SQLite index for a data root.
func IndexPath(root string) string {
return filepath.Join(root, IndexFile)
}
// UserConfig is the small file in the user's config directory that says where
// the data root lives, so the tool can be run from anywhere without flags.
type UserConfig struct {
Root string `toml:"root"`
}
// UserConfigPath returns the config file location, following the XDG base
// directory spec: $XDG_CONFIG_HOME/money/config.toml, falling back to
// ~/.config/money/config.toml.
func UserConfigPath() (string, error) {
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
return filepath.Join(dir, "money", "config.toml"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("cannot locate the home directory: %w", err)
}
return filepath.Join(home, ".config", "money", "config.toml"), nil
}
// LoadUserConfig reads the config file. A missing file is not an error: it
// just means nothing overrides the default data root.
func LoadUserConfig() (*UserConfig, string, error) {
path, err := UserConfigPath()
if err != nil {
return &UserConfig{}, "", err
}
var c UserConfig
if _, err := toml.DecodeFile(path, &c); err != nil {
if os.IsNotExist(err) {
return &UserConfig{}, path, nil
}
return nil, path, fmt.Errorf("%s: %w", path, err)
}
return &c, path, nil
}
// RootSource records where a resolved data root came from, so the tool can
// explain itself when the path is not what the user expected.
type RootSource string
// The ways a data root can be chosen, in order of precedence.
const (
RootFromFlag RootSource = "--root flag"
RootFromEnv RootSource = "MONEY_ROOT"
RootFromConfig RootSource = "config file"
RootFromDefault RootSource = "default"
)
// ResolveRoot decides which data root to use. An explicit flag wins, then
// $MONEY_ROOT, then the config file, then ~/money.
func ResolveRoot(flagRoot string) (root string, source RootSource, err error) {
switch {
case flagRoot != "":
root, source = flagRoot, RootFromFlag
default:
if env := os.Getenv("MONEY_ROOT"); env != "" {
root, source = env, RootFromEnv
} else {
c, _, err := LoadUserConfig()
if err != nil {
return "", "", err
}
if c.Root != "" {
root, source = c.Root, RootFromConfig
} else {
home, err := os.UserHomeDir()
if err != nil {
return "", "", fmt.Errorf("cannot locate the home directory: %w", err)
}
root, source = filepath.Join(home, "money"), RootFromDefault
}
}
}
if root, err = expandHome(root); err != nil {
return "", "", err
}
if root, err = filepath.Abs(root); err != nil {
return "", "", err
}
return root, source, nil
}
// expandHome resolves a leading ~, which a hand-written config file is likely
// to contain and which the shell does not expand for us.
func expandHome(path string) (string, error) {
if path != "~" && !strings.HasPrefix(path, "~/") {
return path, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("cannot expand %q: %w", path, err)
}
return filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(path, "~"), "/")), nil
}