summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-13 11:05:39 +0200
committerDanilo M. <danix@danix.xyz>2026-08-13 11:05:39 +0200
commitd57d922165fa17d33e6360fec5621cb5b30170dc (patch)
tree155a65704bf9ea238947aa96afb9fdf0ac383619
parentfe5703419f2ac2a5e619b3d527530a71a9a9499e (diff)
downloadqtmaildir-d57d922165fa17d33e6360fec5621cb5b30170dc.tar.gz
qtmaildir-d57d922165fa17d33e6360fec5621cb5b30170dc.zip
feat(rulequery): join terms and guard the or-group binding
-rw-r--r--src/rulequery.cpp25
-rw-r--r--tests/test_rulequery.cpp80
2 files changed, 104 insertions, 1 deletions
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 <QStringList>
+
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"