summaryrefslogtreecommitdiffstats
path: root/tests/test_rulequery.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-13 17:07:16 +0200
committerDanilo M. <danix@danix.xyz>2026-08-13 17:07:16 +0200
commit4d84062e48ec35598b7c2ecdf8446939be6064c4 (patch)
treeab280755094321666d29d50694c48722809d953c /tests/test_rulequery.cpp
parentdf6461fa9136f2110c8a24567a3e4a32432a9476 (diff)
parentb8de7ce746718c7193e40b0f70cd3e2178f4ed8a (diff)
downloadqtmaildir-4d84062e48ec35598b7c2ecdf8446939be6064c4.tar.gz
qtmaildir-4d84062e48ec35598b7c2ecdf8446939be6064c4.zip
Merge branch 'rule-builder': a row builder for the tagging rules
Item 76 replaces the four free-text fields with a row builder: field and operator dropdowns per condition, +/- to add and remove them, a match all/any choice, and a separate "but not" block. The stored format does not change, so mailctl needs no edit. The query string stays authoritative and remains visible, and a rule the builder cannot represent opens in a text mode every rule carries. Along the way, items 75, 77 and 80, and a data-loss defect released in 0.16.0 (item 79): opening the dialog and pressing Save destroyed the first rule with nothing edited. That one damaged a real rule in the user's own file, which was repaired by hand. Four defects in this work were found by hand rather than by the suite, and each is recorded where it was missed: a lost note, a one-way text mode toggle, a geometry save on a path neither button takes, and a rule list squeezed to one row by a long rule. Two Qt traps and one about the user's compositor went into CLAUDE.md.
Diffstat (limited to 'tests/test_rulequery.cpp')
-rw-r--r--tests/test_rulequery.cpp415
1 files changed, 415 insertions, 0 deletions
diff --git a/tests/test_rulequery.cpp b/tests/test_rulequery.cpp
new file mode 100644
index 0000000..54f9477
--- /dev/null
+++ b/tests/test_rulequery.cpp
@@ -0,0 +1,415 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#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 everyFieldCompilesToItsPrefix();
+ void isQuotesAndContainsDoesNot();
+ void negationPrefixesNot();
+ void aValueWithASpaceIsAlwaysQuoted();
+ void folderAppendsTheRecursiveSuffix();
+ void dateCompilesToAOneSidedRange();
+ void allJoinsWithAnd();
+ void anyJoinsWithOr();
+ void exclusionsAppendAsAndNot();
+ void anyIsParenthesisedOnlyWhenExclusionsFollow();
+ void anEmptyQueryCompilesToAnEmptyString();
+ void aFlatAndChainParses();
+ void aFlatOrChainParses();
+ void anEmptyQueryParsesToNoRows();
+ void quotedValuesLoseTheirQuotes();
+ void aNegatedTermParsesAsANegatedOperator();
+ void whatParsesCompilesBackUnchanged();
+ void anOrGroupWithExclusionsParses();
+ void anUnrepresentableQueryRejectsWhole();
+ void aParenBearingValueIsARowNotAShape();
+ void theRuleCorpusRoundTripsByteForByte();
+};
+
+void TestRuleQuery::aSingleContainsTermCompiles()
+{
+ RuleQuery q;
+ q.terms.append({RuleTerm::From, RuleTerm::Contains,
+ QStringLiteral("sender@example.org")});
+
+ QCOMPARE(q.compile(), QStringLiteral("from:sender@example.org"));
+}
+
+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.."));
+}
+
+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());
+}
+
+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);
+}
+
+void TestRuleQuery::whatParsesCompilesBackUnchanged()
+{
+ // The dialog decides whether to rewrite the stored string by comparing
+ // against what it parsed, so a compile that differs by so much as a quote
+ // would churn a file a second tool reads.
+ const QStringList queries = {
+ QStringLiteral("from:vendor.example.org"),
+ QStringLiteral("from:vendor.example.org and subject:receipt"),
+ QStringLiteral("from:one.example.org or from:two.example.org"),
+ QStringLiteral("subject:\"your receipt\""),
+ QStringLiteral("path:\"account-one/**\""),
+ QStringLiteral("tag:inbox"),
+ QStringLiteral("attachment:pdf"),
+ QStringLiteral("date:..2026-01-01"),
+ QStringLiteral("date:2026-01-01.."),
+ QStringLiteral("from:vendor.example.org and not tag:inbox"),
+ QStringLiteral("from:vendor.example.org and not subject:receipt "
+ "and not subject:refund"),
+ QStringLiteral("(from:one.example.org or from:two.example.org) "
+ "and not subject:receipt"),
+ };
+
+ for (const QString &query : queries) {
+ const RuleQuery parsed = RuleQuery::parse(query);
+ QVERIFY2(parsed.parsed, qPrintable(query));
+ QCOMPARE(parsed.compile(), query);
+ }
+}
+
+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"));
+}
+
+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"),
+ QStringLiteral("subject:\"unterminated"), // open quote
+ QStringLiteral("(from:a.example.org or from:b.example.org)"),
+ // group, nothing after it
+ QStringLiteral("(from:a.example.org and from:b.example.org) "
+ "and not subject:x"), // group joined by and
+ };
+
+ 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::aParenBearingValueIsARowNotAShape()
+{
+ // notmuch reads these parens as characters to search for, not as
+ // grouping, so the query is meaningful and matches nothing. It parses
+ // here as a From row holding that literal text, which is what it means.
+ // The property to hold is the round trip, NOT a rejection: a test
+ // expecting an error here fails against correct code.
+ const RuleQuery q = RuleQuery::parse(QStringLiteral("from:(((("));
+
+ QVERIFY(q.parsed);
+ QCOMPARE(q.terms.size(), 1);
+ QCOMPARE(q.terms.at(0).value, QStringLiteral("(((("));
+ QCOMPARE(q.compile(), QStringLiteral("from:(((("));
+}
+
+void TestRuleQuery::theRuleCorpusRoundTripsByteForByte()
+{
+ // Every query SHAPE present in a real rules file, with placeholder
+ // values. Verified by hand against the live file when this was written:
+ // all seventeen rules parsed and compiled back byte for byte, none fell
+ // to text mode.
+ //
+ // A compile that differs by so much as a paren would rewrite the shared
+ // file on the next save, which the companion tool then sees as a diff
+ // nobody made.
+ const QStringList corpus = {
+ QString(), // a rule with no query yet
+ 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));
+ }
+}
+
+QTEST_MAIN(TestRuleQuery)
+#include "test_rulequery.moc"