summaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-08-14-query-to-rule-design.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/specs/2026-08-14-query-to-rule-design.md')
-rw-r--r--docs/superpowers/specs/2026-08-14-query-to-rule-design.md125
1 files changed, 125 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-08-14-query-to-rule-design.md b/docs/superpowers/specs/2026-08-14-query-to-rule-design.md
new file mode 100644
index 0000000..8c9c66b
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-14-query-to-rule-design.md
@@ -0,0 +1,125 @@
+# Turning a saved query into a tagging rule
+
+Resolves backlog item **81**. Depends on item 23 (the saved-query file and its
+context menu) and item 76 (the rule builder), both shipped.
+
+## What this is
+
+A saved query is a view: it costs nothing if it is wrong, and the user can see
+exactly what it collects. A tagging rule is the same query given an action and
+handed to a `post-new` hook that runs against real mail every ten minutes. This
+item is the journey from the first to the second, for a query the user has
+already run and whose results are on screen.
+
+Item 77 shipped the opposite direction: a rule in the rules dialog can be
+previewed in the thread list. This is that road backwards.
+
+## The shape
+
+**Entry point.** The saved-query context menu gains **Create tagging rule...**,
+after Delete and below a separator. The menu is built once, in
+`MainWindow::addSavedQueryActions()` (`src/mainwindow.cpp:1743`), and is used
+for both the pinned buttons and the More queries entries, so one action added
+there appears in both places.
+
+**Stored queries only.** A generated entry (Sent, and the account-scoped ones)
+does not get the item. Its query is composed at runtime from the configured
+accounts, so a rule made from it would freeze a snapshot that goes stale the day
+an account is added, in a file the hook reads unattended and nobody re-reads.
+`SavedQuery::isGenerated()` (`src/config.h:144`) is the test, and item 82
+already uses it to make the Edit dialog show a generated query read-only.
+
+**What the action does.** Opens `TagRulesDialog` seeded with one new rule,
+appended to the working list and selected:
+
+| field | value |
+|---|---|
+| `query` | the saved query's stored query, verbatim |
+| `id` | `TagRules::uniqueId(saved.name, <ids already in the list>)` |
+| `add` / `remove` | empty, and the Add tags field takes focus |
+| `enabled` | `true` |
+| `stage` | 50, the default |
+
+Nothing reaches disk until Save, exactly as the existing **Add rule** button
+behaves. Cancel discards the seeded rule along with any other pending edit.
+
+## Decisions, and why
+
+**Not a checkbox in the Save query dialog.** The backlog proposed one. It was
+rejected in favour of a separate action for three reasons: it would give the
+save path a second, more dangerous job; it would make one dialog write two
+files, `queries.json` here and `rules.json` shared with mailctl, raising a
+partial-failure question with no good answer; and `SaveQueryDialog` is
+deliberately pure UI that writes nothing and returns a value
+(`src/savequerydialog.h:31-35`), which is worth keeping. The rules dialog is
+also where the match count and the preview live, and those are what make a rule
+safe to create.
+
+**The seeded rule is enabled.** It matches what Add rule already does, and an
+inconsistent default between two ways of making the same thing is worse than
+either default. A rule created disabled and then forgotten is its own silent
+failure, and the match count in the list is the check that the user has anyway.
+
+**The tags are left empty on purpose.** A rule that adds and removes nothing
+fails `TagRules::validate()`, so Save is refused with the red banner naming the
+rule. The one field the user must supply is the one the dialog opens on, and
+forgetting it is caught rather than written. This is the validation added for
+item 83 doing the job it was built for.
+
+**The scope difference is not restated in the dialog.** When the hook applies a
+rule it supplies `tag:new` and parenthesises the query, so a rule only ever
+matches newly arrived mail, never the messages the user was just looking at.
+That is a real and counterintuitive difference, and it is already stated in the
+dialog's intro text: "Rules tag mail as it arrives ... it does not retag mail
+you already have." A second notice beside the red warning banner would dilute
+the banner, which item 83 has just finished making load-bearing. If the intro
+turns out not to carry it, fix the intro rather than adding a second line.
+
+## Structure
+
+Three small changes, no new file.
+
+- **`TagRulesDialog`** gains a constructor taking a seed `TagRule`. It appends
+ the seed to `m_working` after the normal load, selects it, and focuses the Add
+ tags field. The existing constructor delegates to it with no seed.
+- **`MainWindow::showTagRulesDialog()`** gains an optional seed parameter,
+ passed through. Every existing caller is unchanged.
+- **`MainWindow::addSavedQueryActions()`** gains the action, guarded on
+ `!saved.isGenerated()`.
+
+**Item 78 folds into this.** "Create rule from sender" is the same seeded-dialog
+path with a different seed, so it becomes a second caller rather than a second
+mechanism. This is the reason the seed is a whole `TagRule` rather than a query
+string: a sender rule will want to set tags too.
+
+## Testing
+
+In `test_tagrules`:
+
+- A seeded dialog carries the query and a sanitised id, and the rule is in the
+ working list and **not** on disk.
+- Save writes it. Cancel does not, asserted by rereading the file.
+- A seed whose name collides with an existing rule id gets a suffix rather than
+ replacing that rule. This is the case `uniqueId` exists for, and it is
+ reachable here in a way it is not from the rules dialog alone, since the name
+ comes from a different file.
+- A seeded rule saved with no tags is refused, and the banner names it.
+
+In `test_mainwindow`:
+
+- The menu carries `Create tagging rule...` for a stored query and does not for
+ a generated one. The generated half needs a guard proving the menu was built
+ at all: item 82 records that a test asserting only the ABSENCE of a widget
+ passes against no implementation whatever.
+
+## Out of scope
+
+**Backfill.** Applying the new rule to the mail the user is looking at is the
+obvious next question and is deliberately unbuilt, here as everywhere else. See
+`2026-08-12-tagging-rules-design.md` for what it needs first, including the
+revision it forces to the no-confirmation rule in CLAUDE.md.
+
+**Editing the saved query from the rules dialog.** The two objects are
+independent once the rule exists. A rule that remembers where it came from would
+raise the question of what happens when the query is later edited, and neither
+answer is obviously right.