From 99bbd07a21bf43617eddfc53ae9065a6550d5155 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 12 Aug 2026 11:29:24 +0200 Subject: feat(rules): order rules by stage, ties by file position Account tags must run before topic rules, which is what the stage field is for. Disabled rules stay loaded so a UI can re-enable them. --- mailrules.py | 11 +++++++++++ test_mailrules.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/mailrules.py b/mailrules.py index 26ed698..7df1b42 100755 --- a/mailrules.py +++ b/mailrules.py @@ -125,6 +125,17 @@ def load(path=None): return store +def ordered(rules): + """Enabled rules in execution order: by stage ascending, ties by position. + + `sorted` is stable, so sorting on stage alone preserves file order within + a stage. That is the tie-break the format promises, and it is why this + does not sort on (stage, id): an id-sorted tie would reorder rules a user + deliberately sequenced. + """ + return sorted([r for r in rules if r.enabled], key=lambda r: r.stage) + + def _parse_rule(obj, index, seen, warnings): """One rule, or None with a warning appended. `index` names the rule when it has no usable id of its own.""" diff --git a/test_mailrules.py b/test_mailrules.py index f349f05..e08f490 100755 --- a/test_mailrules.py +++ b/test_mailrules.py @@ -154,6 +154,42 @@ def test_a_newer_format_version_is_refused(): assert any("version" in w for w in store.warnings) +def test_ordered_sorts_by_stage_then_file_position(): + """Account tags must run before topic rules. Ties keep file order, so + the file still reads as a sequence.""" + with tempfile.TemporaryDirectory() as tmp: + path = write_rules(tmp, { + "version": 1, + "rules": [ + {"id": "topic-b", "stage": 50, "add": ["b"], + "query": "from:b@example.com"}, + {"id": "account", "stage": 10, "add": ["acct"], + "query": "path:\"work/**\""}, + {"id": "topic-a", "stage": 50, "add": ["a"], + "query": "from:a@example.com"}, + ], + }) + store = mailrules.load(path) + assert [r.id for r in mailrules.ordered(store.rules)] == [ + "account", "topic-b", "topic-a"] + + +def test_ordered_skips_disabled_rules(): + with tempfile.TemporaryDirectory() as tmp: + path = write_rules(tmp, { + "version": 1, + "rules": [ + {"id": "on", "add": ["a"], "query": "from:a@example.com"}, + {"id": "off", "add": ["b"], "query": "from:b@example.com", + "enabled": False}, + ], + }) + store = mailrules.load(path) + assert [r.id for r in mailrules.ordered(store.rules)] == ["on"] + # The disabled rule is still LOADED, so a UI can show and re-enable it. + assert [r.id for r in store.rules] == ["on", "off"] + + def run_all(): for name, fn in sorted(globals().items()): if name.startswith("test_") and callable(fn): -- cgit v1.2.3