Skip to content

Commit 3a45ad9

Browse files
authored
Merge pull request #7 from thedavidweng/feat/cross-platform-dirs
2 parents 3c6ce00 + 31fe3e0 commit 3a45ad9

5 files changed

Lines changed: 241 additions & 15 deletions

File tree

internal/config/config.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"gopkg.in/yaml.v3"
1212

1313
"github.com/thedavidweng/money/internal/contracts"
14+
"github.com/thedavidweng/money/internal/paths"
1415
)
1516

1617
type Options struct {
@@ -110,14 +111,14 @@ func DefaultConfigPath(profile string) string {
110111
if err := validateProfile(profile); err != nil {
111112
return ""
112113
}
113-
home, err := os.UserHomeDir()
114-
if err != nil {
114+
base := paths.DataDir()
115+
if base == "" {
115116
return ""
116117
}
117118
if profile != "" && profile != "default" {
118-
return filepath.Join(home, ".money", "profiles", profile, "config.yaml")
119+
return filepath.Join(base, "profiles", profile, "config.yaml")
119120
}
120-
return filepath.Join(home, ".money", "config.yaml")
121+
return filepath.Join(base, "config.yaml")
121122
}
122123

123124
func Load(options Options) (Config, error) {

internal/config/config_test.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"os"
66
"path/filepath"
77
"testing"
8+
9+
"github.com/thedavidweng/money/internal/paths"
810
)
911

1012
func TestLoadResolvesExplicitEnvReferencesFromCompanionEnv(t *testing.T) {
@@ -73,11 +75,11 @@ func TestLoadDoesNotReadCwdEnvWithoutExplicitConfigReference(t *testing.T) {
7375
}
7476

7577
func TestDefaultConfigPathReturnsDefaultForEmptyAndDefaultProfile(t *testing.T) {
76-
home, err := os.UserHomeDir()
77-
if err != nil {
78-
t.Skip("cannot determine home dir:", err)
78+
base := paths.DataDir()
79+
if base == "" {
80+
t.Skip("cannot determine data dir")
7981
}
80-
defaultPath := filepath.Join(home, ".money", "config.yaml")
82+
defaultPath := filepath.Join(base, "config.yaml")
8183
if p := DefaultConfigPath(""); p != defaultPath {
8284
t.Fatalf("DefaultConfigPath(\"\") = %q, want %q", p, defaultPath)
8385
}
@@ -87,11 +89,11 @@ func TestDefaultConfigPathReturnsDefaultForEmptyAndDefaultProfile(t *testing.T)
8789
}
8890

8991
func TestDefaultConfigPathReturnsProfilePathForCustomProfile(t *testing.T) {
90-
home, err := os.UserHomeDir()
91-
if err != nil {
92-
t.Skip("cannot determine home dir:", err)
92+
base := paths.DataDir()
93+
if base == "" {
94+
t.Skip("cannot determine data dir")
9395
}
94-
want := filepath.Join(home, ".money", "profiles", "work", "config.yaml")
96+
want := filepath.Join(base, "profiles", "work", "config.yaml")
9597
if p := DefaultConfigPath("work"); p != want {
9698
t.Fatalf("DefaultConfigPath(\"work\") = %q, want %q", p, want)
9799
}

internal/paths/paths.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Package paths resolves the data directory for the money CLI.
2+
//
3+
// The canonical data directory is determined in this order:
4+
// 1. MONEY_HOME environment variable (explicit override)
5+
// 2. Legacy ~/.money directory (backward compatibility if it exists)
6+
// 3. Platform-appropriate default derived from os.UserHomeDir()
7+
//
8+
// On Linux this respects XDG_STATE_HOME (default ~/.local/state/money).
9+
// On macOS it uses ~/Library/Application Support/money.
10+
// On Windows it uses %LOCALAPPDATA%\money.
11+
package paths
12+
13+
import (
14+
"os"
15+
"path/filepath"
16+
"runtime"
17+
)
18+
19+
const appName = "money"
20+
21+
// DataDir returns the root directory for all money data files
22+
// (config.yaml, .env, data/money.db, plaid-dashboard-auth.json, profiles/).
23+
func DataDir() string {
24+
if override := os.Getenv("MONEY_HOME"); override != "" {
25+
return override
26+
}
27+
28+
home, _ := os.UserHomeDir()
29+
30+
// Prefer legacy ~/.money if it exists and the new platform path does not.
31+
if home != "" {
32+
legacy := filepath.Join(home, ".money")
33+
if _, err := os.Stat(legacy); err == nil {
34+
newDefault := platformDefault(home)
35+
if _, err := os.Stat(newDefault); err != nil {
36+
return legacy
37+
}
38+
}
39+
}
40+
41+
return platformDefault(home)
42+
}
43+
44+
// platformDefault returns the XDG/platform-appropriate data directory.
45+
// Returns "" if home is empty and no platform-specific env var is set,
46+
// since a CWD-relative path would silently scatter data across the filesystem.
47+
func platformDefault(home string) string {
48+
switch runtime.GOOS {
49+
case "darwin":
50+
if home == "" {
51+
return ""
52+
}
53+
return filepath.Join(home, "Library", "Application Support", appName)
54+
case "windows":
55+
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
56+
return filepath.Join(localAppData, appName)
57+
}
58+
if home == "" {
59+
return ""
60+
}
61+
return filepath.Join(home, "AppData", "Local", appName)
62+
default: // linux, freebsd, etc.
63+
if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
64+
return filepath.Join(xdg, appName)
65+
}
66+
if home == "" {
67+
return ""
68+
}
69+
return filepath.Join(home, ".local", "state", appName)
70+
}
71+
}

internal/paths/paths_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package paths
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"testing"
8+
)
9+
10+
func TestDataDirHonorsMoneyHomeOverride(t *testing.T) {
11+
t.Setenv("MONEY_HOME", "/custom/money/path")
12+
got := DataDir()
13+
if got != "/custom/money/path" {
14+
t.Fatalf("DataDir() = %q, want /custom/money/path", got)
15+
}
16+
}
17+
18+
func TestDataDirUsesNewPlatformPathWhenLegacyMissing(t *testing.T) {
19+
// Clean environment
20+
t.Setenv("MONEY_HOME", "")
21+
home := t.TempDir()
22+
t.Setenv("HOME", home)
23+
t.Setenv("USERPROFILE", home)
24+
25+
// No ~/.money exists → use new platform default
26+
got := DataDir()
27+
want := platformDefault(home)
28+
if got != want {
29+
t.Fatalf("DataDir() = %q, want %q", got, want)
30+
}
31+
}
32+
33+
func TestDataDirFallsBackToLegacyWhenExists(t *testing.T) {
34+
home := t.TempDir()
35+
t.Setenv("HOME", home)
36+
t.Setenv("USERPROFILE", home)
37+
t.Setenv("MONEY_HOME", "")
38+
39+
legacyDir := filepath.Join(home, ".money")
40+
if err := os.MkdirAll(legacyDir, 0o700); err != nil {
41+
t.Fatal(err)
42+
}
43+
44+
got := DataDir()
45+
if got != legacyDir {
46+
t.Fatalf("DataDir() = %q, want legacy %q", got, legacyDir)
47+
}
48+
}
49+
50+
func TestDataDirPrefersNewPathWhenBothExist(t *testing.T) {
51+
home := t.TempDir()
52+
t.Setenv("HOME", home)
53+
t.Setenv("USERPROFILE", home)
54+
t.Setenv("MONEY_HOME", "")
55+
56+
legacyDir := filepath.Join(home, ".money")
57+
newDir := platformDefault(home)
58+
for _, d := range []string{legacyDir, newDir} {
59+
if err := os.MkdirAll(d, 0o700); err != nil {
60+
t.Fatal(err)
61+
}
62+
}
63+
64+
got := DataDir()
65+
if got != newDir {
66+
t.Fatalf("DataDir() = %q, want new path %q (both exist, prefer new)", got, newDir)
67+
}
68+
}
69+
70+
func TestDataDirDefaultLinux(t *testing.T) {
71+
if runtime.GOOS != "linux" {
72+
t.Skip("linux-only")
73+
}
74+
home := t.TempDir()
75+
t.Setenv("HOME", home)
76+
t.Setenv("MONEY_HOME", "")
77+
t.Setenv("XDG_STATE_HOME", "")
78+
79+
got := DataDir()
80+
want := filepath.Join(home, ".local", "state", "money")
81+
if got != want {
82+
t.Fatalf("DataDir() = %q, want %q", got, want)
83+
}
84+
}
85+
86+
func TestDataDirLinuxXDGStateHome(t *testing.T) {
87+
if runtime.GOOS != "linux" {
88+
t.Skip("linux-only")
89+
}
90+
xdgDir := t.TempDir()
91+
t.Setenv("XDG_STATE_HOME", xdgDir)
92+
t.Setenv("HOME", t.TempDir())
93+
t.Setenv("MONEY_HOME", "")
94+
95+
got := DataDir()
96+
want := filepath.Join(xdgDir, "money")
97+
if got != want {
98+
t.Fatalf("DataDir() = %q, want %q", got, want)
99+
}
100+
}
101+
102+
func TestDataDirDefaultWindows(t *testing.T) {
103+
if runtime.GOOS != "windows" {
104+
t.Skip("windows-only")
105+
}
106+
localAppData := t.TempDir()
107+
t.Setenv("LOCALAPPDATA", localAppData)
108+
t.Setenv("USERPROFILE", t.TempDir())
109+
t.Setenv("MONEY_HOME", "")
110+
111+
got := DataDir()
112+
want := filepath.Join(localAppData, "money")
113+
if got != want {
114+
t.Fatalf("DataDir() = %q, want %q", got, want)
115+
}
116+
}
117+
118+
func TestDataDirDefaultDarwin(t *testing.T) {
119+
if runtime.GOOS != "darwin" {
120+
t.Skip("darwin-only")
121+
}
122+
home := t.TempDir()
123+
t.Setenv("HOME", home)
124+
t.Setenv("MONEY_HOME", "")
125+
126+
got := DataDir()
127+
want := filepath.Join(home, "Library", "Application Support", "money")
128+
if got != want {
129+
t.Fatalf("DataDir() = %q, want %q", got, want)
130+
}
131+
}
132+
133+
func TestDataDirReturnsEmptyWhenHomeUnavailable(t *testing.T) {
134+
t.Setenv("MONEY_HOME", "")
135+
t.Setenv("HOME", "")
136+
t.Setenv("USERPROFILE", "")
137+
if runtime.GOOS == "linux" {
138+
t.Setenv("XDG_STATE_HOME", "")
139+
}
140+
if runtime.GOOS == "windows" {
141+
t.Setenv("LOCALAPPDATA", "")
142+
}
143+
144+
got := DataDir()
145+
if got != "" {
146+
t.Fatalf("DataDir() = %q, want empty string when home dir is unavailable", got)
147+
}
148+
}
149+

tests/e2e/binary_test.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,13 @@ func findProjectRoot() (string, error) {
5353
if err != nil {
5454
return "", err
5555
}
56-
for dir := pwd; dir != "/" && dir != "."; dir = filepath.Dir(dir) {
56+
for dir := pwd; dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
5757
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
5858
return dir, nil
5959
}
6060
}
61-
return filepath.Join(os.Getenv("HOME"), "Development", "money"), nil
61+
home, _ := os.UserHomeDir()
62+
return filepath.Join(home, "Development", "money"), nil
6263
}
6364

6465
// ─── Execution helper ───
@@ -86,8 +87,10 @@ func recordCommand(args []string) {
8687
func run(t *testing.T, bin string, args ...string) (stdout string, exitCode int) {
8788
t.Helper()
8889
cmd := exec.Command(bin, args...)
90+
home := t.TempDir()
8991
cmd.Env = []string{
90-
"HOME=" + t.TempDir(),
92+
"HOME=" + home,
93+
"USERPROFILE=" + home, // Windows uses USERPROFILE for home directory
9194
"PATH=/usr/bin:/bin:/usr/local/bin",
9295
"TERM=dumb",
9396
"NO_COLOR=1",

0 commit comments

Comments
 (0)