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) } }