Files
money/internal/config/config_test.go
T
nikolaandClaude Opus 5 6477147988 Edit a rule from the rules screen
A rule could be written and deleted but never changed, so fixing a glob meant
deleting the rule and typing it again -- losing its note, the comments around
it, and its position, which still breaks ties between equally specific rules.
e now opens the selected rule in the builder and enter rewrites it where it
sits. config.ReplaceRule edits rules.toml textually, as deleting does, and
keeps the comments above the rule: they say why it is there, which editing its
glob rarely changes.

The round trip must not lose what the form does not show. The builder has four
fields and a Rule has five, so an edit carries the type pattern through
untouched and says so under the glob; a type-only rule saves without one. The
preview needed the same care in reverse: a working rule's transactions are
tagged, so an untagged-only preview would be empty for it. Its own rows are
added back, and the ones a narrowed glob stops catching stay on screen marked
-- giving one up is the decision being made, and it must not happen silently.

The builder and its list shared one return view, so opening the builder from
the list left esc pointing back into the form. Each screen now remembers its
own way out; transfers had the same trap and the same fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:40:55 +02:00

813 lines
24 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"}); 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)
}
}
// 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*"
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")
}
}
func TestAppendTransfer(t *testing.T) {
root := t.TempDir()
existing := "[[rule]]\nmatch = \"*LIDL*\"\ntag = \"groceries\"\n"
if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
tr := Transfer{
FromAccount: "nlb", FromDesc: "*TO REVOLUT*",
ToAccount: "revolut", ToDesc: "*FROM NLB*",
Note: `the monthly "top-up"`,
}
if err := AppendTransfer(root, tr); 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) != 1 {
t.Errorf("got %d rules, want the existing one untouched", len(loaded.Rule))
}
if len(loaded.Transfer) != 1 || loaded.Transfer[0] != tr {
t.Errorf("transfers = %+v, want %+v", loaded.Transfer, tr)
}
}
// Both sides are required: a one-sided definition can never pair anything, so
// it is refused rather than written and silently ignored.
func TestTransferNeedsBothSides(t *testing.T) {
full := Transfer{FromAccount: "a", FromDesc: "*OUT*", ToAccount: "b", ToDesc: "*IN*"}
cases := map[string]func(*Transfer){
"no from_account": func(t *Transfer) { t.FromAccount = "" },
"no from_desc": func(t *Transfer) { t.FromDesc = "" },
"no to_account": func(t *Transfer) { t.ToAccount = "" },
"no to_desc": func(t *Transfer) { t.ToDesc = "" },
}
for name, break_ := range cases {
root := t.TempDir()
tr := full
break_(&tr)
if err := AppendTransfer(root, tr); err == nil {
t.Errorf("%s: expected an error", name)
}
if _, err := os.Stat(filepath.Join(root, RulesFile)); !os.IsNotExist(err) {
t.Errorf("%s: a rejected transfer must not create the file", name)
}
}
// The same check applies to a file written by hand.
root := t.TempDir()
body := "[[transfer]]\nfrom_account = \"nlb\"\nfrom_desc = \"*OUT*\"\nto_account = \"revolut\"\n"
if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadRules(root); err == nil {
t.Error("expected a half-written transfer to be refused on load")
}
}
// Rules and transfers share a file, so deleting one kind must not take a
// neighbouring block of the other kind with it.
func TestDeleteLeavesTheOtherKindAlone(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, RulesFile)
original := `[[rule]]
match = "*LIDL*"
tag = "groceries"
# Moving money to the broker.
[[transfer]]
from_account = "nlb"
from_desc = "*TO TRADEREPUBLIC*"
to_account = "traderepublic"
to_desc = "*FROM NLB*"
[[rule]]
match = "*ZARA*"
tag = "clothes"
`
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
// Deleting the first rule must stop at the transfer that follows it.
if _, err := DeleteRules(root, []int{0}); err != nil {
t.Fatal(err)
}
loaded, err := LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Rule) != 1 || loaded.Rule[0].Tag != "clothes" {
t.Errorf("rules = %+v, want only the clothes rule left", loaded.Rule)
}
if len(loaded.Transfer) != 1 {
t.Fatalf("transfers = %+v, want the transfer untouched", loaded.Transfer)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "# Moving money to the broker.") {
t.Errorf("rules.toml = %q, want the transfer's own comment kept", raw)
}
// And deleting the transfer leaves the remaining rule alone.
if _, err := DeleteTransfers(root, []int{0}); err != nil {
t.Fatal(err)
}
loaded, err = LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Transfer) != 0 || len(loaded.Rule) != 1 {
t.Errorf("after deleting the transfer: %d rules, %d transfers; want 1 and 0",
len(loaded.Rule), len(loaded.Transfer))
}
if raw, err = os.ReadFile(path); err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "Moving money to the broker") {
t.Errorf("rules.toml = %q, want the deleted transfer's comment gone with it", raw)
}
}
func TestDeleteTransfersOutOfRange(t *testing.T) {
root := t.TempDir()
if err := AppendTransfer(root, Transfer{
FromAccount: "a", FromDesc: "*OUT*", ToAccount: "b", ToDesc: "*IN*",
}); err != nil {
t.Fatal(err)
}
if _, err := DeleteTransfers(root, []int{3}); err == nil {
t.Error("expected an out-of-range position to be refused")
}
}
// A tolerance survives the round trip through the file, and a definition
// without one keeps meaning what it always meant: exact amounts.
func TestAppendTransferWithTolerance(t *testing.T) {
root := t.TempDir()
tr := Transfer{
FromAccount: "nlb", FromDesc: "*TO REVOLUT*",
ToAccount: "revolut", ToDesc: "*FROM NLB*",
TolerancePct: 1.5,
Note: "NLB takes a wire fee",
}
if err := AppendTransfer(root, tr); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(root, RulesFile))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "tolerance_pct = 1.5") {
t.Errorf("file = %q, want the tolerance written unquoted", body)
}
loaded, err := LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Transfer) != 1 || loaded.Transfer[0] != tr {
t.Errorf("transfers = %+v, want %+v", loaded.Transfer, tr)
}
}
// The default is exact, so an unset tolerance must not be written out as a key
// suggesting the definition decided something.
func TestAppendTransferOmitsAZeroTolerance(t *testing.T) {
root := t.TempDir()
tr := Transfer{
FromAccount: "nlb", FromDesc: "*TO REVOLUT*",
ToAccount: "revolut", ToDesc: "*FROM NLB*",
}
if err := AppendTransfer(root, tr); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(root, RulesFile))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), "tolerance_pct") {
t.Errorf("file = %q, want no tolerance key", body)
}
}
// A negative tolerance is a typo, and one at 100% or beyond would let any
// amount pair with any other, leaving the dates to decide alone.
func TestTransferToleranceIsBounded(t *testing.T) {
full := Transfer{FromAccount: "a", FromDesc: "*OUT*", ToAccount: "b", ToDesc: "*IN*"}
for _, pct := range []float64{-1, 100, 250} {
root := t.TempDir()
tr := full
tr.TolerancePct = pct
if err := AppendTransfer(root, tr); err == nil {
t.Errorf("tolerance_pct %g: expected an error", pct)
}
if _, err := os.Stat(filepath.Join(root, RulesFile)); !os.IsNotExist(err) {
t.Errorf("tolerance_pct %g: a rejected transfer must not create the file", pct)
}
}
// The same check applies to a file written by hand.
root := t.TempDir()
body := "[[transfer]]\nfrom_account = \"a\"\nfrom_desc = \"*OUT*\"\n" +
"to_account = \"b\"\nto_desc = \"*IN*\"\ntolerance_pct = 150\n"
if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadRules(root); err == nil {
t.Error("expected an out-of-range tolerance to be refused on load")
}
}
// Editing rewrites the rule where it sits: the position is what still breaks
// ties between equally specific rules, so a rule that moved could start beating
// one it never used to.
func TestReplaceRuleKeepsPositionAndComments(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, RulesFile)
if err := os.WriteFile(path, []byte(rulesWithComments), 0o644); err != nil {
t.Fatal(err)
}
edited := Rule{Match: "*LIDL SOFIA*", Tag: "groceries", Account: "checking", Note: "just the branch"}
if err := ReplaceRule(root, 0, edited); err != nil {
t.Fatal(err)
}
loaded, err := LoadRules(root)
if err != nil {
t.Fatalf("the file no longer parses after editing: %v", err)
}
if len(loaded.Rule) != 3 {
t.Fatalf("got %d rules, want the same 3: %+v", len(loaded.Rule), loaded.Rule)
}
if loaded.Rule[0] != edited {
t.Errorf("rule 1 = %+v, want %+v", loaded.Rule[0], edited)
}
if loaded.Rule[1].Match != "*PAYROLL*" || loaded.Rule[2].Match != "*TO SAVINGS*" {
t.Errorf("the rest = %+v, want them where they were", loaded.Rule[1:])
}
// The comment above a rule says why it is there, which editing its glob
// does not change.
text, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"# Rules for my accounts.", "# Weekly shop.", "# Moving money to myself."} {
if !strings.Contains(string(text), want) {
t.Errorf("comment %q was lost:\n%s", want, text)
}
}
if strings.Contains(string(text), `"*LIDL*"`) {
t.Errorf("the old pattern is still there:\n%s", text)
}
}
// A rule and the transfer below it share the file, so a replacement has to end
// where the next block of any kind begins.
func TestReplaceRuleLeavesTheOtherKindAlone(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, RulesFile)
original := `[[rule]]
match = "*LIDL*"
tag = "groceries"
# Moving money to the broker.
[[transfer]]
from_account = "nlb"
from_desc = "*TO TRADEREPUBLIC*"
to_account = "traderepublic"
to_desc = "*FROM NLB*"
[[rule]]
match = "*ZARA*"
tag = "clothes"
`
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
if err := ReplaceRule(root, 0, Rule{Match: "*KAUFLAND*", Tag: "groceries"}); err != nil {
t.Fatal(err)
}
loaded, err := LoadRules(root)
if err != nil {
t.Fatal(err)
}
if len(loaded.Transfer) != 1 || loaded.Transfer[0].FromDesc != "*TO TRADEREPUBLIC*" {
t.Errorf("transfers = %+v, want the definition untouched", loaded.Transfer)
}
if len(loaded.Rule) != 2 || loaded.Rule[0].Match != "*KAUFLAND*" || loaded.Rule[1].Match != "*ZARA*" {
t.Errorf("rules = %+v, want only the first one rewritten", loaded.Rule)
}
raw, _ := os.ReadFile(path)
if !strings.Contains(string(raw), "# Moving money to the broker.") {
t.Errorf("the transfer's comment was lost:\n%s", raw)
}
}
// Editing a rule that sets a type keeps it: the caller decides what the rule
// becomes, and a pattern left out of the replacement is a pattern removed.
func TestReplaceRuleWritesEveryPattern(t *testing.T) {
root := t.TempDir()
body := "[[rule]]\nmatch = \"*SPOTIFY*\"\ntype = \"CARD_PAYMENT\"\ntag = \"music\"\n"
if err := os.WriteFile(filepath.Join(root, RulesFile), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
kept := Rule{Match: "*SPOTIFY*", Type: "CARD_PAYMENT", Tag: "subscriptions"}
if err := ReplaceRule(root, 0, kept); err != nil {
t.Fatal(err)
}
loaded, err := LoadRules(root)
if err != nil {
t.Fatal(err)
}
if loaded.Rule[0] != kept {
t.Errorf("rule = %+v, want %+v", loaded.Rule[0], kept)
}
// A type-only rule is a rule; a rule with neither pattern is not.
if err := ReplaceRule(root, 0, Rule{Type: "CARD_PAYMENT", Tag: "cards"}); err != nil {
t.Errorf("a type-only rule was refused: %v", err)
}
if err := ReplaceRule(root, 0, Rule{Tag: "nothing"}); err == nil {
t.Error("expected a rule with no pattern to be rejected")
}
if err := ReplaceRule(root, 0, Rule{Match: "*X*"}); err == nil {
t.Error("expected a rule with no tag to be rejected")
}
}
func TestReplaceRuleOutOfRange(t *testing.T) {
root := t.TempDir()
if err := AppendRule(root, Rule{Match: "*LIDL*", Tag: "groceries"}); err != nil {
t.Fatal(err)
}
for _, pos := range []int{-1, 1, 7} {
if err := ReplaceRule(root, pos, Rule{Match: "*X*", Tag: "x"}); err == nil {
t.Errorf("position %d: expected an error", pos)
}
}
loaded, _ := LoadRules(root)
if len(loaded.Rule) != 1 || loaded.Rule[0].Match != "*LIDL*" {
t.Errorf("rules = %+v, want the file untouched", loaded.Rule)
}
}