From d57d922165fa17d33e6360fec5621cb5b30170dc Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 13 Aug 2026 11:05:39 +0200 Subject: feat(rulequery): join terms and guard the or-group binding --- src/rulequery.cpp | 25 ++++++++++++++- tests/test_rulequery.cpp | 80 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/rulequery.cpp b/src/rulequery.cpp index 9b79b2b..ac8bf18 100644 --- a/src/rulequery.cpp +++ b/src/rulequery.cpp @@ -18,6 +18,8 @@ #include "rulequery.h" +#include + namespace { /// The notmuch prefix each field compiles to. Wire format, never translated. @@ -105,7 +107,28 @@ QString RuleQuery::compile() const if (terms.isEmpty()) return QString(); - return compileTerm(terms.first()); + 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) { + // The block IS the negation, so its rows are stored un-negated and + // the `and not` is applied here. A row stored negated would compile + // to `and not not subject:x`. + out += QStringLiteral(" and not ") + compileTerm(term); + } + + return out; } RuleQuery RuleQuery::parse(const QString &query) diff --git a/tests/test_rulequery.cpp b/tests/test_rulequery.cpp index 970dbac..2eb34ed 100644 --- a/tests/test_rulequery.cpp +++ b/tests/test_rulequery.cpp @@ -36,6 +36,11 @@ private slots: void aValueWithASpaceIsAlwaysQuoted(); void folderAppendsTheRecursiveSuffix(); void dateCompilesToAOneSidedRange(); + void allJoinsWithAnd(); + void anyJoinsWithOr(); + void exclusionsAppendAsAndNot(); + void anyIsParenthesisedOnlyWhenExclusionsFollow(); + void anEmptyQueryCompilesToAnEmptyString(); }; void TestRuleQuery::aSingleContainsTermCompiles() @@ -127,5 +132,80 @@ void TestRuleQuery::dateCompilesToAOneSidedRange() 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()); +} + QTEST_MAIN(TestRuleQuery) #include "test_rulequery.moc" -- cgit v1.2.3