diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-14 17:58:22 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-14 17:58:22 +0200 |
| commit | 7781f437a05b4e860353c012cb2f31860fd48cf6 (patch) | |
| tree | 57b1bf1d6006127bf02bb41f40ae3ff2afe16125 | |
| parent | db770e8d77a1b2482249e3315a227b9b017ab3f1 (diff) | |
| download | qtmaildir-7781f437a05b4e860353c012cb2f31860fd48cf6.tar.gz qtmaildir-7781f437a05b4e860353c012cb2f31860fd48cf6.zip | |
feat(search): add SearchTerm::exclude
Parenthesises both sides, as extend() does: unparenthesised, a
disjunction in the query bar binds so the exclusion covers only its last
term and leaves the excluded mail on screen, with nothing reporting an
error.
An empty existing query returns empty rather than the addition alone,
which is where this deliberately differs from extend(). Excluding from
nothing means the whole Maildir minus one value; the menus will grey the
entry out and this is the second layer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | src/searchterm.cpp | 15 | ||||
| -rw-r--r-- | src/searchterm.h | 18 | ||||
| -rw-r--r-- | tests/test_searchterm.cpp | 39 |
3 files changed, 72 insertions, 0 deletions
diff --git a/src/searchterm.cpp b/src/searchterm.cpp index acc64c6..6bfb521 100644 --- a/src/searchterm.cpp +++ b/src/searchterm.cpp @@ -90,4 +90,19 @@ QString extend(const QString &existing, const QString &addition) return QStringLiteral("(%1) AND (%2)").arg(left, right); } +QString exclude(const QString &existing, const QString &addition) +{ + const QString left = existing.trimmed(); + const QString right = addition.trimmed(); + + if (right.isEmpty()) + return left; + // NOT a replace, unlike extend(): see the header. An empty left would make + // this "everything except", which no right-click asked for. + if (left.isEmpty()) + return QString(); + + return QStringLiteral("(%1) AND NOT (%2)").arg(left, right); +} + } // namespace SearchTerm diff --git a/src/searchterm.h b/src/searchterm.h index d596676..646fd77 100644 --- a/src/searchterm.h +++ b/src/searchterm.h @@ -76,6 +76,24 @@ QString tag(const QString &name); /// matches nothing; an empty `addition` leaves `existing` untouched. QString extend(const QString &existing, const QString &addition); +/// Narrows `existing` by everything that is NOT `addition`, as +/// `(existing) AND NOT (addition)`. +/// +/// **Both sides are parenthesised, for the same load-bearing reason as +/// extend().** The query bar may hold a hand-written disjunction, and +/// `a or b AND NOT c` binds as `a or (b AND NOT c)`: the exclusion would cover +/// only the second term, leaving on screen exactly the mail the user asked to +/// be rid of, with no error reported anywhere. +/// +/// **An empty `existing` yields an EMPTY STRING, unlike extend().** Excluding +/// from nothing would mean the entire Maildir minus one value: a legitimate +/// query, and an implausible thing to have meant by right-clicking a value in +/// a fresh window. The menus grey the entry out when the query bar is empty; +/// this is the second layer, against a caller that forgets the guard. +/// +/// An empty `addition` leaves `existing` untouched. +QString exclude(const QString &existing, const QString &addition); + } // namespace SearchTerm /// One entry a context menu can offer: a finished query and the text naming it. diff --git a/tests/test_searchterm.cpp b/tests/test_searchterm.cpp index e0bdb60..53185f3 100644 --- a/tests/test_searchterm.cpp +++ b/tests/test_searchterm.cpp @@ -41,6 +41,9 @@ private slots: void tagIsNotQuoted(); void extendParenthesisesBothSides(); void extendOntoAnEmptyQueryIsAReplace(); + void excludeParenthesisesBothSides(); + void excludeFromAnEmptyQueryIsEmpty(); + void excludeWithNothingToExcludeLeavesTheQuery(); }; void TestSearchTerm::quotesAPlainValue() @@ -142,5 +145,41 @@ void TestSearchTerm::extendOntoAnEmptyQueryIsAReplace() QStringLiteral("tag:inbox")); } +void TestSearchTerm::excludeParenthesisesBothSides() +{ + // The same trap as extendParenthesisesBothSides, and worse in this + // direction. Unparenthesised, `a or b AND NOT c` binds as + // `a or (b AND NOT c)`: the exclusion covers only the second term, so + // every message matching `a` stays on screen INCLUDING the ones the user + // asked to be rid of. notmuch reports no error for either form, so this + // assertion is the only thing that fails. + QCOMPARE(SearchTerm::exclude(QStringLiteral("tag:inbox or tag:flagged"), + QStringLiteral("from:foo@example.org")), + QStringLiteral( + "(tag:inbox or tag:flagged) AND NOT (from:foo@example.org)")); +} + +void TestSearchTerm::excludeFromAnEmptyQueryIsEmpty() +{ + // Deliberately NOT extend()'s behaviour. extend() returns the addition + // alone, because narrowing nothing by x sensibly means x. Excluding from + // nothing would mean the whole Maildir minus one value: a legitimate + // query, and an implausible thing to have meant by right-clicking a value + // in a fresh window. The menus grey the entry out; this is the second + // layer, against a caller that forgets the guard. + QVERIFY(SearchTerm::exclude(QString(), QStringLiteral("tag:inbox")) + .isEmpty()); + QVERIFY(SearchTerm::exclude(QStringLiteral(" "), + QStringLiteral("tag:inbox")) + .isEmpty()); +} + +void TestSearchTerm::excludeWithNothingToExcludeLeavesTheQuery() +{ + QCOMPARE(SearchTerm::exclude(QStringLiteral("tag:inbox"), QString()), + QStringLiteral("tag:inbox")); + QVERIFY(SearchTerm::exclude(QString(), QString()).isEmpty()); +} + QTEST_MAIN(TestSearchTerm) #include "test_searchterm.moc" |
