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 TestAppendRule(t *testing.T) { root := t.TempDir() existing := "[[rule]]\ntag = \"salary\"\nmatch = \"*PAYROLL*\"\n" if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(existing), 0o644); err != nil { t.Fatal(err) } if err := AppendRule(root, Rule{Match: "*LIDL*", Tag: "groceries", Account: "checking"}); err != nil { t.Fatal(err) } loaded, err := LoadRules(root) if err != nil { t.Fatalf("the file no longer parses after appending: %v", err) } if len(loaded.Rule) != 2 { t.Fatalf("got %d rules, want 2: %+v", len(loaded.Rule), loaded.Rule) } // Appending, not prepending: the existing rule keeps precedence. if loaded.Rule[0].Tag != "salary" { t.Errorf("first rule = %+v, want the pre-existing salary rule", loaded.Rule[0]) } added := loaded.Rule[1] if added.Match != "*LIDL*" || added.Tag != "groceries" || added.Account != "checking" { t.Errorf("appended rule = %+v", added) } } func TestAppendRuleCreatesFile(t *testing.T) { root := t.TempDir() if err := AppendRule(root, Rule{Match: "*LIDL*", Tag: "groceries"}); err != nil { t.Fatal(err) } loaded, err := LoadRules(root) if err != nil { t.Fatal(err) } if len(loaded.Rule) != 1 || loaded.Rule[0].Match != "*LIDL*" { t.Errorf("rules = %+v, want the one appended rule", loaded.Rule) } // An omitted account must not be written as an empty string. if loaded.Rule[0].Account != "" { t.Errorf("account = %q, want empty", loaded.Rule[0].Account) } } func TestAppendRuleRejectsIncomplete(t *testing.T) { root := t.TempDir() cases := map[string]Rule{ "no pattern": {Tag: "groceries"}, "no verdict": {Match: "*LIDL*"}, } for name, r := range cases { if err := AppendRule(root, r); err == nil { t.Errorf("%s: expected an error", name) } } if _, err := os.Stat(filepath.Join(root, RulesFile)); !os.IsNotExist(err) { t.Error("a rejected rule must not create the file") } } // Globs and tags containing quotes must survive the round trip. func TestAppendRuleQuotesValues(t *testing.T) { root := t.TempDir() if err := AppendRule(root, Rule{Match: `*"ODD" PAYEE*`, Tag: "misc", Transfer: true}); err != nil { t.Fatal(err) } loaded, err := LoadRules(root) if err != nil { t.Fatalf("quoted values broke the file: %v", err) } if loaded.Rule[0].Match != `*"ODD" PAYEE*` { t.Errorf("match = %q, want the quotes preserved", loaded.Rule[0].Match) } if !loaded.Rule[0].Transfer { t.Error("transfer flag was not written") } } 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") } }