Files
money/internal/config/config.go
T
nikolaandClaude Opus 5 6477147988 Edit a rule from the rules screen
A rule could be written and deleted but never changed, so fixing a glob meant
deleting the rule and typing it again -- losing its note, the comments around
it, and its position, which still breaks ties between equally specific rules.
e now opens the selected rule in the builder and enter rewrites it where it
sits. config.ReplaceRule edits rules.toml textually, as deleting does, and
keeps the comments above the rule: they say why it is there, which editing its
glob rarely changes.

The round trip must not lose what the form does not show. The builder has four
fields and a Rule has five, so an edit carries the type pattern through
untouched and says so under the glob; a type-only rule saves without one. The
preview needed the same care in reverse: a working rule's transactions are
tagged, so an untagged-only preview would be empty for it. Its own rows are
added back, and the ones a narrowed glob stops catching stay on screen marked
-- giving one up is the decision being made, and it must not happen silently.

The builder and its list shared one return view, so opening the builder from
the list left esc pointing back into the form. Each screen now remembers its
own way out; transfers had the same trap and the same fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:40:55 +02:00

661 lines
22 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 err := checkRule(r); err != nil {
return err
}
return appendBlock(root, formatRule(r))
}
// checkRule rejects a rule that would match nothing or decide nothing.
func checkRule(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 nil
}
// ReplaceRule rewrites the rule at the given position (0-based, as loaded by
// LoadRules) with r, leaving it where it sits. Position no longer decides
// precedence, but it still breaks ties, so an edited rule that moved could
// start losing to — or start beating — an equally specific one it never used to.
//
// Like DeleteRules this edits the file textually rather than re-serialising the
// parsed rules, because comments and formatting are not recoverable from
// []Rule. Only the rule's own lines are replaced: the comments directly above
// it say why it is there, which editing its glob rarely changes, so they stay.
func ReplaceRule(root string, pos int, r Rule) error {
if err := checkRule(r); err != nil {
return err
}
path := filepath.Join(root, RulesFile)
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
lines := strings.Split(string(raw), "\n")
starts := blockStarts(lines)
rules, transfers := blocksOf(starts, "rule"), blocksOf(starts, "transfer")
if pos < 0 || pos >= len(rules) {
return fmt.Errorf("rule %d is out of range; %s holds %d rules", pos+1, path, len(rules))
}
from, to := blockExtent(lines, starts, rules[pos])
kept := make([]string, 0, len(lines))
kept = append(kept, lines[:from]...)
kept = append(kept, strings.Split(strings.TrimRight(formatRule(r), "\n"), "\n")...)
kept = append(kept, lines[to:]...)
out := strings.Join(kept, "\n")
// Never write something that will not load again, and never let editing one
// rule disturb a neighbour — of either kind, since both live in this file.
var check Rules
if _, err := toml.Decode(out, &check); err != nil {
return fmt.Errorf("editing %s would produce invalid TOML: %w", path, err)
}
if len(check.Rule) != len(rules) || len(check.Transfer) != len(transfers) {
return fmt.Errorf("editing %s would leave %d rules and %d transfers, expected %d and %d",
path, len(check.Rule), len(check.Transfer), len(rules), len(transfers))
}
if check.Rule[pos] != r {
return fmt.Errorf("editing %s would leave rule %d as %+v, expected %+v",
path, pos+1, check.Rule[pos], r)
}
return writeFileAtomic(root, path, out)
}
// 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
}
// blocksOf lists where the entries of one table sit among all the blocks, so a
// position in rules.toml as LoadRules numbers it can be turned into a position
// in the file.
func blocksOf(starts []blockStart, table string) []int {
var out []int
for k, s := range starts {
if s.table == table {
out = append(out, k)
}
}
return out
}
// commentPrefix walks back from a block's header over the comment lines it
// owns. An entry owns the run of comments directly above it, with no blank line
// in between; anything further up is a heading for what follows.
func commentPrefix(lines []string, header int) int {
i := header
for i > 0 && strings.HasPrefix(strings.TrimSpace(lines[i-1]), "#") {
i--
}
return i
}
// blockExtent returns the k-th block's own lines as a half-open range: from its
// header to the last line it owns, leaving out the comments introducing the
// next block and the blank lines between the two, which belong to neither.
func blockExtent(lines []string, starts []blockStart, k int) (from, to int) {
from = starts[k].line
to = len(lines)
if k+1 < len(starts) {
to = commentPrefix(lines, starts[k+1].line)
}
for to > from+1 && strings.TrimSpace(lines[to-1]) == "" {
to--
}
return from, to
}
// 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.
mine := blocksOf(starts, table)
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)
}
}
drop := map[int]bool{}
for p := range doomed {
k := mine[p]
// A rule's own comments document it and go with it; blockExtent leaves
// out the blank lines below it, which are the gap between blocks and
// part of neither, so the neighbours are not glued together.
_, end := blockExtent(lines, starts, k)
for i := commentPrefix(lines, starts[k].line); i < end; i++ {
drop[i] = true
}
}
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
}