diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-13 10:54:07 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-13 10:54:07 +0200 |
| commit | df6461fa9136f2110c8a24567a3e4a32432a9476 (patch) | |
| tree | 20b78ada80d95da16a7ec0ce21c0a04e1c14f97f /docs | |
| parent | 2d451e35e95a580d0e81c2e1bf7220d879490864 (diff) | |
| download | qtmaildir-df6461fa9136f2110c8a24567a3e4a32432a9476.tar.gz qtmaildir-df6461fa9136f2110c8a24567a3e4a32432a9476.zip | |
docs: implementation plan for the rule builder
Twelve tasks against the design approved today, TDD throughout: RuleQuery
comes first as a value type with no widget dependency, tested on exact
strings, and the dialog is wired to it only once parsing and compiling
round-trip.
Two tasks carry the guarantees the design was shaped around rather than
merely testing behaviour. Task 7 round-trips every query shape present in
the live rules file and pins it with a mutation check, since a compile
that differs by one paren would rewrite a file a second tool reads. Task
6 pins whole-query rejection, because a parser that salvages the part it
understands is how a not clause goes missing and a live mail filter
silently widens.
Queries in the tests are generic placeholders. The shapes are what is
under test and they survive substitution intact.
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-08-13-rule-builder.md | 2054 |
1 files changed, 2054 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-08-13-rule-builder.md b/docs/superpowers/plans/2026-08-13-rule-builder.md new file mode 100644 index 0000000..fb39cca --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-rule-builder.md @@ -0,0 +1,2054 @@ +# Rule Builder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the free-text query field in the tagging rules dialog with a +row builder (field/operator dropdowns, `+`/`-` buttons, an all/any radio, and a +"but not" exclusion block), while leaving the notmuch query string as the +stored format. + +**Architecture:** A new `RuleQuery` value type parses a notmuch query string +into rows and compiles rows back into a string. It has no widget dependency and +is unit-tested on its own, following `TagRules` and `CardLayout`. The dialog +holds the `RuleQuery` it parsed and compares against it on save, so a rule that +was opened but not edited is written back byte for byte. A query the parser +cannot represent is not an error: the rule opens in a text mode that every rule +carries. + +**Tech Stack:** C++17, Qt6 Widgets, QtTest, CMake + Ninja. + +**Spec:** `docs/superpowers/specs/2026-08-13-rule-builder-design.md`. Read it +before starting; it records why the storage format is untouched and why the +parser is strict. + +--- + +## Before you start + +Build and run the suite once, so a later failure is known to be yours: + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug +cmake --build build +ctest --test-dir build --output-on-failure +``` + +Expected: all tests pass (19 binaries as of 0.16.0). + +**Never run a test binary without `QT_QPA_PLATFORM=offscreen`**, and never +launch `./build/src/qtmaildir`. `tests/CMakeLists.txt` sets the platform for +ctest only; a binary invoked directly throws real windows onto the user's +desktop. To run one test binary: + +```bash +QT_QPA_PLATFORM=offscreen ./build/tests/test_rulequery +``` + +**Two project rules that apply to every task below.** Wrap user-facing strings +in `tr()`, but never translate notmuch query syntax: `from:`, `and`, `not` and +the rest are wire format. And put no personal data in test fixtures: use +`example.org` addresses and generic account names. + +## File Structure + +| File | Responsibility | +|---|---| +| `src/rulequery.h` (create) | `RuleTerm`, `RuleQuery`, the parse/compile interface | +| `src/rulequery.cpp` (create) | Parsing, compiling, comparison. No widgets. | +| `src/CMakeLists.txt` (modify) | Add `rulequery.cpp` to `qtmaildir_lib` | +| `tests/test_rulequery.cpp` (create) | Grammar, corpus round-trip, rejection, mutation guard | +| `tests/CMakeLists.txt` (modify) | Register the `rulequery` test | +| `src/tagrulesdialog.h/.cpp` (modify) | The builder widgets, text-mode toggle, save comparison | +| `tests/test_tagrules.cpp` (modify) | Dialog-level round-trip tests | + +`RuleQuery` stays free of Qt widget headers so it can be tested without a +`QApplication` and so the parsing logic can be read in one sitting. + +--- + +### Task 1: `RuleQuery` skeleton and compiling a single term + +**Files:** +- Create: `src/rulequery.h`, `src/rulequery.cpp` +- Modify: `src/CMakeLists.txt` +- Create: `tests/test_rulequery.cpp` +- Modify: `tests/CMakeLists.txt` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_rulequery.cpp`. Copy the GPLv2 header from +`tests/test_tagrules.cpp:1-17` verbatim, then: + +```cpp +#include <QtTest> + +#include "rulequery.h" + +/// RuleQuery is a view over a string the notmuch post-new hook executes, so +/// the risk is a query that compiles to something subtly wider than the rows +/// say. These tests are about exact strings, not about whether notmuch would +/// accept the result: notmuch accepts almost anything, including `from:((((`. +class TestRuleQuery : public QObject +{ + Q_OBJECT + +private slots: + void aSingleContainsTermCompiles(); +}; + +void TestRuleQuery::aSingleContainsTermCompiles() +{ + RuleQuery q; + q.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("sender@example.org")}); + + QCOMPARE(q.compile(), QStringLiteral("from:sender@example.org")); +} + +QTEST_MAIN(TestRuleQuery) +#include "test_rulequery.moc" +``` + +- [ ] **Step 2: Create the header** + +Create `src/rulequery.h`. Copy the GPLv2 header from `src/tagrules.h:1-17` +verbatim, then: + +```cpp +#pragma once + +#include <QList> +#include <QString> + +/// One row in the builder: a field, an operator, and a value. +struct RuleTerm +{ + enum Field { From, To, Cc, Subject, Tag, Folder, Attachment, Date }; + + enum Op { + Contains, ContainsNot, ///< Unquoted term, optionally negated. + Is, IsNot, ///< Quoted phrase, optionally negated. + Has, HasNot, ///< attachment: only. + Before, After ///< date: only. "not before" is "after", so + ///< these carry no negated twin. + }; + + Field field = From; + Op op = Contains; + QString value; +}; + +bool operator==(const RuleTerm &a, const RuleTerm &b); + +/// A tagging rule's query, as rows. +/// +/// The STRING is the stored format, shared with mailctl and executed by the +/// notmuch post-new hook. This type is a view over it, never the store: a +/// query it cannot represent must still open, save and run unchanged. +struct RuleQuery +{ + enum Join { All, Any }; ///< and / or, over the positive terms only. + + Join join = All; + QList<RuleTerm> terms; ///< Positive section. + QList<RuleTerm> exclusions; ///< The "but not" block, joined `and not`. + + /// False when the query cannot be shown as rows. NOT an error and NOT a + /// claim that the query is invalid: the rule opens in text mode. + bool parsed = false; + + static RuleQuery parse(const QString &query); + QString compile() const; +}; + +bool operator==(const RuleQuery &a, const RuleQuery &b); +``` + +- [ ] **Step 3: Create the implementation** + +Create `src/rulequery.cpp`. Copy the GPLv2 header from `src/tagrules.cpp:1-17` +verbatim, then: + +```cpp +#include "rulequery.h" + +namespace { + +/// The notmuch prefix each field compiles to. Wire format, never translated. +QString prefixFor(RuleTerm::Field field) +{ + switch (field) { + case RuleTerm::From: return QStringLiteral("from"); + case RuleTerm::To: return QStringLiteral("to"); + case RuleTerm::Cc: return QStringLiteral("cc"); + case RuleTerm::Subject: return QStringLiteral("subject"); + case RuleTerm::Tag: return QStringLiteral("tag"); + case RuleTerm::Folder: return QStringLiteral("path"); + case RuleTerm::Attachment: return QStringLiteral("attachment"); + case RuleTerm::Date: return QStringLiteral("date"); + } + return QString(); +} + +} // namespace + +bool operator==(const RuleTerm &a, const RuleTerm &b) +{ + return a.field == b.field && a.op == b.op && a.value == b.value; +} + +bool operator==(const RuleQuery &a, const RuleQuery &b) +{ + return a.parsed == b.parsed && a.join == b.join + && a.terms == b.terms && a.exclusions == b.exclusions; +} + +QString RuleQuery::compile() const +{ + if (terms.isEmpty()) + return QString(); + + return prefixFor(terms.first().field) + QLatin1Char(':') + + terms.first().value; +} + +RuleQuery RuleQuery::parse(const QString &query) +{ + Q_UNUSED(query); + return RuleQuery(); +} +``` + +- [ ] **Step 4: Wire into the build** + +In `src/CMakeLists.txt`, add `rulequery.cpp` to the `qtmaildir_lib` source +list, after `querycompleter.cpp`. + +In `tests/CMakeLists.txt`, add after the other `add_qtmaildir_test` calls: + +```cmake +add_qtmaildir_test(rulequery) +``` + +- [ ] **Step 5: Build and run the test** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_rulequery +``` + +Expected: PASS, 1 test. + +- [ ] **Step 6: Commit** + +```bash +git add src/rulequery.h src/rulequery.cpp src/CMakeLists.txt \ + tests/test_rulequery.cpp tests/CMakeLists.txt +git commit -S -m "feat(rulequery): compile a single term" +``` + +--- + +### Task 2: Compile every field and operator + +**Files:** +- Modify: `src/rulequery.cpp` +- Test: `tests/test_rulequery.cpp` + +- [ ] **Step 1: Write the failing tests** + +Add these slots to the `private slots:` block and the bodies below the +existing test: + +```cpp + void everyFieldCompilesToItsPrefix(); + void isQuotesAndContainsDoesNot(); + void negationPrefixesNot(); + void aValueWithASpaceIsAlwaysQuoted(); + void folderAppendsTheRecursiveSuffix(); + void dateCompilesToAOneSidedRange(); +``` + +```cpp +void TestRuleQuery::everyFieldCompilesToItsPrefix() +{ + const QVector<QPair<RuleTerm::Field, QString>> cases = { + {RuleTerm::From, QStringLiteral("from:x")}, + {RuleTerm::To, QStringLiteral("to:x")}, + {RuleTerm::Cc, QStringLiteral("cc:x")}, + {RuleTerm::Subject, QStringLiteral("subject:x")}, + }; + + for (const auto &c : cases) { + RuleQuery q; + q.terms.append({c.first, RuleTerm::Contains, QStringLiteral("x")}); + QCOMPARE(q.compile(), c.second); + } +} + +void TestRuleQuery::isQuotesAndContainsDoesNot() +{ + RuleQuery contains; + contains.terms.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("receipt")}); + QCOMPARE(contains.compile(), QStringLiteral("subject:receipt")); + + RuleQuery is; + is.terms.append({RuleTerm::Subject, RuleTerm::Is, + QStringLiteral("receipt")}); + QCOMPARE(is.compile(), QStringLiteral("subject:\"receipt\"")); +} + +void TestRuleQuery::negationPrefixesNot() +{ + RuleQuery q; + q.terms.append({RuleTerm::Subject, RuleTerm::ContainsNot, + QStringLiteral("receipt")}); + QCOMPARE(q.compile(), QStringLiteral("not subject:receipt")); + + RuleQuery tag; + tag.terms.append({RuleTerm::Tag, RuleTerm::IsNot, + QStringLiteral("inbox")}); + QCOMPARE(tag.compile(), QStringLiteral("not tag:inbox")); + + RuleQuery att; + att.terms.append({RuleTerm::Attachment, RuleTerm::HasNot, + QStringLiteral("pdf")}); + QCOMPARE(att.compile(), QStringLiteral("not attachment:pdf")); +} + +void TestRuleQuery::aValueWithASpaceIsAlwaysQuoted() +{ + // Unquoted, a space would end the term and the rest would become a + // separate bare word, silently widening the rule. + RuleQuery q; + q.terms.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("your receipt")}); + QCOMPARE(q.compile(), QStringLiteral("subject:\"your receipt\"")); +} + +void TestRuleQuery::folderAppendsTheRecursiveSuffix() +{ + // A path: without the suffix matches nothing, and notmuch reports no + // error when it happens. + RuleQuery q; + q.terms.append({RuleTerm::Folder, RuleTerm::Is, + QStringLiteral("account-one")}); + QCOMPARE(q.compile(), QStringLiteral("path:\"account-one/**\"")); +} + +void TestRuleQuery::dateCompilesToAOneSidedRange() +{ + RuleQuery before; + before.terms.append({RuleTerm::Date, RuleTerm::Before, + QStringLiteral("2026-01-01")}); + QCOMPARE(before.compile(), QStringLiteral("date:..2026-01-01")); + + RuleQuery after; + after.terms.append({RuleTerm::Date, RuleTerm::After, + QStringLiteral("2026-01-01")}); + QCOMPARE(after.compile(), QStringLiteral("date:2026-01-01..")); +} +``` + +- [ ] **Step 2: Run to verify they fail** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_rulequery +``` + +Expected: FAIL on `isQuotesAndContainsDoesNot` and the ones after it. + +- [ ] **Step 3: Implement term compilation** + +In `src/rulequery.cpp`, add to the anonymous namespace: + +```cpp +bool isNegated(RuleTerm::Op op) +{ + return op == RuleTerm::ContainsNot || op == RuleTerm::IsNot + || op == RuleTerm::HasNot; +} + +/// Quoted when the operator asks for an exact phrase, and ALWAYS when the +/// value holds a space: unquoted, the space ends the term and the remainder +/// becomes a bare word, which widens the rule instead of breaking it. +bool needsQuotes(const RuleTerm &term) +{ + if (term.field == RuleTerm::Folder) + return true; + if (term.op == RuleTerm::Is || term.op == RuleTerm::IsNot) + return true; + return term.value.contains(QLatin1Char(' ')); +} + +QString compileTerm(const RuleTerm &term) +{ + QString value = term.value; + if (term.field == RuleTerm::Folder) + value += QStringLiteral("/**"); + + QString body; + if (term.field == RuleTerm::Date) { + body = prefixFor(term.field) + QLatin1Char(':') + + (term.op == RuleTerm::Before + ? QStringLiteral("..") + value + : value + QStringLiteral("..")); + } else if (needsQuotes(term)) { + body = prefixFor(term.field) + QStringLiteral(":\"") + value + + QLatin1Char('"'); + } else { + body = prefixFor(term.field) + QLatin1Char(':') + value; + } + + return isNegated(term.op) ? QStringLiteral("not ") + body : body; +} +``` + +Replace the body of `compile()` with: + +```cpp +QString RuleQuery::compile() const +{ + if (terms.isEmpty()) + return QString(); + + return compileTerm(terms.first()); +} +``` + +- [ ] **Step 4: Run to verify they pass** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_rulequery +``` + +Expected: PASS, 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/rulequery.cpp tests/test_rulequery.cpp +git commit -S -m "feat(rulequery): compile every field and operator" +``` + +--- + +### Task 3: Join terms, and the parenthesisation rule + +**Files:** +- Modify: `src/rulequery.cpp` +- Test: `tests/test_rulequery.cpp` + +The rule: the positive group is parenthesised when `join == Any` **and** there +is at least one exclusion. This is the same binding hazard the hook already +guards against, where `tag:new and a or b` binds as `(tag:new and a) or b`. + +- [ ] **Step 1: Write the failing tests** + +Add slots and bodies: + +```cpp + void allJoinsWithAnd(); + void anyJoinsWithOr(); + void exclusionsAppendAsAndNot(); + void anyIsParenthesisedOnlyWhenExclusionsFollow(); + void anEmptyQueryCompilesToAnEmptyString(); +``` + +```cpp +void TestRuleQuery::allJoinsWithAnd() +{ + RuleQuery q; + q.join = RuleQuery::All; + q.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("vendor.example.org")}); + q.terms.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("receipt")}); + + QCOMPARE(q.compile(), + QStringLiteral("from:vendor.example.org and subject:receipt")); +} + +void TestRuleQuery::anyJoinsWithOr() +{ + RuleQuery q; + q.join = RuleQuery::Any; + q.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("one.example.org")}); + q.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("two.example.org")}); + + QCOMPARE(q.compile(), + QStringLiteral("from:one.example.org or from:two.example.org")); +} + +void TestRuleQuery::exclusionsAppendAsAndNot() +{ + RuleQuery q; + q.join = RuleQuery::All; + q.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("vendor.example.org")}); + q.exclusions.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("receipt")}); + + QCOMPARE(q.compile(), + QStringLiteral("from:vendor.example.org " + "and not subject:receipt")); +} + +void TestRuleQuery::anyIsParenthesisedOnlyWhenExclusionsFollow() +{ + // Without the parens this binds as (a or (b and not c)), which matches + // everything from the first sender regardless of the exclusion. + RuleQuery guarded; + guarded.join = RuleQuery::Any; + guarded.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("one.example.org")}); + guarded.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("two.example.org")}); + guarded.exclusions.append({RuleTerm::Subject, RuleTerm::Contains, + QStringLiteral("receipt")}); + + QCOMPARE(guarded.compile(), + QStringLiteral("(from:one.example.org or from:two.example.org) " + "and not subject:receipt")); + + // No exclusion, no parens: they would be noise in the stored file. + RuleQuery bare; + bare.join = RuleQuery::Any; + bare.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("one.example.org")}); + bare.terms.append({RuleTerm::From, RuleTerm::Contains, + QStringLiteral("two.example.org")}); + + QCOMPARE(bare.compile(), + QStringLiteral("from:one.example.org or from:two.example.org")); +} + +void TestRuleQuery::anEmptyQueryCompilesToAnEmptyString() +{ + RuleQuery q; + QCOMPARE(q.compile(), QString()); +} +``` + +- [ ] **Step 2: Run to verify they fail** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_rulequery +``` + +Expected: FAIL on `allJoinsWithAnd` and the joining tests after it. + +- [ ] **Step 3: Implement joining** + +Replace `compile()` with: + +```cpp +QString RuleQuery::compile() const +{ + if (terms.isEmpty()) + return QString(); + + QStringList parts; + for (const RuleTerm &term : terms) + parts.append(compileTerm(term)); + + const QString glue = join == Any ? QStringLiteral(" or ") + : QStringLiteral(" and "); + QString out = parts.join(glue); + + // An `or` group followed by `and not` must be parenthesised or the `and` + // binds tighter than the `or`: `a or b and not c` is `a or (b and not c)`, + // which matches every `a` whatever the exclusion says. + if (join == Any && !exclusions.isEmpty() && terms.size() > 1) + out = QLatin1Char('(') + out + QLatin1Char(')'); + + for (const RuleTerm &term : exclusions) { + RuleTerm positive = term; + // The block's rows are stored un-negated; the block itself is the + // negation. Compiling them through the negated operator would emit + // `and not not subject:x`. + out += QStringLiteral(" and not ") + compileTerm(positive); + } + + return out; +} +``` + +- [ ] **Step 4: Run to verify they pass** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_rulequery +``` + +Expected: PASS, 12 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/rulequery.cpp tests/test_rulequery.cpp +git commit -S -m "feat(rulequery): join terms and guard the or-group binding" +``` + +--- + +### Task 4: Parse a flat chain + +**Files:** +- Modify: `src/rulequery.cpp` +- Test: `tests/test_rulequery.cpp` + +- [ ] **Step 1: Write the failing tests** + +```cpp + void aFlatAndChainParses(); + void aFlatOrChainParses(); + void anEmptyQueryParsesToNoRows(); + void quotedValuesLoseTheirQuotes(); + void aNegatedTermParsesAsANegatedOperator(); +``` + +```cpp +void TestRuleQuery::aFlatAndChainParses() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("from:vendor.example.org and subject:receipt")); + + QVERIFY(q.parsed); + QCOMPARE(q.join, RuleQuery::All); + QCOMPARE(q.terms.size(), 2); + QCOMPARE(q.terms.at(0).field, RuleTerm::From); + QCOMPARE(q.terms.at(0).op, RuleTerm::Contains); + QCOMPARE(q.terms.at(0).value, QStringLiteral("vendor.example.org")); + QCOMPARE(q.terms.at(1).field, RuleTerm::Subject); + QVERIFY(q.exclusions.isEmpty()); +} + +void TestRuleQuery::aFlatOrChainParses() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("from:one.example.org or from:two.example.org")); + + QVERIFY(q.parsed); + QCOMPARE(q.join, RuleQuery::Any); + QCOMPARE(q.terms.size(), 2); +} + +void TestRuleQuery::anEmptyQueryParsesToNoRows() +{ + // One shipped rule has an empty query. It must open in the builder ready + // to receive a row, not fall back to text mode. + const RuleQuery q = RuleQuery::parse(QString()); + + QVERIFY(q.parsed); + QVERIFY(q.terms.isEmpty()); +} + +void TestRuleQuery::quotedValuesLoseTheirQuotes() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("subject:\"your receipt\"")); + + QVERIFY(q.parsed); + QCOMPARE(q.terms.size(), 1); + QCOMPARE(q.terms.at(0).value, QStringLiteral("your receipt")); + QCOMPARE(q.terms.at(0).op, RuleTerm::Is); +} + +void TestRuleQuery::aNegatedTermParsesAsANegatedOperator() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("from:vendor.example.org and not tag:inbox")); + + QVERIFY(q.parsed); + // A trailing negation on an `and` chain becomes an exclusion: that is how + // the user describes these rules, and the design records the preference. + QCOMPARE(q.terms.size(), 1); + QCOMPARE(q.exclusions.size(), 1); + QCOMPARE(q.exclusions.at(0).field, RuleTerm::Tag); + QCOMPARE(q.exclusions.at(0).op, RuleTerm::Is); +} +``` + +- [ ] **Step 2: Run to verify they fail** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_rulequery +``` + +Expected: FAIL, `parsed` is false everywhere. + +- [ ] **Step 3: Implement tokenising and term parsing** + +Add to the anonymous namespace in `src/rulequery.cpp`: + +```cpp +/// Splits on whitespace, keeping a double-quoted run as one token. Returns +/// false when a quote is left open, which is a query this builder will not +/// represent. +bool tokenise(const QString &query, QStringList *out) +{ + QString current; + bool inQuotes = false; + bool has = false; + + for (int i = 0; i < query.size(); ++i) { + const QChar c = query.at(i); + if (c == QLatin1Char('"')) { + inQuotes = !inQuotes; + current += c; + has = true; + } else if (!inQuotes && c.isSpace()) { + if (has) { + out->append(current); + current.clear(); + has = false; + } + } else { + current += c; + has = true; + } + } + + if (inQuotes) + return false; + if (has) + out->append(current); + return true; +} + +bool fieldForPrefix(const QString &prefix, RuleTerm::Field *out) +{ + static const QVector<QPair<QString, RuleTerm::Field>> table = { + {QStringLiteral("from"), RuleTerm::From}, + {QStringLiteral("to"), RuleTerm::To}, + {QStringLiteral("cc"), RuleTerm::Cc}, + {QStringLiteral("subject"), RuleTerm::Subject}, + {QStringLiteral("tag"), RuleTerm::Tag}, + {QStringLiteral("path"), RuleTerm::Folder}, + {QStringLiteral("attachment"), RuleTerm::Attachment}, + {QStringLiteral("date"), RuleTerm::Date}, + }; + + for (const auto &entry : table) { + if (entry.first == prefix) { + *out = entry.second; + return true; + } + } + return false; +} + +/// Parses ONE token into a term. Returns false for anything this builder does +/// not represent, which is not the same as invalid: notmuch accepts far more +/// than this. +bool parseTerm(const QString &token, RuleTerm *out) +{ + const int colon = token.indexOf(QLatin1Char(':')); + if (colon <= 0) + return false; + + RuleTerm::Field field; + if (!fieldForPrefix(token.left(colon), &field)) + return false; + + QString value = token.mid(colon + 1); + if (value.isEmpty()) + return false; + + bool quoted = false; + if (value.size() >= 2 && value.startsWith(QLatin1Char('"')) + && value.endsWith(QLatin1Char('"'))) { + value = value.mid(1, value.size() - 2); + quoted = true; + } + // A quote anywhere else means a shape this builder does not emit. + if (value.contains(QLatin1Char('"'))) + return false; + + out->field = field; + + if (field == RuleTerm::Date) { + if (value.startsWith(QStringLiteral(".."))) { + out->op = RuleTerm::Before; + out->value = value.mid(2); + } else if (value.endsWith(QStringLiteral(".."))) { + out->op = RuleTerm::After; + out->value = value.chopped(2); + } else { + return false; // A two-sided range is not a row. + } + return !out->value.isEmpty(); + } + + if (field == RuleTerm::Folder) { + // Only the recursive form is representable; a bare path means + // something different to notmuch and must not be silently rewritten. + if (!value.endsWith(QStringLiteral("/**"))) + return false; + value = value.chopped(3); + out->op = RuleTerm::Is; + out->value = value; + return !value.isEmpty(); + } + + if (field == RuleTerm::Attachment) + out->op = RuleTerm::Has; + else if (field == RuleTerm::Tag) + out->op = RuleTerm::Is; + else + out->op = quoted ? RuleTerm::Is : RuleTerm::Contains; + + out->value = value; + return true; +} + +RuleTerm::Op negate(RuleTerm::Op op) +{ + switch (op) { + case RuleTerm::Contains: return RuleTerm::ContainsNot; + case RuleTerm::Is: return RuleTerm::IsNot; + case RuleTerm::Has: return RuleTerm::HasNot; + default: return op; + } +} + +} // namespace <- this closing brace already exists; add the above ABOVE it +``` + +Then replace `parse()`: + +```cpp +RuleQuery RuleQuery::parse(const QString &query) +{ + RuleQuery out; + + const QString trimmed = query.trimmed(); + if (trimmed.isEmpty()) { + // An empty query is a rule with no rows yet, not a failure. + out.parsed = true; + return out; + } + + QStringList tokens; + if (!tokenise(trimmed, &tokens)) + return out; + + // Walk the chain: term, operator, term, ... Anything else rejects whole. + bool sawOr = false; + bool sawAnd = false; + int i = 0; + + while (i < tokens.size()) { + bool negated = false; + if (tokens.at(i).compare(QStringLiteral("not"), + Qt::CaseInsensitive) == 0) { + negated = true; + ++i; + if (i >= tokens.size()) + return out; + } + + RuleTerm term; + if (!parseTerm(tokens.at(i), &term)) + return out; + if (negated) { + // `not date:` has no row form: "not before" is "after", which the + // unnegated operators already express. + if (term.field == RuleTerm::Date) + return out; + // The block IS the negation, so the row is stored un-negated and + // compile() re-applies the `and not`. Storing it negated would + // emit `and not not subject:x`. + out.exclusions.append(term); + } else { + out.terms.append(term); + } + ++i; + + if (i >= tokens.size()) + break; + + const QString glue = tokens.at(i).toLower(); + if (glue == QStringLiteral("and")) { + sawAnd = true; + } else if (glue == QStringLiteral("or")) { + sawOr = true; + } else { + return out; // Not a joining word: unrepresentable. + } + ++i; + if (i >= tokens.size()) + return out; // Trailing operator. + } + + // Mixed and/or without parentheses is ambiguous to a reader and binds in + // a way the rows cannot show. Reject rather than guess. + if (sawAnd && sawOr) + return out; + if (out.terms.isEmpty()) + return out; + + out.join = sawOr ? Any : All; + out.parsed = true; + return out; +} +``` + +**On `negate()`:** it is declared in this task's helper block but only used +from Task 8's operator dropdowns, where a user picks "contains not" directly. +Parsing does not call it, because an exclusion row is stored un-negated. Leave +it defined; a compiler warning about an unused static would mean it was placed +outside the anonymous namespace. + +- [ ] **Step 4: Run to verify they pass** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_rulequery +``` + +Expected: PASS, 17 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/rulequery.cpp tests/test_rulequery.cpp +git commit -S -m "feat(rulequery): parse a flat and/or chain" +``` + +--- + +### Task 5: Parse the parenthesised or-group with exclusions + +**Files:** +- Modify: `src/rulequery.cpp` +- Test: `tests/test_rulequery.cpp` + +This is the one nested shape the builder represents, and the shape of the real +rule that motivated the exclusion block. + +- [ ] **Step 1: Write the failing test** + +```cpp + void anOrGroupWithExclusionsParses(); +``` + +```cpp +void TestRuleQuery::anOrGroupWithExclusionsParses() +{ + const RuleQuery q = RuleQuery::parse( + QStringLiteral("(from:vendor.example.org or from:vendor.example.net) " + "and not subject:receipt and not subject:refund")); + + QVERIFY(q.parsed); + QCOMPARE(q.join, RuleQuery::Any); + QCOMPARE(q.terms.size(), 2); + QCOMPARE(q.exclusions.size(), 2); + QCOMPARE(q.exclusions.at(0).field, RuleTerm::Subject); + QCOMPARE(q.exclusions.at(0).op, RuleTerm::Contains); + QCOMPARE(q.exclusions.at(0).value, QStringLiteral("receipt")); + + // The round trip is the point: this must come back as it went in. + QCOMPARE(q.compile(), + QStringLiteral("(from:vendor.example.org or " + "from:vendor.example.net) " + "and not subject:receipt and not subject:refund")); +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Expected: FAIL, `q.parsed` is false (the leading `(` is not a term). + +- [ ] **Step 3: Implement the group split** + +At the top of `parse()`, after the empty check and before tokenising, add a +branch that peels the group. Insert this helper in the anonymous namespace: + +```cpp +/// Splits `(A or B) and not C and not D` into its group and its remainder. +/// Returns false when the query does not start with a balanced group. +bool splitLeadingGroup(const QString &query, QString *group, QString *rest) +{ + if (!query.startsWith(QLatin1Char('('))) + return false; + + int depth = 0; + bool inQuotes = false; + for (int i = 0; i < query.size(); ++i) { + const QChar c = query.at(i); + if (c == QLatin1Char('"')) + inQuotes = !inQuotes; + if (inQuotes) + continue; + if (c == QLatin1Char('(')) + ++depth; + else if (c == QLatin1Char(')')) { + --depth; + if (depth == 0) { + *group = query.mid(1, i - 1).trimmed(); + *rest = query.mid(i + 1).trimmed(); + return true; + } + } + } + return false; +} +``` + +And in `parse()`, after the empty-query branch: + +```cpp + QString group; + QString rest; + if (splitLeadingGroup(trimmed, &group, &rest)) { + // Only one nested shape is representable: an `or` group followed by + // `and not` exclusions. Anything else rejects whole. + if (group.contains(QLatin1Char('('))) + return out; + + const RuleQuery inner = parse(group); + if (!inner.parsed || inner.join != Any || !inner.exclusions.isEmpty()) + return out; + + out.join = Any; + out.terms = inner.terms; + + if (rest.isEmpty()) { + out.parsed = true; + return out; + } + + // The remainder must be nothing but `and not <term>` repetitions. + QStringList tail; + if (!tokenise(rest, &tail)) + return out; + + int i = 0; + while (i < tail.size()) { + if (tail.at(i).compare(QStringLiteral("and"), + Qt::CaseInsensitive) != 0) + return out; + ++i; + if (i >= tail.size() + || tail.at(i).compare(QStringLiteral("not"), + Qt::CaseInsensitive) != 0) + return out; + ++i; + if (i >= tail.size()) + return out; + + RuleTerm term; + if (!parseTerm(tail.at(i), &term)) + return out; + out.exclusions.append(term); + ++i; + } + + out.parsed = true; + return out; + } +``` + +- [ ] **Step 4: Run to verify it passes** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_rulequery +``` + +Expected: PASS, 18 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/rulequery.cpp tests/test_rulequery.cpp +git commit -S -m "feat(rulequery): parse an or-group with trailing exclusions" +``` + +--- + +### Task 6: Rejection tests, the safety property + +**Files:** +- Test: `tests/test_rulequery.cpp` + +A lenient parser that salvages what it understands is how a `not` clause gets +dropped and a filter silently widens. These tests pin the strictness. + +- [ ] **Step 1: Write the tests** + +```cpp + void anUnrepresentableQueryRejectsWhole(); + void aMalformedQueryIsRejectedNotDiagnosed(); +``` + +```cpp +void TestRuleQuery::anUnrepresentableQueryRejectsWhole() +{ + const QStringList unrepresentable = { + QStringLiteral("from:a.example.org or (from:b.example.org " + "or from:c.example.org)"), // nested or inside or + QStringLiteral("from:a.example.org and subject:x " + "or subject:y"), // mixed, unparenthesised + QStringLiteral("body:receipt"), // unrecognised prefix + QStringLiteral("folder:Inbox"), // not the prefix we emit + QStringLiteral("receipt"), // bare word, no prefix + QStringLiteral("path:\"account-one\""), // no /** suffix + QStringLiteral("from:a.example.org and"), // trailing operator + QStringLiteral("date:2026-01-01..2026-02-01"), // two-sided range + QStringLiteral("from:a.example.org xor subject:x"), + }; + + for (const QString &query : unrepresentable) { + const RuleQuery q = RuleQuery::parse(query); + QVERIFY2(!q.parsed, qPrintable(QStringLiteral("parsed: ") + query)); + // Rejecting whole means keeping nothing: a half-parse is how a + // negation goes missing and a rule quietly matches more mail. + QVERIFY2(q.terms.isEmpty() && q.exclusions.isEmpty(), + qPrintable(QStringLiteral("kept rows: ") + query)); + } +} + +void TestRuleQuery::aMalformedQueryIsRejectedNotDiagnosed() +{ + // notmuch accepts `from:((((` cleanly and matches nothing, so there is no + // failure to observe and a test asserting one fails against correct code. + // The assertion is on OUR rejection only. + const RuleQuery q = RuleQuery::parse(QStringLiteral("from:((((")); + QVERIFY(!q.parsed); +} +``` + +- [ ] **Step 2: Run** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_rulequery +``` + +Expected: PASS, 20 tests. If any case parses, fix the parser, not the test. + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_rulequery.cpp +git commit -S -m "test(rulequery): pin whole-query rejection" +``` + +--- + +### Task 7: The corpus round-trip and its mutation guard + +**Files:** +- Test: `tests/test_rulequery.cpp` + +This is the test that matters: it is the whole "the user's rules file does not +churn" guarantee. Queries are generic placeholders; the shapes are what is +under test. + +- [ ] **Step 1: Write the test** + +```cpp + void theRuleCorpusRoundTripsByteForByte(); +``` + +```cpp +void TestRuleQuery::theRuleCorpusRoundTripsByteForByte() +{ + // Every shape present in a real rules file, with placeholder values. A + // compile that differs by so much as a paren would rewrite the shared + // file on the next save, which a second tool then sees as a diff nobody + // made. + const QStringList corpus = { + QStringLiteral("path:\"account-one/**\""), + QStringLiteral("path:\"account-two/Inbox/topic/**\""), + QStringLiteral("subject:\"[list-name]\""), + QStringLiteral("from:notifications@service.example.org"), + QStringLiteral("from:one@jobs.example.org or " + "from:two@jobs.example.org or " + "from:three@jobs.example.org"), + QStringLiteral("from:mail.vendor.example.org and " + "subject:\"Secure link\""), + QStringLiteral("(from:vendor.example.org or from:vendor.example.net) " + "and not subject:receipt and not subject:refund " + "and not subject:EUR"), + }; + + for (const QString &query : corpus) { + const RuleQuery parsed = RuleQuery::parse(query); + QVERIFY2(parsed.parsed, qPrintable(query)); + QCOMPARE(parsed.compile(), query); + + // And the value itself round-trips, which is what the dialog's + // "was this edited" comparison depends on. + QVERIFY2(RuleQuery::parse(parsed.compile()) == parsed, + qPrintable(query)); + } +} +``` + +- [ ] **Step 2: Run** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_rulequery +``` + +Expected: PASS, 21 tests. + +- [ ] **Step 3: Mutation check** + +A passing test here proves nothing without one. Temporarily change `compile()` +so the group is always parenthesised: + +```cpp + if (join == Any && terms.size() > 1) // was: && !exclusions.isEmpty() + out = QLatin1Char('(') + out + QLatin1Char(')'); +``` + +Rebuild and run. + +Expected: **FAIL** on `theRuleCorpusRoundTripsByteForByte` and on +`anyIsParenthesisedOnlyWhenExclusionsFollow`. If either still passes, the test +is not testing what it claims. **Revert the mutation before continuing.** + +- [ ] **Step 4: Verify the revert** + +```bash +git diff --stat src/rulequery.cpp +``` + +Expected: no output. Then rerun the test: PASS, 21 tests. + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_rulequery.cpp +git commit -S -m "test(rulequery): round-trip the real rule shapes" +``` + +--- + +### Task 8: Builder widgets in the dialog + +**Files:** +- Modify: `src/tagrulesdialog.h`, `src/tagrulesdialog.cpp` + +No test in this task: it is widget construction, and the behaviour it enables is +tested in Tasks 9 and 10. + +- [ ] **Step 1: Declare the new members** + +In `src/tagrulesdialog.h`, add `#include "rulequery.h"` beside the existing +`#include "tagrules.h"`, add `class QComboBox;` and `class QRadioButton;` and +`class QVBoxLayout;` to the forward declarations, then add to the private +section: + +```cpp + /// One builder row's widgets, so a row can be removed as a unit. + struct Row + { + QWidget *container = nullptr; + QComboBox *field = nullptr; + QComboBox *op = nullptr; + QLineEdit *value = nullptr; + QComboBox *folder = nullptr; ///< Shown instead of value for Folder. + }; + + void rebuildRows(const RuleQuery &query); + Row *addRow(bool exclusion); + void removeRow(bool exclusion, int index); + RuleQuery currentQueryFromRows() const; + void syncQueryLine(); + void setTextMode(bool on); + + QList<Row> m_rows; + QList<Row> m_exclusionRows; + + /// The query as parsed when the current rule was loaded. Save compares + /// against this: equal means the stored string is written back untouched, + /// so opening a rule and closing it cannot rewrite the shared file. A + /// dirty flag cannot do this job, because Qt emits the widgets' changed + /// signals during programmatic population too. + RuleQuery m_loadedQuery; + + QRadioButton *m_matchAll = nullptr; + QRadioButton *m_matchAny = nullptr; + QCheckBox *m_textMode = nullptr; + QWidget *m_builder = nullptr; + QVBoxLayout *m_rowsLayout = nullptr; + QVBoxLayout *m_exclusionsLayout = nullptr; + QLabel *m_exclusionsHeader = nullptr; + QPushButton *m_addExclusion = nullptr; +``` + +- [ ] **Step 2: Build the builder section** + +In `src/tagrulesdialog.cpp`, add the includes `<QComboBox>`, `<QRadioButton>` +and `<QButtonGroup>` alongside the existing ones. + +Replace the `form->addRow(tr("Query"), m_query);` line (currently +`src/tagrulesdialog.cpp:107`) with the builder, keeping `m_query` as the +query line below it: + +```cpp + m_builder = new QWidget(this); + auto *builderLayout = new QVBoxLayout(m_builder); + builderLayout->setContentsMargins(0, 0, 0, 0); + + auto *matchRow = new QHBoxLayout; + m_matchAll = new QRadioButton(tr("Match &all"), m_builder); + m_matchAny = new QRadioButton(tr("Match a&ny"), m_builder); + m_matchAll->setChecked(true); + auto *matchGroup = new QButtonGroup(this); + matchGroup->addButton(m_matchAll); + matchGroup->addButton(m_matchAny); + m_textMode = new QCheckBox(tr("Edit as &text"), m_builder); + m_textMode->setToolTip( + tr("Edit the notmuch query directly. A rule too complex to show as " + "rows opens this way.")); + matchRow->addWidget(m_matchAll); + matchRow->addWidget(m_matchAny); + matchRow->addStretch(); + matchRow->addWidget(m_textMode); + builderLayout->addLayout(matchRow); + + m_rowsLayout = new QVBoxLayout; + builderLayout->addLayout(m_rowsLayout); + + m_exclusionsHeader = new QLabel(tr("But not"), m_builder); + builderLayout->addWidget(m_exclusionsHeader); + m_exclusionsLayout = new QVBoxLayout; + builderLayout->addLayout(m_exclusionsLayout); + + m_addExclusion = new QPushButton(tr("Add e&xclusion"), m_builder); + builderLayout->addWidget(m_addExclusion, 0, Qt::AlignLeft); + + form->addRow(tr("Match"), m_builder); + form->addRow(tr("Query"), m_query); + + // The query line shows what the rows compile to. Read-only in builder + // mode: it is what actually ships to the hook, and watching it change is + // what makes the rows trustworthy. + m_query->setReadOnly(true); + + connect(m_matchAll, &QRadioButton::toggled, + this, &TagRulesDialog::syncQueryLine); + connect(m_addExclusion, &QPushButton::clicked, this, [this] { + addRow(true); + syncQueryLine(); + }); + connect(m_textMode, &QCheckBox::toggled, + this, &TagRulesDialog::setTextMode); +``` + +- [ ] **Step 3: Implement the row helpers** + +Add to `src/tagrulesdialog.cpp`. The field and operator labels are prose and +translated; the notmuch prefixes they map to are not. + +```cpp +namespace { + +struct FieldEntry { RuleTerm::Field field; const char *label; }; + +const FieldEntry kFields[] = { + {RuleTerm::From, QT_TR_NOOP("From")}, + {RuleTerm::To, QT_TR_NOOP("To")}, + {RuleTerm::Cc, QT_TR_NOOP("Cc")}, + {RuleTerm::Subject, QT_TR_NOOP("Subject")}, + {RuleTerm::Tag, QT_TR_NOOP("Tag")}, + {RuleTerm::Folder, QT_TR_NOOP("Folder")}, + {RuleTerm::Attachment, QT_TR_NOOP("Attachment")}, + {RuleTerm::Date, QT_TR_NOOP("Date")}, +}; + +} // namespace + +TagRulesDialog::Row *TagRulesDialog::addRow(bool exclusion) +{ + Row row; + row.container = new QWidget(m_builder); + auto *layout = new QHBoxLayout(row.container); + layout->setContentsMargins(0, 0, 0, 0); + + row.field = new QComboBox(row.container); + for (const FieldEntry &entry : kFields) + row.field->addItem(tr(entry.label), int(entry.field)); + + row.op = new QComboBox(row.container); + row.value = new QLineEdit(row.container); + + auto *plus = new QPushButton(QStringLiteral("+"), row.container); + auto *minus = new QPushButton(QStringLiteral("-"), row.container); + plus->setFixedWidth(30); + minus->setFixedWidth(30); + + layout->addWidget(row.field); + layout->addWidget(row.op); + layout->addWidget(row.value, 1); + layout->addWidget(plus); + layout->addWidget(minus); + + QList<Row> &rows = exclusion ? m_exclusionRows : m_rows; + QVBoxLayout *target = exclusion ? m_exclusionsLayout : m_rowsLayout; + rows.append(row); + target->addWidget(row.container); + + auto refreshOps = [this, exclusion] { syncQueryLine(); }; + connect(row.field, &QComboBox::currentIndexChanged, this, + [this, exclusion, refreshOps](int) { + populateOperators(exclusion); + refreshOps(); + }); + connect(row.op, &QComboBox::currentIndexChanged, + this, [this](int) { syncQueryLine(); }); + connect(row.value, &QLineEdit::textEdited, + this, [this](const QString &) { syncQueryLine(); }); + connect(plus, &QPushButton::clicked, this, [this, exclusion] { + addRow(exclusion); + syncQueryLine(); + }); + connect(minus, &QPushButton::clicked, this, [this, exclusion, row] { + const QList<Row> &list = exclusion ? m_exclusionRows : m_rows; + for (int i = 0; i < list.size(); ++i) { + if (list.at(i).container == row.container) { + removeRow(exclusion, i); + break; + } + } + syncQueryLine(); + }); + + populateOperators(exclusion); + return &rows.last(); +} + +void TagRulesDialog::removeRow(bool exclusion, int index) +{ + QList<Row> &rows = exclusion ? m_exclusionRows : m_rows; + if (index < 0 || index >= rows.size()) + return; + + // The positive section keeps at least one row: a rule with no rows has an + // empty query, which is reachable by clearing the value rather than by + // deleting the last row out from under the user. + if (!exclusion && rows.size() == 1) { + rows[0].value->clear(); + return; + } + + delete rows.at(index).container; + rows.removeAt(index); + updateExclusionsVisibility(); +} + +void TagRulesDialog::updateExclusionsVisibility() +{ + // Sixteen of seventeen shipped rules have no exclusions, so an empty + // block on every rule is noise. + const bool any = !m_exclusionRows.isEmpty(); + m_exclusionsHeader->setVisible(any); +} +``` + +Declare `populateOperators(bool exclusion)` and +`updateExclusionsVisibility()` in the header beside the other helpers, and +implement `populateOperators` so each row's operator list matches its field: + +```cpp +void TagRulesDialog::populateOperators(bool exclusion) +{ + const QList<Row> &rows = exclusion ? m_exclusionRows : m_rows; + for (const Row &row : rows) { + const auto field = + RuleTerm::Field(row.field->currentData().toInt()); + const QString had = row.op->currentText(); + QSignalBlocker block(row.op); + row.op->clear(); + + switch (field) { + case RuleTerm::Tag: + case RuleTerm::Folder: + row.op->addItem(tr("is"), int(RuleTerm::Is)); + row.op->addItem(tr("is not"), int(RuleTerm::IsNot)); + break; + case RuleTerm::Attachment: + row.op->addItem(tr("has"), int(RuleTerm::Has)); + row.op->addItem(tr("has not"), int(RuleTerm::HasNot)); + break; + case RuleTerm::Date: + row.op->addItem(tr("before"), int(RuleTerm::Before)); + row.op->addItem(tr("after"), int(RuleTerm::After)); + break; + default: + row.op->addItem(tr("contains"), int(RuleTerm::Contains)); + row.op->addItem(tr("contains not"), int(RuleTerm::ContainsNot)); + row.op->addItem(tr("is"), int(RuleTerm::Is)); + row.op->addItem(tr("is not"), int(RuleTerm::IsNot)); + break; + } + + const int restored = row.op->findText(had); + if (restored >= 0) + row.op->setCurrentIndex(restored); + } +} +``` + +- [ ] **Step 4: Build** + +```bash +cmake --build build +``` + +Expected: compiles clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/tagrulesdialog.h src/tagrulesdialog.cpp +git commit -S -m "feat(rules): add the builder row widgets" +``` + +--- + +### Task 9: Load rows from a rule, and keep the query line in step + +**Files:** +- Modify: `src/tagrulesdialog.cpp` +- Test: `tests/test_tagrules.cpp` + +- [ ] **Step 1: Write the failing test** + +`test_tagrules.cpp` currently tests `TagRules` only and includes no widget. Add +`#include "tagrulesdialog.h"` and a slot: + +```cpp + void openingARuleFillsTheBuilderRows(); +``` + +```cpp +void TestTagRules::openingARuleFillsTheBuilderRows() +{ + const QString path = writeRules(R"({ + "version": 1, + "rules": [ + {"id": "vendor", "query": "from:vendor.example.org and subject:receipt", + "add": ["vendor"], "stage": 50, "enabled": true} + ] + })"); + + // The dialog reads the shared store from its default path, so point the + // whole process at the temporary one for the length of the test. + qputenv("XDG_CONFIG_HOME", m_dir.path().toUtf8()); + QDir().mkpath(m_dir.filePath(QStringLiteral("mailrules"))); + QFile::copy(path, m_dir.filePath(QStringLiteral("mailrules/rules.json"))); + + TagRulesDialog dialog; + QCOMPARE(dialog.rowCountForTest(), 2); + QCOMPARE(dialog.queryLineForTest(), + QStringLiteral("from:vendor.example.org and subject:receipt")); +} +``` + +Add the two test accessors to `src/tagrulesdialog.h`, in the public section: + +```cpp + /// Test seams. The builder's state is otherwise reachable only through + /// synthetic clicks on widgets whose geometry the offscreen platform does + /// not guarantee. + int rowCountForTest() const { return m_rows.size(); } + QString queryLineForTest() const; +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_tagrules +``` + +Expected: FAIL, row count is 0. + +- [ ] **Step 3: Implement loading and syncing** + +```cpp +QString TagRulesDialog::queryLineForTest() const +{ + return m_query->text(); +} + +void TagRulesDialog::rebuildRows(const RuleQuery &query) +{ + while (!m_rows.isEmpty()) { + delete m_rows.takeLast().container; + } + while (!m_exclusionRows.isEmpty()) { + delete m_exclusionRows.takeLast().container; + } + + m_matchAll->setChecked(query.join == RuleQuery::All); + m_matchAny->setChecked(query.join == RuleQuery::Any); + + for (const RuleTerm &term : query.terms) + applyTermToRow(addRow(false), term); + for (const RuleTerm &term : query.exclusions) + applyTermToRow(addRow(true), term); + + if (m_rows.isEmpty()) + addRow(false); // Always one empty row to type into. + + updateExclusionsVisibility(); +} + +void TagRulesDialog::applyTermToRow(Row *row, const RuleTerm &term) +{ + QSignalBlocker blockField(row->field); + QSignalBlocker blockOp(row->op); + + const int fieldIndex = row->field->findData(int(term.field)); + if (fieldIndex >= 0) + row->field->setCurrentIndex(fieldIndex); + populateOperators(false); + populateOperators(true); + + const int opIndex = row->op->findData(int(term.op)); + if (opIndex >= 0) + row->op->setCurrentIndex(opIndex); + + row->value->setText(term.value); +} + +RuleQuery TagRulesDialog::currentQueryFromRows() const +{ + RuleQuery query; + query.parsed = true; + query.join = m_matchAny->isChecked() ? RuleQuery::Any : RuleQuery::All; + + for (const Row &row : m_rows) { + if (row.value->text().trimmed().isEmpty()) + continue; + query.terms.append({RuleTerm::Field(row.field->currentData().toInt()), + RuleTerm::Op(row.op->currentData().toInt()), + row.value->text().trimmed()}); + } + for (const Row &row : m_exclusionRows) { + if (row.value->text().trimmed().isEmpty()) + continue; + query.exclusions.append( + {RuleTerm::Field(row.field->currentData().toInt()), + RuleTerm::Op(row.op->currentData().toInt()), + row.value->text().trimmed()}); + } + return query; +} + +void TagRulesDialog::syncQueryLine() +{ + if (m_textMode->isChecked()) + return; // The line is the source of truth in text mode. + m_query->setText(currentQueryFromRows().compile()); + applyEditsToCurrentRule(); +} +``` + +Declare `applyTermToRow(Row *, const RuleTerm &)` in the header. + +In `onSelectionChanged()`, after `m_query->setText(rule.query);`, add: + +```cpp + // Parse once, on load, and keep it: Save compares against this to decide + // whether the stored string may be left alone. + m_loadedQuery = RuleQuery::parse(rule.query); + + const QSignalBlocker blockTextMode(m_textMode); + m_textMode->setChecked(!m_loadedQuery.parsed); + m_builder->setVisible(m_loadedQuery.parsed); + m_query->setReadOnly(m_loadedQuery.parsed); + + if (m_loadedQuery.parsed) + rebuildRows(m_loadedQuery); +``` + +- [ ] **Step 4: Run to verify it passes** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_tagrules +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/tagrulesdialog.h src/tagrulesdialog.cpp tests/test_tagrules.cpp +git commit -S -m "feat(rules): load a rule into the builder rows" +``` + +--- + +### Task 10: Text mode, and never rewriting an untouched rule + +**Files:** +- Modify: `src/tagrulesdialog.cpp` +- Test: `tests/test_tagrules.cpp` + +The byte-identical test here is the guarantee the whole design was shaped +around. Write it first. + +- [ ] **Step 1: Write the failing tests** + +```cpp + void openingARuleWithoutEditingLeavesItByteIdentical(); + void anUnrepresentableRuleOpensInTextMode(); +``` + +```cpp +void TestTagRules::openingARuleWithoutEditingLeavesItByteIdentical() +{ + // Compiling on open would rewrite the shared file for no reason, and + // mailctl would see a diff the user never made. + const QString original = + QStringLiteral("(from:vendor.example.org or from:vendor.example.net) " + "and not subject:receipt"); + + const QString path = writeRules(QStringLiteral(R"({ + "version": 1, + "rules": [ + {"id": "vendor", "query": "%1", "add": ["vendor"], + "stage": 50, "enabled": true} + ] + })").arg(QString(original).replace(QLatin1Char('"'), + QStringLiteral("\\\"")))); + + TagRules rules; + rules.load(path); + QCOMPARE(rules.rules().size(), 1); + + const RuleQuery parsed = RuleQuery::parse(rules.rules().first().query); + QVERIFY(parsed.parsed); + + // Nothing edited: the stored string must survive untouched. + QCOMPARE(rules.rules().first().query, original); + QCOMPARE(parsed.compile(), original); +} + +void TestTagRules::anUnrepresentableRuleOpensInTextMode() +{ + const RuleQuery q = RuleQuery::parse(QStringLiteral("body:receipt")); + QVERIFY(!q.parsed); +} +``` + +- [ ] **Step 2: Run** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_tagrules +``` + +Expected: PASS if Tasks 1-7 are correct. If the first fails, the compile is +not byte-faithful and the bug is in `compile()`, not here. + +- [ ] **Step 3: Implement text mode and the save comparison** + +```cpp +void TagRulesDialog::setTextMode(bool on) +{ + if (on) { + // Show what the rows currently mean, then hand the string over. + if (!m_rows.isEmpty()) + m_query->setText(currentQueryFromRows().compile()); + m_builder->setVisible(false); + m_query->setReadOnly(false); + return; + } + + // Going back needs the typed query to be representable. If it is not, the + // checkbox cannot clear: there are no rows that mean this query. + const RuleQuery parsed = RuleQuery::parse(m_query->text().trimmed()); + if (!parsed.parsed) { + const QSignalBlocker block(m_textMode); + m_textMode->setChecked(true); + QMessageBox::information( + this, tr("Cannot show as rows"), + tr("This query is more than the builder can show, so it stays " + "as text. It is still saved and applied normally.")); + return; + } + + rebuildRows(parsed); + m_builder->setVisible(true); + m_query->setReadOnly(true); +} +``` + +In `applyEditsToCurrentRule()`, replace `rule.query = m_query->text().trimmed();` +with: + +```cpp + if (m_textMode->isChecked()) { + rule.query = m_query->text().trimmed(); + } else { + const RuleQuery current = currentQueryFromRows(); + // Unchanged rows mean the stored string is left exactly as it was + // read. Recompiling an untouched rule would churn the shared file. + if (!(current == m_loadedQuery)) + rule.query = current.compile(); + } +``` + +Add `#include <QMessageBox>` if it is not already present. + +- [ ] **Step 4: Run the whole suite** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure +``` + +Expected: all pass, now 20 binaries. + +- [ ] **Step 5: Commit** + +```bash +git add src/tagrulesdialog.cpp tests/test_tagrules.cpp +git commit -S -m "feat(rules): text mode, and leave untouched rules unwritten" +``` + +--- + +### Task 11: The folder dropdown + +**Files:** +- Modify: `src/tagrulesdialog.h`, `src/tagrulesdialog.cpp` + +A `path:` typo matches nothing and notmuch reports nothing, so this is the +field where a dropdown earns its keep. + +- [ ] **Step 1: Accept the account list** + +Add to the header, in the public section: + +```cpp + /// Account subdirectory names, for the Folder row's dropdown. Supplied by + /// the caller rather than read here: Config knows them and this dialog + /// deliberately holds no Config of its own. + void setFolders(const QStringList &folders); +``` + +and `QStringList m_folders;` to the private section. + +- [ ] **Step 2: Implement** + +```cpp +void TagRulesDialog::setFolders(const QStringList &folders) +{ + m_folders = folders; + populateOperators(false); + populateOperators(true); +} +``` + +In `addRow`, after creating `row.value`, add the folder combo: + +```cpp + row.folder = new QComboBox(row.container); + row.folder->setEditable(true); // A folder in the file but not in the + // config must still display and save. + row.folder->addItems(m_folders); + row.folder->setVisible(false); + layout->addWidget(row.folder, 1); +``` + +and in the field-change lambda, swap which widget is shown: + +```cpp + const bool isFolder = + RuleTerm::Field(row.field->currentData().toInt()) + == RuleTerm::Folder; + row.value->setVisible(!isFolder); + row.folder->setVisible(isFolder); +``` + +In `currentQueryFromRows()` and `applyTermToRow()`, read and write +`row.folder->currentText()` when the field is `Folder`, and `row.value->text()` +otherwise. Factor that into two small helpers so the two call sites cannot +disagree: + +```cpp +QString TagRulesDialog::rowValue(const Row &row) const +{ + const bool isFolder = + RuleTerm::Field(row.field->currentData().toInt()) == RuleTerm::Folder; + return (isFolder ? row.folder->currentText() : row.value->text()).trimmed(); +} + +void TagRulesDialog::setRowValue(Row *row, const QString &value) +{ + if (RuleTerm::Field(row->field->currentData().toInt()) == RuleTerm::Folder) + row->folder->setCurrentText(value); + else + row->value->setText(value); +} +``` + +Declare both in the header, then replace the three direct accesses: + +In `currentQueryFromRows()`, both loops become: + +```cpp + for (const Row &row : m_rows) { + const QString value = rowValue(row); + if (value.isEmpty()) + continue; + query.terms.append({RuleTerm::Field(row.field->currentData().toInt()), + RuleTerm::Op(row.op->currentData().toInt()), + value}); + } + for (const Row &row : m_exclusionRows) { + const QString value = rowValue(row); + if (value.isEmpty()) + continue; + query.exclusions.append( + {RuleTerm::Field(row.field->currentData().toInt()), + RuleTerm::Op(row.op->currentData().toInt()), + value}); + } +``` + +In `applyTermToRow()`, the last line becomes: + +```cpp + setRowValue(row, term.value); +``` + +and in `removeRow()`, the single-row clear becomes: + +```cpp + if (!exclusion && rows.size() == 1) { + setRowValue(&rows[0], QString()); + return; + } +``` + +In `addRow()`, the `textEdited` connection gains a twin for the combo, or a +folder edit never reaches the query line: + +```cpp + connect(row.folder, &QComboBox::currentTextChanged, + this, [this](const QString &) { syncQueryLine(); }); +``` + +- [ ] **Step 3: Supply the folders from MainWindow** + +Find where `TagRulesDialog` is constructed in `src/mainwindow.cpp`: + +```bash +grep -n "TagRulesDialog" src/mainwindow.cpp +``` + +After construction, add: + +```cpp + m_tagRulesDialog->setFolders(m_config.accountNames()); +``` + +If `Config` exposes the account subdirectories under a different name, use +that; check with: + +```bash +grep -n "account" src/config.h +``` + +- [ ] **Step 4: Build and run the suite** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/tagrulesdialog.h src/tagrulesdialog.cpp src/mainwindow.cpp +git commit -S -m "feat(rules): a folder dropdown, so the path suffix is never typed" +``` + +--- + +### Task 12: Changelog, and hand back for a look + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Add the entry** + +Under `## [Unreleased]`, in an `### Added` section (create it if absent): + +```markdown +- The tagging rules dialog builds a rule from rows now: a field and operator + dropdown per condition, `+`/`-` to add and remove them, a match all/any + choice, and a separate "but not" block for exclusions. The notmuch query + stays visible and is what gets saved, so a rule the builder cannot show + opens as text and still works. Opening a rule without editing it leaves the + stored query untouched. +``` + +- [ ] **Step 2: Full suite** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure +``` + +Expected: all pass, 20 binaries. + +- [ ] **Step 3: Commit** + +```bash +git add CHANGELOG.md +git commit -S -m "docs: changelog for the rule builder" +``` + +- [ ] **Step 4: Hand back, do not release** + +Report to the user: the suite result, and that the dialog wants a hand test. +Say what to look for: + +- Open Tagging rules, pick a rule with an `or` list. The rows should show each + sender, "Match any" selected, and the query line unchanged from what was + stored. +- Pick the rule with exclusions. The "But not" block should hold them. +- Open a rule, change nothing, Save. The rules file should be unchanged: + `git diff` in the config directory, or compare a copy taken beforehand. +- Tick "Edit as text", type something the builder cannot show + (`body:receipt`), untick it. The checkbox should refuse and explain. + +**Do not run the application yourself.** Running it is the user's hand test. + +--- + +## Notes for the implementer + +**The parser is strict on purpose.** If a real rule fails to parse and the fix +looks like "accept a bit more", check first whether accepting it can drop a +term. A half-parse that loses a `not` widens a live mail filter silently. Text +mode is the correct outcome for anything the grammar does not cover. + +**Do not add fields to `TagRule` or keys to `rules.json`.** The format is shared +with `mailctl`, which has its own reader; a field added on one side is dropped +by the other's next save. This plan deliberately needs no format change. If a +task seems to want one, re-read +`docs/superpowers/specs/2026-08-13-rule-builder-design.md` and +"Changing the shared rule format" in `CLAUDE.md`. + +**`m_ruleCountGeneration` stays separate from `m_generation`.** Count matches +already works; do not route it through the query generation, which discards a +thread load in flight and blanks the message pane. + +**Tag completion on the add and remove fields is NOT in this plan.** It is the +other half of backlog item 76, independent of the builder, and the spec says so. +It reuses `QueryCompleter` (`src/querycompleter.h:90`) and carries its own trap: +a multi-value field must not use `QLineEdit::setCompleter`, because the line +edit overwrites the completer's prefix with the widget's whole text, so the +first tag completes and nothing after it does. Attach with +`QCompleter::setWidget` and drive the prefix by hand, and type the keys in the +test rather than calling `setText()`, which does not drive a completer at all. |
