From 23e2b007c76dbd1569b1f97c17100eefed4988af Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 3 Aug 2026 08:52:21 +0200 Subject: feat: add NotmuchWorker with batched queries and tag mutation Owns the only notmuch database handle. Queries run read-only and emit threads in batches of 200 with a generation counter so the UI can discard superseded results. Tag mutation closes the read-only handle, opens read-write, applies, and closes, holding the process-wide write lock for milliseconds rather than blocking a concurrent `notmuch new`. Tested against a throwaway database built in a QTemporaryDir, superseding the spec's original "no unit test" position: applyTags is the only code here that writes to a notmuch index. The fixture never touches ~/Mail or ~/.notmuch-config. Two fixes against the drafted implementation, both caught by mutating the code and confirming exactly one test failed: - loadThread conflated "no query given" with "query matched nothing in this thread", so filtering a thread down to zero matches rendered every message expanded. Tracked with an explicit haveMatchSet flag. - applyTags now documents why a stale message id must skip rather than abort: notmuch_database_find_message reports SUCCESS with a null message for an unknown id, and the live ids alongside it still need tagging. Note for fixture authors: notmuch synchronizes maildir flags with tags at index time, so a file named `...:2,S` is indexed without the unread tag no matter what [new] tags requests. Unread fixture messages go in new/. Co-Authored-By: Claude Opus 5 --- tests/notmuchfixture.h | 114 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/notmuchfixture.h (limited to 'tests/notmuchfixture.h') diff --git a/tests/notmuchfixture.h b/tests/notmuchfixture.h new file mode 100644 index 0000000..b7287f4 --- /dev/null +++ b/tests/notmuchfixture.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +/// A throwaway notmuch database in a temporary directory. +/// +/// Builds a Maildir tree, writes a notmuch config pointing at it, and runs +/// `notmuch new`. Nothing touches the developer's own ~/Mail or +/// ~/.notmuch-config: the config path is handed to the worker explicitly. +/// +/// Maildir flags are not decoration. notmuch synchronizes them with tags at +/// index time, so a file named `...:2,S` ("seen") comes out WITHOUT the unread +/// tag no matter what [new] tags says. addMessage() takes the unread state and +/// picks the filename accordingly. +class NotmuchFixture +{ +public: + /// True when the temporary tree was created. Check before use. + bool isValid() const { return m_dir.isValid(); } + + QString configPath() const { return m_dir.filePath(QStringLiteral("config")); } + QString maildirPath() const { return m_dir.filePath(QStringLiteral("mail")); } + + /// Writes one message into /cur (or new/ when unread). + /// + /// Returns false if the file could not be written. Call index() afterwards. + bool addMessage(const QString &folder, const QString &messageId, + const QString &subject, const QString &from, + const QString &date, const QString &body, + bool unread = true, const QString &inReplyTo = QString()) + { + // Unread messages must not carry the maildir "S" flag, so they go to + // new/ where no flags exist at all. + const QString sub = unread ? QStringLiteral("new") : QStringLiteral("cur"); + const QString dirPath = maildirPath() + QLatin1Char('/') + folder; + QDir dir; + if (!dir.mkpath(dirPath + QStringLiteral("/cur")) + || !dir.mkpath(dirPath + QStringLiteral("/new")) + || !dir.mkpath(dirPath + QStringLiteral("/tmp"))) { + return false; + } + + // The local part of the id makes a safe, unique, flag-free filename. + QString base = messageId; + base.remove(QLatin1Char('<')).remove(QLatin1Char('>')); + base.replace(QLatin1Char('@'), QLatin1Char('.')); + base.replace(QLatin1Char('/'), QLatin1Char('.')); + if (!unread) + base += QStringLiteral(":2,S"); + + QFile file(dirPath + QLatin1Char('/') + sub + QLatin1Char('/') + base); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) + return false; + + QTextStream out(&file); + out << "From: " << from << "\n" + << "To: danix@danix.xyz\n" + << "Subject: " << subject << "\n" + << "Message-ID: <" << messageId << ">\n" + << "Date: " << date << "\n"; + if (!inReplyTo.isEmpty()) + out << "In-Reply-To: <" << inReplyTo << ">\n" + << "References: <" << inReplyTo << ">\n"; + out << "\n" << body << "\n"; + out.flush(); + file.close(); + return true; + } + + /// Writes the config and runs `notmuch new`. Safe to call repeatedly. + /// Returns false (with error() set) if notmuch is missing or fails. + bool index() + { + QFile config(configPath()); + if (!config.open(QIODevice::WriteOnly | QIODevice::Text)) { + m_error = QStringLiteral("cannot write fixture config"); + return false; + } + QTextStream out(&config); + out << "[database]\n" + << "path=" << maildirPath() << "\n" + << "[new]\n" + << "tags=unread;inbox;\n"; + out.flush(); + config.close(); + + QProcess proc; + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("NOTMUCH_CONFIG"), configPath()); + proc.setProcessEnvironment(env); + proc.start(QStringLiteral("notmuch"), { QStringLiteral("new") }); + if (!proc.waitForStarted(5000)) { + m_error = QStringLiteral("notmuch not found on PATH"); + return false; + } + if (!proc.waitForFinished(30000) || proc.exitCode() != 0) { + m_error = QStringLiteral("notmuch new failed: %1") + .arg(QString::fromLocal8Bit(proc.readAllStandardError())); + return false; + } + return true; + } + + QString error() const { return m_error; } + +private: + QTemporaryDir m_dir; + QString m_error; +}; -- cgit v1.2.3