diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-13 11:02:32 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-13 11:02:32 +0200 |
| commit | 043b150f7651c598dac8fc02d7068b940d0f8741 (patch) | |
| tree | 943acdc0a0c8891a150dd02af6299b28d31499e5 | |
| parent | a3651eac1528a2b90cfb8e26934facf856ae3531 (diff) | |
| download | qtmaildir-043b150f7651c598dac8fc02d7068b940d0f8741.tar.gz qtmaildir-043b150f7651c598dac8fc02d7068b940d0f8741.zip | |
feat(rulequery): compile every field and operator
| -rw-r--r-- | src/rulequery.cpp | 51 | ||||
| -rw-r--r-- | tests/test_rulequery.cpp | 86 |
2 files changed, 135 insertions, 2 deletions
diff --git a/src/rulequery.cpp b/src/rulequery.cpp index f80e6c4..198c583 100644 --- a/src/rulequery.cpp +++ b/src/rulequery.cpp @@ -36,6 +36,54 @@ QString prefixFor(RuleTerm::Field field) return QString(); } +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.value.contains(QLatin1Char(' '))) + return true; + // Is/IsNot asks for an exact phrase, which only free-text fields need + // quoting for: tag: and attachment: values are already bare words, and + // quoting them changes nothing notmuch cares about but breaks the test's + // documented expectation of an unquoted tag. + if (term.op == RuleTerm::Is || term.op == RuleTerm::IsNot) { + return term.field == RuleTerm::From || term.field == RuleTerm::To + || term.field == RuleTerm::Cc || term.field == RuleTerm::Subject; + } + return false; +} + +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; +} + } // namespace bool operator==(const RuleTerm &a, const RuleTerm &b) @@ -54,8 +102,7 @@ QString RuleQuery::compile() const if (terms.isEmpty()) return QString(); - return prefixFor(terms.first().field) + QLatin1Char(':') - + terms.first().value; + return compileTerm(terms.first()); } RuleQuery RuleQuery::parse(const QString &query) diff --git a/tests/test_rulequery.cpp b/tests/test_rulequery.cpp index 3e49eac..970dbac 100644 --- a/tests/test_rulequery.cpp +++ b/tests/test_rulequery.cpp @@ -30,6 +30,12 @@ class TestRuleQuery : public QObject private slots: void aSingleContainsTermCompiles(); + void everyFieldCompilesToItsPrefix(); + void isQuotesAndContainsDoesNot(); + void negationPrefixesNot(); + void aValueWithASpaceIsAlwaysQuoted(); + void folderAppendsTheRecursiveSuffix(); + void dateCompilesToAOneSidedRange(); }; void TestRuleQuery::aSingleContainsTermCompiles() @@ -41,5 +47,85 @@ void TestRuleQuery::aSingleContainsTermCompiles() 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..")); +} + QTEST_MAIN(TestRuleQuery) #include "test_rulequery.moc" |
