// 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"` 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. 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 == "" { return fmt.Errorf("a rule needs a tag") } return appendBlock(root, formatRule(r)) } // 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 } // 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. var mine []int for k, s := range starts { if s.table == table { mine = append(mine, k) } } 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) } } // An entry 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].line for i > 0 && strings.HasPrefix(strings.TrimSpace(lines[i-1]), "#") { i-- } return i } drop := map[int]bool{} for p := range doomed { k := mine[p] // The block runs up to the next block's comment prefix, so a comment // introducing the following one 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 blocks, not part of either; leaving // them avoids gluing the neighbours together. for i := end - 1; i >= starts[k].line && 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, 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 }