// 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"` } // 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 == "" { return nil, fmt.Errorf("%s: rule %d (%q) sets no tag", 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 == "" { return fmt.Errorf("a rule needs a tag") } 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) // 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 }