// 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" "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 } // 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 }