From cf1753fb0ab61ea95de365a0e8d97f48c90272c0 Mon Sep 17 00:00:00 2001 From: Nikola Petrov Date: Sun, 9 Aug 2026 00:40:59 +0200 Subject: [PATCH] Find the data root via the XDG config directory Running the tool from anywhere required either a --root flag or an exported MONEY_ROOT. Read the data root from $XDG_CONFIG_HOME/money/config.toml (~/.config/money/config.toml) instead, so it can be configured once. Resolution order is --root, then $MONEY_ROOT, then the config file, then ~/money. A leading tilde in the config file is expanded, since no shell has done it for us, and relative paths are made absolute. The new "money config" subcommand prints the resolved root and which rule chose it, which is the first thing to check when the tool reads the wrong directory. Co-Authored-By: Claude Opus 5 --- README.md | 23 ++++- cmd/money/main.go | 43 +++++++-- internal/config/config.go | 97 +++++++++++++++++++ internal/config/config_test.go | 164 +++++++++++++++++++++++++++++++++ 4 files changed, 316 insertions(+), 11 deletions(-) create mode 100644 internal/config/config_test.go diff --git a/README.md b/README.md index cb00f29..bac4fd4 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ transfers so they never count as spending. ## Layout ``` -~/money/ # the data root ($MONEY_ROOT, or --root) +~/money/ # the data root (see "Where the data root lives") rules.toml # tag + transfer rules, in order .money/index.db # SQLite index (rebuildable; safe to delete*) checking/ @@ -25,6 +25,26 @@ transfers so they never count as spending. \* Deleting the index loses manual tags and manual transfer marks, which live only there. Everything else is re-derived from the statements. +## Where the data root lives + +Checked in order, first one wins: + +1. `--root DIR` on the command line +2. `$MONEY_ROOT` +3. the `root` key in `$XDG_CONFIG_HOME/money/config.toml`, which is + `~/.config/money/config.toml` unless `XDG_CONFIG_HOME` says otherwise +4. `~/money` + +So the usual setup is to write the config file once and never pass a flag again: + +```toml +# ~/.config/money/config.toml +root = "~/documents/finances" +``` + +A leading `~` is expanded and relative paths are made absolute. `money config` +prints the resolved root and which of the four rules chose it. + ## Usage ``` @@ -38,6 +58,7 @@ money ls --account checking --month 2026-01 money report --month 2026-01 # spending by tag money accounts # balances money parsers # available statement parsers +money config # which data root is in use, and why ``` ### TUI keys diff --git a/cmd/money/main.go b/cmd/money/main.go index cf36aef..44b79d3 100644 --- a/cmd/money/main.go +++ b/cmd/money/main.go @@ -9,7 +9,6 @@ import ( "flag" "fmt" "os" - "path/filepath" "strings" "text/tabwriter" @@ -35,8 +34,10 @@ commands: report spending by tag accounts list accounts with balances parsers list available statement parsers + config show which data root is in use and why -The data root defaults to $MONEY_ROOT, or ~/money if that is unset. +The data root is taken from --root, else $MONEY_ROOT, else the "root" key in +$XDG_CONFIG_HOME/money/config.toml (~/.config/money/config.toml), else ~/money. ` func main() { @@ -47,7 +48,7 @@ func main() { } func run(args []string) error { - root := defaultRoot() + var root string // empty means "not given on the command line" // A leading --root/-root applies to every subcommand. for len(args) > 0 && strings.HasPrefix(args[0], "-") { flagName := strings.TrimLeft(args[0], "-") @@ -75,12 +76,14 @@ func run(args []string) error { cmd, args = args[0], args[1:] } - abs, err := filepath.Abs(root) + abs, source, err := config.ResolveRoot(root) if err != nil { return err } switch cmd { + case "config": + return cmdConfig(abs, source) case "import": return cmdImport(abs, args) case "retag": @@ -104,14 +107,34 @@ func run(args []string) error { } } -func defaultRoot() string { - if r := os.Getenv("MONEY_ROOT"); r != "" { - return r +// cmdConfig explains which data root is in use and where that came from, +// which is the first thing to check when the tool looks at the wrong place. +func cmdConfig(root string, source config.RootSource) error { + path, err := config.UserConfigPath() + if err != nil { + return err } - if home, err := os.UserHomeDir(); err == nil { - return filepath.Join(home, "money") + exists := "not present" + if _, err := os.Stat(path); err == nil { + exists = "present" } - return "." + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "data root\t%s\n", root) + fmt.Fprintf(w, "chosen by\t%s\n", source) + fmt.Fprintf(w, "config file\t%s (%s)\n", path, exists) + if _, err := os.Stat(root); err != nil { + fmt.Fprintf(w, "status\tdoes not exist yet\n") + } else { + fmt.Fprintf(w, "index\t%s\n", config.IndexPath(root)) + } + w.Flush() + + if source != config.RootFromConfig && exists == "not present" { + fmt.Printf("\nTo point money at a different directory, create %s with:\n\n root = %q\n", + path, root) + } + return nil } // open loads the config and index that every command needs. diff --git a/internal/config/config.go b/internal/config/config.go index e40d70f..82e5a5b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -177,3 +177,100 @@ func loadAccount(dir, slug, cfgPath string) (*Account, error) { 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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..b535c1f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,164 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// writeUserConfig points XDG_CONFIG_HOME at a temporary directory and puts a +// config file in it, so tests never touch the real one. +func writeUserConfig(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + if body == "" { + return dir + } + cfgDir := filepath.Join(dir, "money") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "config.toml"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestResolveRootPrecedence(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("no home directory in this environment") + } + + t.Run("flag beats everything", func(t *testing.T) { + writeUserConfig(t, "root = \"/from/config\"\n") + t.Setenv("MONEY_ROOT", "/from/env") + + root, source, err := ResolveRoot("/from/flag") + if err != nil { + t.Fatal(err) + } + if root != "/from/flag" || source != RootFromFlag { + t.Errorf("got %q from %q, want /from/flag from the flag", root, source) + } + }) + + t.Run("env beats config", func(t *testing.T) { + writeUserConfig(t, "root = \"/from/config\"\n") + t.Setenv("MONEY_ROOT", "/from/env") + + root, source, err := ResolveRoot("") + if err != nil { + t.Fatal(err) + } + if root != "/from/env" || source != RootFromEnv { + t.Errorf("got %q from %q, want /from/env from the environment", root, source) + } + }) + + t.Run("config beats the default", func(t *testing.T) { + writeUserConfig(t, "root = \"/from/config\"\n") + t.Setenv("MONEY_ROOT", "") + + root, source, err := ResolveRoot("") + if err != nil { + t.Fatal(err) + } + if root != "/from/config" || source != RootFromConfig { + t.Errorf("got %q from %q, want /from/config from the config file", root, source) + } + }) + + t.Run("default when nothing is set", func(t *testing.T) { + writeUserConfig(t, "") // XDG dir exists but holds no config file + t.Setenv("MONEY_ROOT", "") + + root, source, err := ResolveRoot("") + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, "money") + if root != want || source != RootFromDefault { + t.Errorf("got %q from %q, want %q from the default", root, source, want) + } + }) + + // A hand-written config file is likely to contain a tilde, which no shell + // has expanded for us. + t.Run("tilde in the config file is expanded", func(t *testing.T) { + writeUserConfig(t, "root = \"~/finances\"\n") + t.Setenv("MONEY_ROOT", "") + + root, _, err := ResolveRoot("") + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, "finances") + if root != want { + t.Errorf("root = %q, want %q", root, want) + } + }) + + t.Run("relative paths become absolute", func(t *testing.T) { + writeUserConfig(t, "") + t.Setenv("MONEY_ROOT", "some/relative/dir") + + root, _, err := ResolveRoot("") + if err != nil { + t.Fatal(err) + } + if !filepath.IsAbs(root) { + t.Errorf("root = %q, want an absolute path", root) + } + }) +} + +func TestUserConfigPathFollowsXDG(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/custom/config") + path, err := UserConfigPath() + if err != nil { + t.Fatal(err) + } + if want := "/custom/config/money/config.toml"; path != want { + t.Errorf("path = %q, want %q", path, want) + } + + // Unset, it falls back to ~/.config. + t.Setenv("XDG_CONFIG_HOME", "") + home, err := os.UserHomeDir() + if err != nil { + t.Skip("no home directory in this environment") + } + path, err = UserConfigPath() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(home, ".config", "money", "config.toml"); path != want { + t.Errorf("path = %q, want %q", path, want) + } +} + +// A missing config file is normal, not an error. +func TestLoadUserConfigMissingFile(t *testing.T) { + writeUserConfig(t, "") + c, path, err := LoadUserConfig() + if err != nil { + t.Fatalf("a missing config file should not be an error: %v", err) + } + if c.Root != "" { + t.Errorf("root = %q, want empty", c.Root) + } + if path == "" { + t.Error("expected the path to be reported even when the file is absent") + } +} + +// A malformed config file is an error, so a typo does not silently send the +// tool to the wrong directory. +func TestLoadUserConfigMalformed(t *testing.T) { + writeUserConfig(t, "root = this is not valid toml\n") + if _, _, err := LoadUserConfig(); err == nil { + t.Error("expected an error for a malformed config file") + } +}