Files
money/internal/config/config.go
T
nikolaandClaude Opus 5 c2eba53fd4 Add a rules screen that finds and removes dead rules
Nothing showed whether a rule was still earning its place. The new screen, on
5, lists every rule in file order with the number of transactions it claims,
marking those that claim none.

The count comes from Engine.Usage, which counts by first match, so a rule
shadowed by an earlier one reports zero even though its glob matches. That is
the case worth catching: such a rule looks correct in isolation and can never
fire.

d removes the selected rule and p removes every unused one, each behind a y/n
confirmation since this rewrites a hand-maintained file. config.DeleteRules
edits rules.toml textually rather than re-serialising the parsed rules, so
comments and layout survive; a comment directly above a rule goes with it,
while one separated by a blank line is left as a heading. The result is
re-parsed before it replaces the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 02:02:01 +02:00

470 lines
14 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"
// StateDir holds the rebuildable SQLite index.
StateDir = ".money"
// IndexFile is the SQLite index inside StateDir.
IndexFile = "index.db"
)
// Rule is one entry in rules.toml. Rules are evaluated in file order and the
// first one whose Match (and optional Account) matches wins.
type Rule struct {
Match string `toml:"match"`
Tag string `toml:"tag"`
Transfer bool `toml:"transfer"`
Account string `toml:"account"` // optional: restrict to one account slug
// Counterparty matches the other side's account number, which for
// movements between the user's own accounts is often the only reliable
// signal. Optional; when set, it must match as well as Match.
Counterparty string `toml:"counterparty"`
// 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"`
}
// Rules is the parsed rules.toml.
type Rules struct {
Rule []Rule `toml:"rule"`
}
// 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.Counterparty == "" && rule.Type == "" {
return nil, fmt.Errorf("%s: rule %d has no match, counterparty or type pattern", path, i+1)
}
if rule.Tag == "" && !rule.Transfer {
return nil, fmt.Errorf("%s: rule %d (%q) sets neither tag nor transfer", path, i+1, rule.Match)
}
}
return &r, nil
}
// AppendRule adds a rule to the end of rules.toml, creating the file if it is
// not there yet. Appending rather than inserting means an existing rule always
// keeps precedence, since the first match wins.
//
// 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.Counterparty == "" && r.Type == "" {
return fmt.Errorf("a rule needs a match, counterparty or type pattern")
}
if r.Tag == "" && !r.Transfer {
return fmt.Errorf("a rule needs a tag or transfer = true")
}
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(formatRule(r))
return writeFileAtomic(root, path, b.String())
}
// DeleteRules removes the rules at the given positions (0-based, as loaded by
// LoadRules) from rules.toml.
//
// The file is edited textually rather than re-serialised from the parsed
// rules, so comments, ordering and formatting the user put there by hand
// survive. A comment block sitting directly above a deleted rule goes with it,
// since it documents that rule; a comment separated by a blank line is treated
// as a section heading and left alone.
func DeleteRules(root 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")
// Where each [[rule]] block begins.
var starts []int
for i, line := range lines {
if strings.TrimSpace(line) == "[[rule]]" {
starts = append(starts, i)
}
}
for _, p := range positions {
if p < 0 || p >= len(starts) {
return 0, fmt.Errorf("rule %d is out of range; %s holds %d rules", p+1, path, len(starts))
}
}
// A rule 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]
for i > 0 && strings.HasPrefix(strings.TrimSpace(lines[i-1]), "#") {
i--
}
return i
}
drop := map[int]bool{}
for k := range starts {
if !doomed[k] {
continue
}
// The block runs up to the next rule's comment prefix, so a comment
// introducing the following rule 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 rules, not part of either; leaving
// them avoids gluing the neighbours together.
for i := end - 1; i >= starts[k] && 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.
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)
}
if want := len(starts) - len(doomed); len(check.Rule) != want {
return 0, fmt.Errorf("deleting from %s would leave %d rules, expected %d",
path, len(check.Rule), 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
}
// 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) {
if value != "" {
fmt.Fprintf(&b, "%-12s = %s\n", key, strconv.Quote(value))
}
}
write("match", r.Match)
write("counterparty", r.Counterparty)
write("type", r.Type)
write("account", r.Account)
write("tag", r.Tag)
if r.Transfer {
b.WriteString("transfer = true\n")
}
return b.String()
}
// Column locates one field in a CSV row.
type Column struct {
Col int `toml:"col"`
Layout string `toml:"layout"` // date only, Go reference layout
Decimal string `toml:"decimal"` // amount only, default "."
Thousands string `toml:"thousands"` // amount only, default ""
}
// CSVConfig describes how to read a delimited statement.
type CSVConfig struct {
Delimiter string `toml:"delimiter"`
SkipRows int `toml:"skip_rows"`
Encoding string `toml:"encoding"` // "" or "utf-8"; other encodings unsupported for now
Date Column `toml:"date"`
Description Column `toml:"description"`
Amount *Column `toml:"amount"` // single signed column...
Debit *Column `toml:"debit"` // ...or a debit/credit pair
Credit *Column `toml:"credit"`
// Invert flips the sign of the parsed amount, for statements that report
// outflows as positive numbers.
Invert bool `toml:"invert"`
}
// CmdConfig runs an external extractor (e.g. one of the existing Python
// scripts) and reads normalised CSV from its stdout.
type CmdConfig struct {
// Argv is the command to run. The literal token "{{file}}" is replaced
// with the absolute path of the statement being imported.
Argv []string `toml:"argv"`
// Layout is the date layout the script emits; defaults to 2006-01-02.
Layout string `toml:"layout"`
// SkipRows skips leading rows of the script's output (e.g. a header).
SkipRows int `toml:"skip_rows"`
}
// 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"`
CSV *CSVConfig `toml:"csv"`
Cmd *CmdConfig `toml:"cmd"`
}
// 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, StateDir, 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
}