A glob like *4412* says nothing about why it exists or who it catches, and six months later neither does memory. Rules get an optional note: free text that never takes part in matching, written as a TOML key rather than a comment so it survives a round trip and can be shown back. The rule builder grows a fourth field for it and the rules screen a last column. Four fields spaced out are taller than a short window has room for, so the form now drops its blank lines and then the hints on unfocused fields before anything would run off the bottom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
478 lines
14 KiB
Go
478 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"`
|
|
// 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"`
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
// Last, so the patterns and the tag stay lined up above it however long
|
|
// the note runs.
|
|
write("note", r.Note)
|
|
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
|
|
}
|