counterparty was a structured field only nlb could fill honestly. revolut and traderepublic invented one by running an IBAN-shaped regex over the description they had just built, and the two spellings disagreed -- SI56 1234 5678 9012 345 against SI56123456789012345 -- so a literal rule pattern that worked on one account silently matched nothing on another. It is gone from the model, the index, the rule keys, ls --wide and the rules screen. nlb now appends its IBAN column to the end of the description, where the other two already keep theirs, so match = "*SI56*" works everywhere. That changes those descriptions and with them their fingerprints, so a statement overlapping an already-imported period will re-add rather than dedupe those rows until the index is rebuilt. An index built by an older binary drops the column when it is opened. The index itself moves from .money/index.db up to index.db beside rules.toml. Nothing looks in the old location, so an existing one has to be moved by hand -- otherwise the tool quietly starts a fresh index and the manual tags in the old file, the only thing statements cannot reproduce, stay behind in it. The csv and cmd parsers are gone along with the [csv] and [cmd] config they carried. cmd shelled out to the Python extractors, which were ported to Go and deleted, so it bridged to nothing; csv was a generic column-mapped fallback that no account used, and between them they were the largest configuration surface in the tool. A bank is now described in Go, where it can be tested. The importer tests register their own three-column parser rather than borrow a bank's, so they stay about the directory walk, dedupe and per-file error reporting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
435 lines
13 KiB
Go
435 lines
13 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. 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
|
|
// 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.Type == "" {
|
|
return nil, fmt.Errorf("%s: rule %d has no match 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.Type == "" {
|
|
return fmt.Errorf("a rule needs a match 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("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()
|
|
}
|
|
|
|
// 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
|
|
}
|