Writing a glob by hand meant guessing what it would catch, then running retag to find out. The new screen, on 4, puts the glob, account and tag fields on the left and every still-untagged description on the right, sorted alphabetically and grouped by description with an occurrence count. Matches are marked as the glob is typed, along with a count, so the effect of a rule is visible before it is written. Enter appends it to rules.toml via config.AppendRule, reloads the engine from disk and retags, so the rows it caught leave the list immediately. Rules are appended rather than prepended, keeping the precedence of anything already in the file. Since the preview only lists transactions no existing rule has tagged, it reflects that precedence for free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348 lines
11 KiB
Go
348 lines
11 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))
|
|
|
|
tmp, err := os.CreateTemp(root, ".rules-*.toml")
|
|
if err != nil {
|
|
return fmt.Errorf("write %s: %w", path, err)
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
|
|
if _, err := tmp.WriteString(b.String()); 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
|
|
}
|