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>
462 lines
12 KiB
Go
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")
|
|
}
|
|
}
|