diff options
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/tagrules.cpp | 229 | ||||
| -rw-r--r-- | src/tagrules.h | 87 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_tagrules.cpp | 245 |
5 files changed, 563 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6f5101e..4df9adf 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -12,6 +12,7 @@ add_library(qtmaildir_lib STATIC tagchip.cpp tagcolors.cpp tagdialog.cpp + tagrules.cpp tagstrip.cpp threadlistmodel.cpp threadlistview.cpp diff --git a/src/tagrules.cpp b/src/tagrules.cpp new file mode 100644 index 0000000..39cc529 --- /dev/null +++ b/src/tagrules.cpp @@ -0,0 +1,229 @@ +/* + * 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 "tagrules.h" + +#include <QCoreApplication> +#include <QDir> +#include <QFile> +#include <QFileInfo> +#include <QJsonArray> +#include <QJsonDocument> +#include <QJsonParseError> +#include <QRegularExpression> +#include <QSaveFile> +#include <algorithm> + +namespace { + +constexpr int kFormatVersion = 1; +constexpr int kDefaultStage = 50; + +/// Fields this version understands. Anything else is preserved. +bool isKnownKey(const QString &key) +{ + static const QStringList known{ + QStringLiteral("id"), QStringLiteral("stage"), + QStringLiteral("enabled"), QStringLiteral("add"), + QStringLiteral("remove"), QStringLiteral("query"), + QStringLiteral("note"), + }; + return known.contains(key); +} + +QStringList stringsOf(const QJsonValue &value) +{ + QStringList out; + const QJsonArray array = value.toArray(); + for (const QJsonValue &entry : array) { + const QString text = entry.toString().trimmed(); + if (!text.isEmpty()) + out.append(text); + } + return out; +} + +} // namespace + +QString TagRules::defaultPath() +{ + // Not QStandardPaths::ConfigLocation: that appends the organization and + // application names, which would put the file under qtmaildir's own + // directory. mailctl reads this path too, so it must be tool-neutral. + QString base = qEnvironmentVariable("XDG_CONFIG_HOME"); + if (base.isEmpty()) + base = QDir::homePath() + QStringLiteral("/.config"); + return base + QStringLiteral("/mailrules/rules.json"); +} + +void TagRules::load(const QString &path) +{ + const QString target = path.isEmpty() ? defaultPath() : path; + + m_rules.clear(); + m_warnings.clear(); + m_unknown = QJsonObject(); + m_missing = false; + + QFile file(target); + if (!file.exists()) { + m_missing = true; + return; + } + + if (!file.open(QIODevice::ReadOnly)) { + m_warnings.append( + QObject::tr("Cannot read %1: %2").arg(target, file.errorString())); + return; + } + + QJsonParseError error{}; + const QJsonDocument document = + QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) { + m_warnings.append(QObject::tr("Cannot read %1: %2") + .arg(target, error.errorString())); + return; + } + + const QJsonObject root = document.object(); + + const int version = root.value(QStringLiteral("version")) + .toInt(kFormatVersion); + if (version != kFormatVersion) { + m_warnings.append( + QObject::tr("%1 uses format version %2, newer than this version " + "understands (%3); refusing to guess") + .arg(target).arg(version).arg(kFormatVersion)); + return; + } + + for (auto it = root.begin(); it != root.end(); ++it) { + if (it.key() != QStringLiteral("version") + && it.key() != QStringLiteral("rules")) { + m_unknown.insert(it.key(), it.value()); + } + } + + // An id is a handle: a UI selects on it and a diff tracks it. + static const QRegularExpression idPattern( + QStringLiteral("^[a-z0-9][a-z0-9-]*$")); + + QStringList seen; + const QJsonArray array = root.value(QStringLiteral("rules")).toArray(); + for (int index = 0; index < array.size(); ++index) { + const QJsonObject object = array.at(index).toObject(); + const QString where = QObject::tr("rule #%1").arg(index + 1); + + TagRule rule; + rule.id = object.value(QStringLiteral("id")).toString(); + if (!idPattern.match(rule.id).hasMatch()) { + m_warnings.append( + QObject::tr("%1: id '%2' is missing or not lowercase letters, " + "digits and dashes; dropped") + .arg(where, rule.id)); + continue; + } + + if (seen.contains(rule.id)) { + m_warnings.append(QObject::tr("Rule '%1': duplicate id; keeping " + "the first").arg(rule.id)); + continue; + } + + rule.query = object.value(QStringLiteral("query")).toString().trimmed(); + if (rule.query.isEmpty()) { + m_warnings.append( + QObject::tr("Rule '%1': no query; dropped").arg(rule.id)); + continue; + } + + rule.add = stringsOf(object.value(QStringLiteral("add"))); + rule.remove = stringsOf(object.value(QStringLiteral("remove"))); + if (rule.add.isEmpty() && rule.remove.isEmpty()) { + m_warnings.append(QObject::tr("Rule '%1': adds and removes " + "nothing; dropped").arg(rule.id)); + continue; + } + + rule.stage = object.value(QStringLiteral("stage")).toInt(kDefaultStage); + rule.enabled = object.value(QStringLiteral("enabled")).toBool(true); + rule.note = object.value(QStringLiteral("note")).toString(); + + for (auto it = object.begin(); it != object.end(); ++it) { + if (!isKnownKey(it.key())) + rule.unknown.insert(it.key(), it.value()); + } + + seen.append(rule.id); + m_rules.append(rule); + } +} + +bool TagRules::save(const QString &path) const +{ + const QString target = path.isEmpty() ? defaultPath() : path; + + QDir().mkpath(QFileInfo(target).absolutePath()); + + QJsonArray array; + for (const TagRule &rule : m_rules) { + QJsonObject object; + object.insert(QStringLiteral("id"), rule.id); + object.insert(QStringLiteral("stage"), rule.stage); + object.insert(QStringLiteral("enabled"), rule.enabled); + object.insert(QStringLiteral("add"), + QJsonArray::fromStringList(rule.add)); + object.insert(QStringLiteral("remove"), + QJsonArray::fromStringList(rule.remove)); + object.insert(QStringLiteral("query"), rule.query); + object.insert(QStringLiteral("note"), rule.note); + for (auto it = rule.unknown.begin(); it != rule.unknown.end(); ++it) + object.insert(it.key(), it.value()); + array.append(object); + } + + QJsonObject root = m_unknown; + root.insert(QStringLiteral("version"), kFormatVersion); + root.insert(QStringLiteral("rules"), array); + + // QSaveFile writes a temporary and renames on commit, which is the same + // atomicity mailrules.py gets from os.replace. The hook must never read a + // half-written file. + QSaveFile file(target); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return false; + file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + return file.commit(); +} + +QList<TagRule> TagRules::ordered() const +{ + QList<TagRule> out; + for (const TagRule &rule : m_rules) { + if (rule.enabled) + out.append(rule); + } + // stable_sort, not sort: ties must keep file order, which is the tie-break + // the format promises and what lets a user sequence rules within a stage. + std::stable_sort(out.begin(), out.end(), + [](const TagRule &a, const TagRule &b) { + return a.stage < b.stage; + }); + return out; +} diff --git a/src/tagrules.h b/src/tagrules.h new file mode 100644 index 0000000..8f75b38 --- /dev/null +++ b/src/tagrules.h @@ -0,0 +1,87 @@ +/* + * 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. + */ + +#pragma once + +#include <QJsonObject> +#include <QList> +#include <QString> +#include <QStringList> + +/// One auto-tagging rule, as stored in ~/.config/mailrules/rules.json. +/// +/// A rule carries NO scope. The notmuch post-new hook supplies `tag:new`, a +/// dry run supplies nothing and counts against the whole corpus. That split is +/// what lets one rule answer both "what would this tag on arrival" and "what +/// does this match in all my mail". +struct TagRule +{ + QString id; ///< Stable handle, [a-z0-9-]. Never the tag name: tags + ///< contain '/' and can be renamed. + QString query; ///< notmuch query, unscoped. + QString note; ///< Why the rule is shaped this way. Shown in the dialog. + QStringList add; + QStringList remove; + int stage = 50; ///< Ascending. Account tags 10, topic rules 50. + bool enabled = true; + + /// Fields this version of qtmaildir does not understand, kept verbatim and + /// written back on save. Without this, one save from here silently strips + /// whatever a newer mailctl wrote, and the shared format would belong to + /// whichever tool saved last. + QJsonObject unknown; +}; + +/// Reads and writes the shared rule store. +/// +/// Degrades rather than refusing, exactly as Config does: a malformed rule is +/// dropped with a warning and the rest still load, because one typo must not +/// cost every rule. qtmaildir must never fail to open because of this file. +class TagRules +{ +public: + /// $XDG_CONFIG_HOME/mailrules/rules.json, or ~/.config/... as fallback. + /// Deliberately not under qtmaildir's own config directory: mailctl reads + /// the same file and neither tool owns it. + static QString defaultPath(); + + /// Replaces the current contents. Never throws; see warnings(). + void load(const QString &path = QString()); + + /// Atomic: QSaveFile writes a temporary and renames, so the hook can never + /// read a partial file. Returns false if the write failed. + bool save(const QString &path = QString()) const; + + QList<TagRule> rules() const { return m_rules; } + void setRules(const QList<TagRule> &rules) { m_rules = rules; } + + /// Enabled rules in execution order: stage ascending, ties in file order. + QList<TagRule> ordered() const; + + QStringList warnings() const { return m_warnings; } + + /// No file yet, as distinct from a file that would not load. A fresh + /// install is not an error and must not be reported as one. + bool missing() const { return m_missing; } + +private: + QList<TagRule> m_rules; + QStringList m_warnings; + QJsonObject m_unknown; ///< Unrecognised top-level keys. + bool m_missing = false; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 06cc739..9c65abd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -52,3 +52,4 @@ add_qtmaildir_test(mainwindow) add_qtmaildir_test(messageview) add_qtmaildir_test(querycompleter) add_qtmaildir_test(tagdialog) +add_qtmaildir_test(tagrules) diff --git a/tests/test_tagrules.cpp b/tests/test_tagrules.cpp new file mode 100644 index 0000000..8c7e1a9 --- /dev/null +++ b/tests/test_tagrules.cpp @@ -0,0 +1,245 @@ +/* + * 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 <QTemporaryDir> +#include <QtTest> + +#include "tagrules.h" + +/// The risk in TagRules is the format, not painting: a field silently dropped +/// on save mis-tags real mail on the next sync, and does it quietly. These +/// tests are therefore about round-trips and rejections, and the file they +/// read is byte-for-byte what mailctl writes. +class TestTagRules : public QObject +{ + Q_OBJECT + +private slots: + void aRuleLoadsWithEveryField(); + void absentFieldsTakeTheirDefaults(); + void aMalformedRuleIsDroppedWithAWarning(); + void unknownFieldsSurviveASave(); + void stageOrderPutsAccountsFirst(); + void aQueryWithQuotesRoundTrips(); + void aMissingFileIsEmptyNotAnError(); + void aNewerVersionIsRefused(); + +private: + QString writeRules(const QString &json); + QTemporaryDir m_dir; +}; + +QString TestTagRules::writeRules(const QString &json) +{ + const QString path = m_dir.filePath(QStringLiteral("rules.json")); + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return QString(); + file.write(json.toUtf8()); + file.close(); + return path; +} + +void TestTagRules::aRuleLoadsWithEveryField() +{ + const QString path = writeRules(R"({ + "version": 1, + "rules": [{ + "id": "notify-forge", + "stage": 50, + "enabled": true, + "add": ["notify/forge"], + "remove": [], + "query": "from:notifications@example.com", + "note": "All repositories, not one project." + }] + })"); + + TagRules rules; + rules.load(path); + + QVERIFY2(rules.warnings().isEmpty(), + qPrintable(rules.warnings().join(QStringLiteral("; ")))); + QCOMPARE(rules.rules().size(), 1); + + const TagRule &rule = rules.rules().first(); + QCOMPARE(rule.id, QStringLiteral("notify-forge")); + QCOMPARE(rule.stage, 50); + QVERIFY(rule.enabled); + QCOMPARE(rule.add, QStringList{ QStringLiteral("notify/forge") }); + QVERIFY(rule.remove.isEmpty()); + QCOMPARE(rule.query, QStringLiteral("from:notifications@example.com")); + QCOMPARE(rule.note, QStringLiteral("All repositories, not one project.")); +} + +void TestTagRules::absentFieldsTakeTheirDefaults() +{ + const QString path = writeRules(R"({ + "version": 1, + "rules": [{"id": "minimal", "add": ["x"], + "query": "from:a@example.com"}] + })"); + + TagRules rules; + rules.load(path); + + QVERIFY(rules.warnings().isEmpty()); + const TagRule &rule = rules.rules().first(); + QCOMPARE(rule.stage, 50); + QVERIFY(rule.enabled); + QVERIFY(rule.remove.isEmpty()); + QVERIFY(rule.note.isEmpty()); +} + +void TestTagRules::aMalformedRuleIsDroppedWithAWarning() +{ + // One bad rule must not cost the others. Four separate defects, and the + // good rule sits first so a parser that stops at the first problem is + // caught by the count rather than by an empty list. + const QString path = writeRules(R"({ + "version": 1, + "rules": [ + {"id": "good", "add": ["x"], "query": "from:a@example.com"}, + {"id": "no-query", "add": ["y"]}, + {"id": "no-tags", "query": "from:b@example.com"}, + {"id": "Bad Id", "add": ["z"], "query": "from:c@example.com"} + ] + })"); + + TagRules rules; + rules.load(path); + + QCOMPARE(rules.rules().size(), 1); + QCOMPARE(rules.rules().first().id, QStringLiteral("good")); + QCOMPARE(rules.warnings().size(), 3); +} + +void TestTagRules::unknownFieldsSurviveASave() +{ + // The neutrality guarantee. If qtmaildir strips a field mailctl wrote, + // the file is qtmaildir's file that mailctl may read. + const QString path = writeRules(R"({ + "version": 1, + "future_top_level": {"set_by": "another tool"}, + "rules": [{ + "id": "keeper", + "add": ["x"], + "query": "from:a@example.com", + "future_field": [1, 2, 3] + }] + })"); + + TagRules rules; + rules.load(path); + QVERIFY(rules.save(path)); + + QFile file(path); + QVERIFY(file.open(QIODevice::ReadOnly)); + const QJsonObject root = + QJsonDocument::fromJson(file.readAll()).object(); + + QCOMPARE(root.value(QStringLiteral("future_top_level")) + .toObject().value(QStringLiteral("set_by")).toString(), + QStringLiteral("another tool")); + + const QJsonObject saved = + root.value(QStringLiteral("rules")).toArray().first().toObject(); + QCOMPARE(saved.value(QStringLiteral("future_field")).toArray().size(), 3); + QCOMPARE(saved.value(QStringLiteral("id")).toString(), + QStringLiteral("keeper")); +} + +void TestTagRules::stageOrderPutsAccountsFirst() +{ + const QString path = writeRules(R"({ + "version": 1, + "rules": [ + {"id": "topic-b", "stage": 50, "add": ["b"], + "query": "from:b@example.com"}, + {"id": "account", "stage": 10, "add": ["acct"], + "query": "path:\"work/**\""}, + {"id": "topic-a", "stage": 50, "add": ["a"], + "query": "from:a@example.com"}, + {"id": "off", "stage": 20, "add": ["c"], + "query": "from:c@example.com", "enabled": false} + ] + })"); + + TagRules rules; + rules.load(path); + + QStringList ids; + for (const TagRule &rule : rules.ordered()) + ids.append(rule.id); + + // Stage ascending, ties in file order, disabled excluded. + QCOMPARE(ids, (QStringList{ QStringLiteral("account"), + QStringLiteral("topic-b"), + QStringLiteral("topic-a") })); + // Still loaded, so the dialog can show and re-enable it. + QCOMPARE(rules.rules().size(), 4); +} + +void TestTagRules::aQueryWithQuotesRoundTrips() +{ + // Not hypothetical: every account rule is written path:"account/**". + const QString path = writeRules(R"({ + "version": 1, + "rules": [{"id": "account", "add": ["acct"], + "query": "path:\"work-account/**\""}] + })"); + + TagRules rules; + rules.load(path); + QCOMPARE(rules.rules().first().query, + QStringLiteral("path:\"work-account/**\"")); + + QVERIFY(rules.save(path)); + + TagRules reloaded; + reloaded.load(path); + QVERIFY(reloaded.warnings().isEmpty()); + QCOMPARE(reloaded.rules().first().query, + QStringLiteral("path:\"work-account/**\"")); +} + +void TestTagRules::aMissingFileIsEmptyNotAnError() +{ + // qtmaildir must open on a machine that has never written this file. + TagRules rules; + rules.load(m_dir.filePath(QStringLiteral("absent.json"))); + QVERIFY(rules.rules().isEmpty()); + QVERIFY(rules.warnings().isEmpty()); + QVERIFY(rules.missing()); +} + +void TestTagRules::aNewerVersionIsRefused() +{ + const QString path = writeRules(R"({ + "version": 2, + "rules": [{"id": "x", "add": ["a"], "query": "from:a@example.com"}] + })"); + + TagRules rules; + rules.load(path); + QVERIFY(rules.rules().isEmpty()); + QCOMPARE(rules.warnings().size(), 1); +} + +QTEST_MAIN(TestTagRules) +#include "test_tagrules.moc" |
