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>
This commit is contained in:
2026-08-17 19:40:55 +02:00
co-authored by Claude Opus 5
parent 442684be60
commit 6477147988
6 changed files with 739 additions and 105 deletions
+105 -29
View File
@@ -135,14 +135,73 @@ func checkTransfer(t Transfer) error {
// The file is rewritten through a temporary file so a failure part-way cannot
// leave the user with a truncated config.
func AppendRule(root string, r Rule) error {
if err := checkRule(r); err != nil {
return err
}
return appendBlock(root, formatRule(r))
}
// checkRule rejects a rule that would match nothing or decide nothing.
func checkRule(r Rule) error {
if r.Match == "" && r.Type == "" {
return fmt.Errorf("a rule needs a match or type pattern")
}
if r.Tag == "" {
return fmt.Errorf("a rule needs a tag")
}
return nil
}
return appendBlock(root, formatRule(r))
// ReplaceRule rewrites the rule at the given position (0-based, as loaded by
// LoadRules) with r, leaving it where it sits. Position no longer decides
// precedence, but it still breaks ties, so an edited rule that moved could
// start losing to — or start beating — an equally specific one it never used to.
//
// Like DeleteRules this edits the file textually rather than re-serialising the
// parsed rules, because comments and formatting are not recoverable from
// []Rule. Only the rule's own lines are replaced: the comments directly above
// it say why it is there, which editing its glob rarely changes, so they stay.
func ReplaceRule(root string, pos int, r Rule) error {
if err := checkRule(r); err != nil {
return err
}
path := filepath.Join(root, RulesFile)
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
lines := strings.Split(string(raw), "\n")
starts := blockStarts(lines)
rules, transfers := blocksOf(starts, "rule"), blocksOf(starts, "transfer")
if pos < 0 || pos >= len(rules) {
return fmt.Errorf("rule %d is out of range; %s holds %d rules", pos+1, path, len(rules))
}
from, to := blockExtent(lines, starts, rules[pos])
kept := make([]string, 0, len(lines))
kept = append(kept, lines[:from]...)
kept = append(kept, strings.Split(strings.TrimRight(formatRule(r), "\n"), "\n")...)
kept = append(kept, lines[to:]...)
out := strings.Join(kept, "\n")
// Never write something that will not load again, and never let editing one
// rule disturb a neighbour — of either kind, since both live in this file.
var check Rules
if _, err := toml.Decode(out, &check); err != nil {
return fmt.Errorf("editing %s would produce invalid TOML: %w", path, err)
}
if len(check.Rule) != len(rules) || len(check.Transfer) != len(transfers) {
return fmt.Errorf("editing %s would leave %d rules and %d transfers, expected %d and %d",
path, len(check.Rule), len(check.Transfer), len(rules), len(transfers))
}
if check.Rule[pos] != r {
return fmt.Errorf("editing %s would leave rule %d as %+v, expected %+v",
path, pos+1, check.Rule[pos], r)
}
return writeFileAtomic(root, path, out)
}
// AppendTransfer adds a transfer definition to the end of rules.toml. Order
@@ -216,6 +275,45 @@ func blockStarts(lines []string) []blockStart {
return out
}
// blocksOf lists where the entries of one table sit among all the blocks, so a
// position in rules.toml as LoadRules numbers it can be turned into a position
// in the file.
func blocksOf(starts []blockStart, table string) []int {
var out []int
for k, s := range starts {
if s.table == table {
out = append(out, k)
}
}
return out
}
// commentPrefix walks back from a block's header over the comment lines it
// owns. An entry owns the run of comments directly above it, with no blank line
// in between; anything further up is a heading for what follows.
func commentPrefix(lines []string, header int) int {
i := header
for i > 0 && strings.HasPrefix(strings.TrimSpace(lines[i-1]), "#") {
i--
}
return i
}
// blockExtent returns the k-th block's own lines as a half-open range: from its
// header to the last line it owns, leaving out the comments introducing the
// next block and the blank lines between the two, which belong to neither.
func blockExtent(lines []string, starts []blockStart, k int) (from, to int) {
from = starts[k].line
to = len(lines)
if k+1 < len(starts) {
to = commentPrefix(lines, starts[k+1].line)
}
for to > from+1 && strings.TrimSpace(lines[to-1]) == "" {
to--
}
return from, to
}
// deleteBlocks removes entries of one table from rules.toml.
//
// The file is edited textually rather than re-serialised from the parsed
@@ -241,12 +339,7 @@ func deleteBlocks(root, table string, positions []int) (int, error) {
starts := blockStarts(lines)
// mine[p] is where the p-th entry of this table sits among all the blocks.
var mine []int
for k, s := range starts {
if s.table == table {
mine = append(mine, k)
}
}
mine := blocksOf(starts, table)
for _, p := range positions {
if p < 0 || p >= len(mine) {
return 0, fmt.Errorf("%s %d is out of range; %s holds %d %ss",
@@ -254,33 +347,16 @@ func deleteBlocks(root, table string, positions []int) (int, error) {
}
}
// An entry owns the run of comment lines directly above it, with no blank
// line in between. Anything further up is a heading for what follows.
prefix := func(k int) int {
i := starts[k].line
for i > 0 && strings.HasPrefix(strings.TrimSpace(lines[i-1]), "#") {
i--
}
return i
}
drop := map[int]bool{}
for p := range doomed {
k := mine[p]
// The block runs up to the next block's comment prefix, so a comment
// introducing the following one is not swept up with this one.
end := len(lines)
if k+1 < len(starts) {
end = prefix(k + 1)
}
for i := prefix(k); i < end; i++ {
// A rule's own comments document it and go with it; blockExtent leaves
// out the blank lines below it, which are the gap between blocks and
// part of neither, so the neighbours are not glued together.
_, end := blockExtent(lines, starts, k)
for i := commentPrefix(lines, starts[k].line); i < end; i++ {
drop[i] = true
}
// Blank lines are the gap between blocks, not part of either; leaving
// them avoids gluing the neighbours together.
for i := end - 1; i >= starts[k].line && strings.TrimSpace(lines[i]) == ""; i-- {
delete(drop, i)
}
}
kept := make([]string, 0, len(lines))