Files
money/internal/config/config_test.go
T
nikolaandClaude Opus 5 83d842d92e Let rules carry a note
A glob like *4412* says nothing about why it exists or who it catches, and
six months later neither does memory. Rules get an optional note: free text
that never takes part in matching, written as a TOML key rather than a
comment so it survives a round trip and can be shown back.

The rule builder grows a fourth field for it and the rules screen a last
column. Four fields spaced out are taller than a short window has room for,
so the form now drops its blank lines and then the hints on unfocused fields
before anything would run off the bottom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 23:23:29 +02:00

462 lines
12 KiB
Go

package config
import (
"os"
"path/filepath"
"strings"
"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")
}
}
// A note is documentation carried with the rule. It is a key rather than a
// comment precisely so it survives the round trip.
func TestAppendRuleWithNote(t *testing.T) {
root := t.TempDir()
note := `the "corner shop" — 4412 is the branch code`
if err := AppendRule(root, Rule{Match: "*4412*", Tag: "groceries", Note: note}); err != nil {
t.Fatal(err)
}
loaded, err := LoadRules(root)
if err != nil {
t.Fatalf("the note broke the file: %v", err)
}
if loaded.Rule[0].Note != note {
t.Errorf("note = %q, want %q", loaded.Rule[0].Note, note)
}
// A note alone is not a rule; the usual validation still applies.
if err := AppendRule(root, Rule{Note: "just a thought"}); err == nil {
t.Error("expected a rule with only a note to be rejected")
}
}
// Deleting a rule takes its note with it and leaves everyone else's alone.
func TestDeleteRuleTakesItsNote(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, RulesFile)
original := `[[rule]]
match = "*LIDL*"
tag = "groceries"
note = "the weekly shop"
[[rule]]
match = "*ZARA*"
tag = "clothes"
note = "keep: for the January review"
`
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
if _, err := DeleteRules(root, []int{0}); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "weekly shop") {
t.Errorf("rules.toml = %q, want the deleted rule's note gone with it", raw)
}
loaded, err := LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Rule) != 1 || loaded.Rule[0].Note != "keep: for the January review" {
t.Errorf("rules = %+v, want the surviving rule to keep its note", loaded.Rule)
}
}
const rulesWithComments = `# Rules for my accounts.
# Order matters: the first match wins.
# Weekly shop.
[[rule]]
match = "*LIDL*"
tag = "groceries"
[[rule]]
match = "*PAYROLL*"
tag = "salary"
# Moving money to myself.
[[rule]]
match = "*TO SAVINGS*"
transfer = true
tag = "transfer"
`
// Deleting must edit the file textually: a user's comments and layout are not
// recoverable from the parsed rules.
func TestDeleteRulesKeepsCommentsAndOrder(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, RulesFile)
if err := os.WriteFile(path, []byte(rulesWithComments), 0o644); err != nil {
t.Fatal(err)
}
n, err := DeleteRules(root, []int{1}) // the payroll rule, which has no comment
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("deleted %d rules, want 1", n)
}
loaded, err := LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Rule) != 2 {
t.Fatalf("got %d rules, want 2: %+v", len(loaded.Rule), loaded.Rule)
}
if loaded.Rule[0].Match != "*LIDL*" || loaded.Rule[1].Match != "*TO SAVINGS*" {
t.Errorf("surviving rules = %+v, want the order preserved", loaded.Rule)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(body)
for _, want := range []string{
"# Rules for my accounts.",
"# Order matters: the first match wins.",
"# Weekly shop.",
"# Moving money to myself.",
} {
if !strings.Contains(text, want) {
t.Errorf("comment %q was lost:\n%s", want, text)
}
}
if strings.Contains(text, "PAYROLL") {
t.Errorf("the deleted rule is still there:\n%s", text)
}
}
// A comment directly above a rule documents it and goes with it; a heading
// separated by a blank line does not.
func TestDeleteRulesTakesAttachedComment(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(rulesWithComments), 0o644); err != nil {
t.Fatal(err)
}
if _, err := DeleteRules(root, []int{0}); err != nil { // the commented LIDL rule
t.Fatal(err)
}
body, _ := os.ReadFile(filepath.Join(root, RulesFile))
text := string(body)
if strings.Contains(text, "# Weekly shop.") {
t.Errorf("the rule's own comment should have gone with it:\n%s", text)
}
if !strings.Contains(text, "# Rules for my accounts.") {
t.Errorf("the file heading must survive:\n%s", text)
}
}
func TestDeleteRulesMultiple(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(rulesWithComments), 0o644); err != nil {
t.Fatal(err)
}
n, err := DeleteRules(root, []int{0, 2})
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("deleted %d, want 2", n)
}
loaded, err := LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Rule) != 1 || loaded.Rule[0].Match != "*PAYROLL*" {
t.Errorf("remaining = %+v, want only the payroll rule", loaded.Rule)
}
}
func TestDeleteRulesAll(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(rulesWithComments), 0o644); err != nil {
t.Fatal(err)
}
if _, err := DeleteRules(root, []int{0, 1, 2}); err != nil {
t.Fatal(err)
}
loaded, err := LoadRules(root)
if err != nil {
t.Fatalf("emptying the file must leave it loadable: %v", err)
}
if len(loaded.Rule) != 0 {
t.Errorf("got %+v, want no rules", loaded.Rule)
}
}
func TestDeleteRulesOutOfRange(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(rulesWithComments), 0o644); err != nil {
t.Fatal(err)
}
if _, err := DeleteRules(root, []int{7}); err == nil {
t.Error("expected an error for an out-of-range position")
}
loaded, _ := LoadRules(root)
if len(loaded.Rule) != 3 {
t.Error("a rejected delete must leave the file untouched")
}
}
// Deleting nothing is a no-op, not a rewrite.
func TestDeleteRulesEmptySelection(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, RulesFile)
if err := os.WriteFile(path, []byte(rulesWithComments), 0o644); err != nil {
t.Fatal(err)
}
if n, err := DeleteRules(root, nil); err != nil || n != 0 {
t.Fatalf("n=%d err=%v, want 0 and no error", n, err)
}
body, _ := os.ReadFile(path)
if string(body) != rulesWithComments {
t.Error("the file was rewritten despite deleting nothing")
}
}
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")
}
}