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