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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user