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") + } +}