diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-14 19:13:42 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-14 19:13:42 +0200 |
| commit | 6367b48d93d3c57b0ba17af0c8736e03bcc2067d (patch) | |
| tree | 871e95d938b4efca380a683b4db6fb9e54efea18 /docs | |
| parent | 038e4977ea0c12b6eafee7d0858b3c46af22bbf2 (diff) | |
| download | qtmaildir-6367b48d93d3c57b0ba17af0c8736e03bcc2067d.tar.gz qtmaildir-6367b48d93d3c57b0ba17af0c8736e03bcc2067d.zip | |
docs: add the CLI selectors implementation plan
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-09-13-cli-selectors.md | 2044 |
1 files changed, 2044 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-13-cli-selectors.md b/docs/superpowers/plans/2026-09-13-cli-selectors.md new file mode 100644 index 0000000..6e01cc4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-cli-selectors.md @@ -0,0 +1,2044 @@ +# CLI Selectors and Single-Instance Launch Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let another program launch `qtmaildir --account KEY --thread ID --message ID` and have the already-running window apply those selectors and raise itself, instead of a second process opening a second window. + +**Architecture:** `QCommandLineParser` replaces the hand-rolled `strcmp` loop in `main.cpp`. A `QLocalServer` under the state directory makes the first process the server; a later launch connects, sends its selectors as one payload, and exits. Both the local startup path and the socket handler call one `MainWindow::applySelectors()`, which reuses the existing `recoverStaleThread()` for the thread and message cases. Resolving a Message-ID to a thread id is one new worker round trip. + +**Tech Stack:** Qt 6.11 (Widgets, **Network** is new), libnotmuch 5, CMake 3.21+/Ninja, QtTest. + +**Spec:** `docs/superpowers/specs/2026-09-13-cli-selectors-design.md`. Backlog item 200. + +--- + +## Required reading before Task 1 + +Read these before writing any code. Each records a trap this plan walks past. + +- `AGENTS.md`, the whole file. In particular **"Adding an action is FIVE places"** (this plan adds none, and the reason is stated in the spec), the `tr()` rules, and the test-writing rules under "Rendering probes lie". +- `docs/superpowers/specs/2026-09-13-cli-selectors-design.md`, the spec this implements. +- `src/searchterm.h:30-35` on why a malformed notmuch query cannot be detected by asking notmuch. + +Three facts that will otherwise cost a session each: + +1. **Never run a test binary without `QT_QPA_PLATFORM=offscreen`**, and never launch `./build/src/qtmaildir` yourself. `tests/CMakeLists.txt` sets that variable for ctest only. One direct run of `test_mainwindow` throws a hundred windows onto the user's screen. +2. **`recoverStaleThread()` is a PRIVATE SLOT** (`src/mainwindow.h:605`). Tests reach private slots by name through `QMetaObject::invokeMethod`, which is the established pattern in `tests/test_mainwindow.cpp` (see line 2060). +3. **`QTRY_VERIFY_WITH_TIMEOUT`, never `qWait(n)`.** A fixed sleep passes when the result never arrives. + +## File Structure + +**Created:** + +- `src/singleinstance.h` / `src/singleinstance.cpp` — a `SingleInstance` QObject owning the `QLocalServer`. One responsibility: decide whether this process is the first, hand a later process's payload to whoever is listening, and emit what arrived. Knows nothing about queries or mail. +- `src/launchselectors.h` / `src/launchselectors.cpp` — a `LaunchSelectors` struct and the parse/serialise functions over it. Pure over values, no Qt GUI, no widget: this is what makes the parse and the payload testable without a window. +- `tests/test_launchselectors.cpp` — the parse and the payload round trip. +- `tests/test_singleinstance.cpp` — server/client behaviour against a `QTemporaryDir`. + +**Modified:** + +- `CMakeLists.txt:20` — add `Network` to `QTMAILDIR_QT_COMPONENTS`. +- `src/CMakeLists.txt:58` — link `Qt6::Network`, add the two new `.cpp` files to `qtmaildir_lib`. +- `src/main.cpp:38-66` — replace the `strcmp` loop; add the connect-or-listen step. +- `src/mainwindow.h` / `src/mainwindow.cpp` — `applySelectors()`, a state-path helper, and the Message-ID round trip. +- `src/notmuchworker.h` / `src/notmuchworker.cpp` — `resolveThreadForMessage()` slot and `threadForMessageResolved()` signal. +- `tests/CMakeLists.txt` — register the two new tests. +- `tests/test_mainwindow.cpp` — the applied-selector cases. +- `tests/test_notmuchworker.cpp` — the Message-ID lookup cases. +- `README.md` — a Usage section. +- `CHANGELOG.md` — an `[Unreleased]` entry. + +**Why two new units rather than code in `main.cpp`:** `main.cpp` is not in `qtmaildir_lib` (only the executable compiles it, see `src/CMakeLists.txt:67`), so anything written there cannot be tested at all. The parse and the socket both need tests, so both live in the library. + +--- + +## Task 1: The Qt6::Network component + +**Files:** +- Modify: `CMakeLists.txt:20` +- Modify: `src/CMakeLists.txt:58-61` + +- [ ] **Step 1: Add Network to the component list** + +In `CMakeLists.txt`, change line 20 from: + +```cmake +set(QTMAILDIR_QT_COMPONENTS Widgets Svg WebEngineWidgets) +``` + +to: + +```cmake +# Network is for QLocalServer/QLocalSocket only, which is a unix domain socket +# between two copies of this program (item 200). It is NOT network protocol +# work: the rule in AGENTS.md is about IMAP and SMTP, and nothing here speaks +# either. Slackware ships it inside the monolithic qt6 package, so this adds no +# new build dependency. +set(QTMAILDIR_QT_COMPONENTS Widgets Svg WebEngineWidgets Network) +``` + +- [ ] **Step 2: Link it** + +In `src/CMakeLists.txt`, change the `target_link_libraries(qtmaildir_lib ...)` call at line 58 so the `PUBLIC` list reads: + +```cmake +target_link_libraries(qtmaildir_lib + PUBLIC Qt6::Widgets Qt6::Svg Qt6::WebEngineWidgets Qt6::Network + PkgConfig::GMIME + ${NOTMUCH_LIBRARY} PkgConfig::CMARK_GFM + ${CMARK_GFM_EXTENSIONS_LIBRARY}) +``` + +- [ ] **Step 3: Reconfigure and build** + +Run: + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug && cmake --build build +``` + +Expected: configures and builds clean. If `find_package` cannot find `Qt6Network`, stop and report it rather than working around it — it means the assumption that Slackware's `qt6` package carries it is wrong, and that changes the SlackBuild too. + +- [ ] **Step 4: Commit** + +```bash +git add CMakeLists.txt src/CMakeLists.txt +git commit -S -m "build: link Qt6::Network for the single-instance socket + +A unix domain socket between two copies of this program, for item 200. Not +network protocol work: the rule in AGENTS.md is about IMAP and SMTP. + +Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01P3HQXLauwQgzxR4YfJBB3x" +``` + +--- + +## Task 2: LaunchSelectors, the value type and its parse + +**Files:** +- Create: `src/launchselectors.h` +- Create: `src/launchselectors.cpp` +- Create: `tests/test_launchselectors.cpp` +- Modify: `src/CMakeLists.txt` +- Modify: `tests/CMakeLists.txt` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_launchselectors.cpp`: + +```cpp +/* + * 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 <QtTest> + +#include "launchselectors.h" + +/// The command line and the socket payload, over values. No window, no +/// QApplication: this is the half of item 200 that can be asserted exactly, +/// which is why it is its own unit rather than code inside main.cpp. +class TestLaunchSelectors : public QObject +{ + Q_OBJECT + +private slots: + void anEmptyCommandLineSelectsNothing(); + void eachSelectorIsParsed(); + void theThreeSelectorsCompose(); + void anUnknownOptionIsReportedNotFatal(); + void aPayloadRoundTrips(); + void anEmptyPayloadRoundTripsToNothing(); + void aTruncatedPayloadIsRejected(); + void anOversizedPayloadIsRejected(); +}; + +void TestLaunchSelectors::anEmptyCommandLineSelectsNothing() +{ + QString error; + const LaunchSelectors selectors = + LaunchSelectors::parse({ QStringLiteral("qtmaildir") }, &error); + + QVERIFY(error.isEmpty()); + QVERIFY(selectors.isEmpty()); + QVERIFY(selectors.account.isEmpty()); + QVERIFY(selectors.threadId.isEmpty()); + QVERIFY(selectors.messageId.isEmpty()); +} + +void TestLaunchSelectors::eachSelectorIsParsed() +{ + QString error; + + const LaunchSelectors account = LaunchSelectors::parse( + { QStringLiteral("qtmaildir"), QStringLiteral("--account"), + QStringLiteral("work") }, &error); + QVERIFY(error.isEmpty()); + QCOMPARE(account.account, QStringLiteral("work")); + QVERIFY(!account.isEmpty()); + + const LaunchSelectors thread = LaunchSelectors::parse( + { QStringLiteral("qtmaildir"), QStringLiteral("--thread"), + QStringLiteral("0000000000001a2b") }, &error); + QVERIFY(error.isEmpty()); + QCOMPARE(thread.threadId, QStringLiteral("0000000000001a2b")); + + const LaunchSelectors message = LaunchSelectors::parse( + { QStringLiteral("qtmaildir"), QStringLiteral("--message"), + QStringLiteral("<abc@example.org>") }, &error); + QVERIFY(error.isEmpty()); + QCOMPARE(message.messageId, QStringLiteral("<abc@example.org>")); +} + +void TestLaunchSelectors::theThreeSelectorsCompose() +{ + // They are not exclusive: "open this message, in this account's view" is + // one sensible request, and the spec says they compose. + QString error; + const LaunchSelectors selectors = LaunchSelectors::parse( + { QStringLiteral("qtmaildir"), + QStringLiteral("--account"), QStringLiteral("work"), + QStringLiteral("--thread"), QStringLiteral("00001a2b"), + QStringLiteral("--message"), QStringLiteral("<abc@example.org>") }, + &error); + + QVERIFY(error.isEmpty()); + QCOMPARE(selectors.account, QStringLiteral("work")); + QCOMPARE(selectors.threadId, QStringLiteral("00001a2b")); + QCOMPARE(selectors.messageId, QStringLiteral("<abc@example.org>")); +} + +void TestLaunchSelectors::anUnknownOptionIsReportedNotFatal() +{ + // Reported so main() can print it, and NOT a crash or a silent ignore. + // Today's strcmp loop ignores everything it does not know, which is how a + // typo currently produces a normal window and no clue. + QString error; + const LaunchSelectors selectors = LaunchSelectors::parse( + { QStringLiteral("qtmaildir"), QStringLiteral("--nonsense") }, &error); + + QVERIFY(!error.isEmpty()); + QVERIFY(selectors.isEmpty()); +} + +void TestLaunchSelectors::aPayloadRoundTrips() +{ + // What crosses the socket. A round trip is the whole contract: the values + // that go in are the values that come out, including one with an embedded + // newline, which is what defeats a line-based format. + LaunchSelectors original; + original.account = QStringLiteral("work"); + original.threadId = QStringLiteral("00001a2b"); + original.messageId = QStringLiteral("<a\nb@example.org>"); + + QString error; + const LaunchSelectors parsed = + LaunchSelectors::fromPayload(original.toPayload(), &error); + + QVERIFY(error.isEmpty()); + QCOMPARE(parsed.account, original.account); + QCOMPARE(parsed.threadId, original.threadId); + QCOMPARE(parsed.messageId, original.messageId); +} + +void TestLaunchSelectors::anEmptyPayloadRoundTripsToNothing() +{ + // A bare `qtmaildir` with a running instance still sends a payload: it + // means "raise yourself", which is a real request and not an error. + QString error; + const LaunchSelectors parsed = + LaunchSelectors::fromPayload(LaunchSelectors().toPayload(), &error); + + QVERIFY(error.isEmpty()); + QVERIFY(parsed.isEmpty()); +} + +void TestLaunchSelectors::aTruncatedPayloadIsRejected() +{ + // The socket hands over whatever it is given. A half-written payload must + // be refused rather than half-applied. + LaunchSelectors original; + original.account = QStringLiteral("work"); + const QByteArray payload = original.toPayload(); + QVERIFY(payload.size() > 4); + + QString error; + const LaunchSelectors parsed = + LaunchSelectors::fromPayload(payload.left(payload.size() - 2), &error); + + QVERIFY(!error.isEmpty()); + QVERIFY(parsed.isEmpty()); +} + +void TestLaunchSelectors::anOversizedPayloadIsRejected() +{ + // A cap, because a local socket will hand over as much as the peer sends. + // The peer is the user's own process, so this is not a hostile-input + // defence; it is what stops a confused writer from being read as a + // gigabyte of selector. + QString error; + const LaunchSelectors parsed = LaunchSelectors::fromPayload( + QByteArray(LaunchSelectors::kMaxPayloadBytes + 1, 'x'), &error); + + QVERIFY(!error.isEmpty()); + QVERIFY(parsed.isEmpty()); +} + +QTEST_MAIN(TestLaunchSelectors) +#include "test_launchselectors.moc" +``` + +- [ ] **Step 2: Register the test and run it to verify it fails** + +Add to `tests/CMakeLists.txt`, beside the other `add_qtmaildir_test` lines: + +```cmake +add_qtmaildir_test(launchselectors) +``` + +Run: + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug && cmake --build build +``` + +Expected: FAILS to compile, with `launchselectors.h: No such file or directory`. + +- [ ] **Step 3: Write the header** + +Create `src/launchselectors.h`: + +```cpp +/* + * 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 <QByteArray> +#include <QString> +#include <QStringList> + +/// What a launch asked the window to show (item 200). +/// +/// A value type with no Qt GUI dependency, deliberately: it is parsed before +/// QApplication exists, it crosses a socket, and both halves need tests that +/// no window has to be built for. +/// +/// The three selectors COMPOSE rather than excluding each other. "Open this +/// message, in this account's view" is one request, and nothing about it is +/// contradictory. +struct LaunchSelectors +{ + /// An account key as written in the config, e.g. `work` from + /// `[account.work]`. Validated against the configured accounts by the + /// window, not here: this unit knows nothing about a Config. + QString account; + + /// A notmuch thread id. + QString threadId; + + /// A Message-ID, with or without the angle brackets. + QString messageId; + + bool isEmpty() const + { + return account.isEmpty() && threadId.isEmpty() && messageId.isEmpty(); + } + + /// Longest payload accepted off the socket. + /// + /// A local socket hands over whatever the peer sends, and the peer here is + /// another copy of this program running as the same user, so this is not a + /// defence against an attacker. It is what stops a confused or truncated + /// writer from being read as an unbounded selector. Three ids and an + /// account key are a few hundred bytes; 64 KiB is room to spare. + static constexpr int kMaxPayloadBytes = 64 * 1024; + + /// Parses a command line, `arguments[0]` being the program name. + /// + /// Takes a QStringList rather than argc/argv so it can be called before + /// QCoreApplication exists, which is what lets --version keep answering on + /// a machine where the GUI cannot open. + /// + /// On an unknown option, returns an empty result and sets \p error. The + /// caller prints it; it is NOT fatal to the window, since a typo should + /// not cost the user their mail client. + static LaunchSelectors parse(const QStringList &arguments, QString *error); + + /// The help text, for `--help`. Translatable prose; the option NAMES are + /// wire format and are never translated. + static QString helpText(const QString &versionDisplay); + + /// Serialises for the socket. The inverse of fromPayload(). + QByteArray toPayload() const; + + /// Parses a socket payload. On anything malformed, oversized or truncated, + /// returns an empty result and sets \p error. + static LaunchSelectors fromPayload(const QByteArray &payload, + QString *error); +}; + +/// Declared in the header that DEFINES the type, as types.h and threaddigest.h +/// do for theirs. A consumer declaring it instead would leave any other +/// consumer without it, and QSignalSpy needs it to carry the type. +Q_DECLARE_METATYPE(LaunchSelectors) +``` + +- [ ] **Step 4: Write the implementation** + +Create `src/launchselectors.cpp`: + +```cpp +/* + * 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 "launchselectors.h" + +#include <QCommandLineOption> +#include <QCommandLineParser> +#include <QCoreApplication> +#include <QDataStream> +#include <QIODevice> + +namespace { + +/// Bumped only if the payload's shape changes incompatibly. Both ends of the +/// socket are the same binary in the ordinary case, but an upgrade can leave an +/// old instance running while a new one is launched, and a version the reader +/// does not know is refused rather than misread. +constexpr quint16 kPayloadVersion = 1; + +} // namespace + +LaunchSelectors LaunchSelectors::parse(const QStringList &arguments, + QString *error) +{ + if (error) + error->clear(); + + QCommandLineParser parser; + // No addHelpOption()/addVersionOption(): those are handled in main() before + // QApplication exists, and Qt's own versions call exit() through + // QCoreApplication, which is not constructed at that point. + QCommandLineOption accountOption( + QStringLiteral("account"), + QCoreApplication::translate( + "LaunchSelectors", "Open this account's view."), + QStringLiteral("key")); + QCommandLineOption threadOption( + QStringLiteral("thread"), + QCoreApplication::translate( + "LaunchSelectors", "Open this thread."), + QStringLiteral("id")); + QCommandLineOption messageOption( + QStringLiteral("message"), + QCoreApplication::translate( + "LaunchSelectors", "Open this message, inside its thread."), + QStringLiteral("id")); + + parser.addOption(accountOption); + parser.addOption(threadOption); + parser.addOption(messageOption); + + // parse(), not process(): process() prints to stderr and calls exit() on an + // error, which would take the window down over a typo. The error is + // returned instead and main() decides. + if (!parser.parse(arguments)) { + if (error) + *error = parser.errorText(); + return {}; + } + + // --version and --help are consumed in main() before this runs, so they + // never reach the parser. An unknown option does, and is an error rather + // than something to ignore: today's strcmp loop ignores everything it does + // not recognise, so a typo silently produces an ordinary window. + const QStringList unknown = parser.unknownOptionNames(); + if (!unknown.isEmpty()) { + if (error) { + *error = QCoreApplication::translate( + "LaunchSelectors", "Unknown option: %1") + .arg(unknown.join(QStringLiteral(", "))); + } + return {}; + } + + LaunchSelectors selectors; + selectors.account = parser.value(accountOption); + selectors.threadId = parser.value(threadOption); + selectors.messageId = parser.value(messageOption); + return selectors; +} + +QString LaunchSelectors::helpText(const QString &versionDisplay) +{ + // The option NAMES are wire format and are never translated; the prose + // beside them is. Kept as one block rather than assembled from pieces so a + // translator sees the layout they are translating. + return QCoreApplication::translate( + "LaunchSelectors", + "qtmaildir %1 - a Qt6 mail client for notmuch-indexed Maildirs\n" + "\n" + "Usage: qtmaildir [options]\n" + "\n" + " -h, --help Show this help and exit\n" + " -v, --version Show the version and exit\n" + " --account <key> Open this account's view\n" + " --thread <id> Open this thread\n" + " --message <id> Open this message, inside its thread\n" + "\n" + "The three selectors combine. When qtmaildir is already " + "running,\n" + "a second launch hands its selectors to that window and exits " + "rather\n" + "than opening a second one.\n" + "\n" + "Configuration: ~/.config/qtmaildir/qtmaildir.conf\n" + "qtmaildir reads a notmuch-indexed Maildir. It does no network\n" + "protocol work: fetching and sending are external commands.\n") + .arg(versionDisplay); +} + +QByteArray LaunchSelectors::toPayload() const +{ + // QDataStream rather than a line-based format: a Message-ID can contain + // almost anything, a newline included, and a length-prefixed encoding does + // not care. The round-trip test carries an embedded newline for exactly + // this reason. + QByteArray payload; + QDataStream stream(&payload, QIODevice::WriteOnly); + stream.setVersion(QDataStream::Qt_6_0); + stream << kPayloadVersion << account << threadId << messageId; + return payload; +} + +LaunchSelectors LaunchSelectors::fromPayload(const QByteArray &payload, + QString *error) +{ + if (error) + error->clear(); + + if (payload.size() > kMaxPayloadBytes) { + if (error) { + *error = QCoreApplication::translate( + "LaunchSelectors", "Launch payload too large"); + } + return {}; + } + + QDataStream stream(payload); + stream.setVersion(QDataStream::Qt_6_0); + + quint16 version = 0; + stream >> version; + if (stream.status() != QDataStream::Ok || version != kPayloadVersion) { + if (error) { + *error = QCoreApplication::translate( + "LaunchSelectors", "Unrecognised launch payload"); + } + return {}; + } + + LaunchSelectors selectors; + stream >> selectors.account >> selectors.threadId >> selectors.messageId; + + // Checked AFTER every read, which is what catches a truncated payload: a + // short read leaves the stream in ReadPastEnd and the fields + // default-constructed, so without this a half-written message id would be + // applied as an empty one. + if (stream.status() != QDataStream::Ok) { + if (error) { + *error = QCoreApplication::translate( + "LaunchSelectors", "Truncated launch payload"); + } + return {}; + } + + return selectors; +} +``` + +- [ ] **Step 5: Add the source to the library** + +In `src/CMakeLists.txt`, add `launchselectors.cpp` to the `qtmaildir_lib` source list, keeping the list's existing order convention. + +- [ ] **Step 6: Run the test to verify it passes** + +```bash +cmake --build build && ctest --test-dir build -R launchselectors --output-on-failure +``` + +Expected: PASS, 8 tests. + +- [ ] **Step 7: Commit** + +```bash +git add src/launchselectors.h src/launchselectors.cpp \ + tests/test_launchselectors.cpp src/CMakeLists.txt tests/CMakeLists.txt +git commit -S -m "feat: parse the launch selectors as a value type + +--account, --thread and --message, plus the payload that crosses the socket. +A value type with no GUI dependency: it is parsed before QApplication exists +and both halves need tests no window has to be built for. + +QDataStream rather than a line-based payload, because a Message-ID may contain +a newline. Every read is status-checked, which is what catches a truncated +payload: a short read otherwise leaves the fields default-constructed and a +half-written id would be applied as an empty one. + +Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01P3HQXLauwQgzxR4YfJBB3x" +``` + +--- + +## Task 3: SingleInstance, the socket + +**Files:** +- Create: `src/singleinstance.h` +- Create: `src/singleinstance.cpp` +- Create: `tests/test_singleinstance.cpp` +- Modify: `src/CMakeLists.txt` +- Modify: `tests/CMakeLists.txt` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_singleinstance.cpp`: + +```cpp +/* + * 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 <QLocalServer> +#include <QSignalSpy> +#include <QTemporaryDir> +#include <QtTest> + +#include "launchselectors.h" +#include "singleinstance.h" + +/// The socket half of item 200. Every case runs against a socket name of its +/// own inside a QTemporaryDir, so nothing here can reach a real running +/// qtmaildir, and two cases cannot collide. +class TestSingleInstance : public QObject +{ + Q_OBJECT + +private slots: + void init(); + + void theFirstInstanceBecomesTheServer(); + void aSecondInstanceHandsOverItsSelectors(); + void aSecondInstanceWithNoSelectorsStillArrives(); + void aStaleSocketFileIsReclaimed(); + void anUncreatableSocketDoesNotStopStartup(); + +private: + QTemporaryDir m_dir; + QString m_socketPath; +}; + +void TestSingleInstance::init() +{ + QVERIFY(m_dir.isValid()); + // A name per test function, so a socket left behind by one case cannot + // decide the next one's result. + m_socketPath = m_dir.filePath( + QStringLiteral("sock-%1").arg(QTest::currentTestFunction())); +} + +void TestSingleInstance::theFirstInstanceBecomesTheServer() +{ + SingleInstance first(m_socketPath); + QVERIFY(first.tryBecomeServer()); + QVERIFY(first.isServer()); +} + +void TestSingleInstance::aSecondInstanceHandsOverItsSelectors() +{ + // The whole point of the feature: the second process does not open a + // window, it hands its request to the first and exits. + SingleInstance first(m_socketPath); + QVERIFY(first.tryBecomeServer()); + + QSignalSpy arrived(&first, &SingleInstance::selectorsReceived); + + LaunchSelectors selectors; + selectors.account = QStringLiteral("work"); + selectors.messageId = QStringLiteral("<abc@example.org>"); + + SingleInstance second(m_socketPath); + QVERIFY(!second.tryBecomeServer()); + QVERIFY(second.sendToRunningInstance(selectors)); + + QTRY_VERIFY_WITH_TIMEOUT(arrived.count() == 1, 5000); + const auto received = + arrived.first().at(0).value<LaunchSelectors>(); + QCOMPARE(received.account, QStringLiteral("work")); + QCOMPARE(received.messageId, QStringLiteral("<abc@example.org>")); +} + +void TestSingleInstance::aSecondInstanceWithNoSelectorsStillArrives() +{ + // A bare `qtmaildir` against a running instance means "raise yourself". + // That is a real request, so it must arrive rather than being dropped as + // an empty message. + SingleInstance first(m_socketPath); + QVERIFY(first.tryBecomeServer()); + + QSignalSpy arrived(&first, &SingleInstance::selectorsReceived); + + SingleInstance second(m_socketPath); + QVERIFY(!second.tryBecomeServer()); + QVERIFY(second.sendToRunningInstance(LaunchSelectors())); + + QTRY_VERIFY_WITH_TIMEOUT(arrived.count() == 1, 5000); + QVERIFY(arrived.first().at(0).value<LaunchSelectors>().isEmpty()); +} + +void TestSingleInstance::aStaleSocketFileIsReclaimed() +{ + // A crash or a kill leaves the socket file behind, and listen() then fails + // with AddressInUse on a file nothing is serving. Without recovery the + // application would never start again until someone deleted it by hand. + // + // The file is created by a server that is then destroyed WITHOUT removing + // it, which QLocalServer does on an abrupt exit. + // A plain file at the socket path, which is what a killed process leaves + // behind: a filesystem entry with no process serving it. listen() then + // fails with AddressInUse, and nothing answers a connection. + QFile stale(m_socketPath); + QVERIFY(stale.open(QIODevice::WriteOnly)); + stale.close(); + QVERIFY(QFile::exists(m_socketPath)); + + SingleInstance fresh(m_socketPath); + QVERIFY2(fresh.tryBecomeServer(), + "a stale socket file must not stop the application starting"); + QVERIFY(fresh.isServer()); +} + +void TestSingleInstance::anUncreatableSocketDoesNotStopStartup() +{ + // A read-only state directory must degrade to today's behaviour, a window + // that opens and works, rather than to no mail client at all. The caller + // reads isServer() as false and carries on. + const QString impossible = + m_dir.filePath(QStringLiteral("no/such/directory/sock")); + + SingleInstance instance(impossible); + QVERIFY(!instance.tryBecomeServer()); + QVERIFY(!instance.isServer()); + // And it cannot reach a running instance either, since there is none. + QVERIFY(!instance.sendToRunningInstance(LaunchSelectors())); +} + +QTEST_MAIN(TestSingleInstance) +#include "test_singleinstance.moc" +``` + +- [ ] **Step 2: Register the test and run it to verify it fails** + +Add to `tests/CMakeLists.txt`: + +```cmake +add_qtmaildir_test(singleinstance) +``` + +Run: + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug && cmake --build build +``` + +Expected: FAILS to compile, `singleinstance.h: No such file or directory`. + +- [ ] **Step 3: Write the header** + +Create `src/singleinstance.h`: + +```cpp +/* + * 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 <QObject> +#include <QString> + +#include "launchselectors.h" + +class QLocalServer; + +/// Makes a launch either the running instance or a messenger to it (item 200). +/// +/// **This is not network protocol work.** A QLocalServer is a unix domain +/// socket between two copies of this program, owned by the user, in the user's +/// own state directory. The rule in AGENTS.md is about IMAP and SMTP. +/// +/// Knows nothing about queries, accounts or mail: it carries a LaunchSelectors +/// from one process to another and emits what arrived. +class SingleInstance : public QObject +{ + Q_OBJECT + +public: + /// \p socketPath is a filesystem path, so a test can point it inside a + /// QTemporaryDir and never touch the user's real one. + explicit SingleInstance(const QString &socketPath, + QObject *parent = nullptr); + ~SingleInstance() override; + + /// Tries to become the instance others talk to. + /// + /// Returns true when this process is now listening, false when another + /// instance already is OR when no socket could be created at all. The + /// caller treats both falses the same way for the second case: **a socket + /// that cannot be created must not stop the window opening**, or a + /// read-only state directory costs the user their mail client. + /// + /// Handles the stale socket file, which is the ordinary aftermath of a + /// crash: it attempts a CONNECTION first, and a refused connection on an + /// existing file proves nothing is serving it, so the file is removed and + /// the listen retried. Connecting first is what stops a live instance + /// being removed out from under itself. + bool tryBecomeServer(); + + /// True when tryBecomeServer() succeeded and this process is listening. + bool isServer() const; + + /// Sends \p selectors to the running instance. Returns false when there is + /// none, or when the write could not be completed. + /// + /// An EMPTY selector set is still sent: a bare `qtmaildir` against a + /// running window means "raise yourself", which is a request and not a + /// no-op. + bool sendToRunningInstance(const LaunchSelectors &selectors); + +signals: + /// A later launch handed these over. Emitted on the server side only. + void selectorsReceived(const LaunchSelectors &selectors); + +private: + QString m_socketPath; + QLocalServer *m_server = nullptr; +}; +``` + +`Q_DECLARE_METATYPE(LaunchSelectors)` is **not** repeated here: it belongs in +`launchselectors.h`, which defines the type, matching `types.h` and +`threaddigest.h`. Declaring it in a consumer instead would leave every other +consumer without it. + +- [ ] **Step 4: Write the implementation** + +Create `src/singleinstance.cpp`: + +```cpp +/* + * 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 "singleinstance.h" + +#include <QDebug> +#include <QDir> +#include <QFileInfo> +#include <QLocalServer> +#include <QLocalSocket> + +namespace { + +/// How long a client waits for the running instance, and how long the server +/// waits for a client's payload. Short: both processes are on this machine and +/// the peer is either there or it is not. A long wait would stall a launch +/// behind a wedged instance, which is worse than opening a window. +constexpr int kTimeoutMs = 2000; + +} // namespace + +SingleInstance::SingleInstance(const QString &socketPath, QObject *parent) + : QObject(parent), m_socketPath(socketPath) +{ + // Registered here rather than at a call site so any connection carrying + // this type works, including a queued one a future caller might add. A + // Q_DECLARE_METATYPE alone gives the type a metatype but does not register + // it under the name a queued invoke resolves, which is the trap AGENTS.md + // records for Q_ENUM. + qRegisterMetaType<LaunchSelectors>("LaunchSelectors"); +} + +SingleInstance::~SingleInstance() +{ + if (m_server) { + m_server->close(); + // Removes the filesystem entry, so an orderly exit leaves nothing for + // the next launch to reclaim. + QLocalServer::removeServer(m_socketPath); + } +} + +bool SingleInstance::isServer() const +{ + return m_server != nullptr && m_server->isListening(); +} + +bool SingleInstance::tryBecomeServer() +{ + if (isServer()) + return true; + + // CONNECT FIRST, and the order is the design. A successful connection means + // a live instance owns this socket and this process is the messenger. A + // refused connection on an EXISTING file means the file is stale, left by a + // crash, and can be removed; doing it in this order is what stops a live + // instance being removed out from under itself. + { + QLocalSocket probe; + probe.connectToServer(m_socketPath); + if (probe.waitForConnected(kTimeoutMs)) { + probe.disconnectFromServer(); + return false; + } + } + + auto *server = new QLocalServer(this); + // The socket is the user's own, in their own state directory. Nothing else + // has any business connecting to it. + server->setSocketOptions(QLocalServer::UserAccessOption); + + if (!server->listen(m_socketPath)) { + if (server->serverError() == QAbstractSocket::AddressInUseError + && QFileInfo::exists(m_socketPath)) { + // Nothing answered the probe above, so this file is stale. + QLocalServer::removeServer(m_socketPath); + server->listen(m_socketPath); + } + } + + if (!server->isListening()) { + // A read-only state directory, a filesystem that has no unix sockets, + // or a path whose parent does not exist. Report and carry on: the + // window must still open. Losing single-instance behaviour is a + // degradation; losing the mail client is not acceptable. + qWarning() << "qtmaildir: cannot create the single-instance socket at" + << m_socketPath << ":" << server->errorString() + << "- continuing without it"; + delete server; + return false; + } + + m_server = server; + connect(m_server, &QLocalServer::newConnection, this, [this]() { + while (QLocalSocket *socket = m_server->nextPendingConnection()) { + // Deleted when the peer goes away, which it does immediately after + // writing: the client's whole life is one payload. + connect(socket, &QLocalSocket::disconnected, + socket, &QLocalSocket::deleteLater); + + if (!socket->waitForReadyRead(kTimeoutMs)) { + socket->disconnectFromServer(); + continue; + } + + // readAll() rather than a sized read: the payload is one short + // write and the cap inside fromPayload() is what bounds it. + const QByteArray payload = socket->readAll(); + QString error; + const LaunchSelectors selectors = + LaunchSelectors::fromPayload(payload, &error); + if (!error.isEmpty()) { + qWarning() << "qtmaildir: ignoring a launch payload:" << error; + socket->disconnectFromServer(); + continue; + } + + // Emitted even when empty: a bare launch means "raise yourself". + emit selectorsReceived(selectors); + socket->disconnectFromServer(); + } + }); + + return true; +} + +bool SingleInstance::sendToRunningInstance(const LaunchSelectors &selectors) +{ + QLocalSocket socket; + socket.connectToServer(m_socketPath); + if (!socket.waitForConnected(kTimeoutMs)) + return false; + + socket.write(selectors.toPayload()); + // Flushed before returning, because the caller exits immediately + // afterwards and an unflushed write would be lost with the process. + if (!socket.waitForBytesWritten(kTimeoutMs)) + return false; + + socket.disconnectFromServer(); + return true; +} +``` + +- [ ] **Step 5: Add the source to the library** + +In `src/CMakeLists.txt`, add `singleinstance.cpp` to the `qtmaildir_lib` source list. + +- [ ] **Step 6: Run the test to verify it passes** + +```bash +cmake --build build && ctest --test-dir build -R singleinstance --output-on-failure +``` + +Expected: PASS, 5 tests. + +- [ ] **Step 7: Commit** + +```bash +git add src/singleinstance.h src/singleinstance.cpp \ + tests/test_singleinstance.cpp src/CMakeLists.txt tests/CMakeLists.txt +git commit -S -m "feat: add the single-instance socket + +A QLocalServer under the state directory. The first launch listens; a later one +connects, hands over its selectors and exits. + +Connect-first ordering, which is also how a stale socket file is detected: a +refused connection on an existing file proves nothing is serving it. Doing it +the other way round would remove a live instance's socket out from under it. + +A socket that cannot be created does NOT stop the window opening. A read-only +state directory costs single-instance behaviour, which is a degradation; it +must not cost the user their mail client. + +Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01P3HQXLauwQgzxR4YfJBB3x" +``` + +--- + +## Task 4: Resolving a Message-ID to its thread + +**Files:** +- Modify: `src/notmuchworker.h:130-141` +- Modify: `src/notmuchworker.cpp` (beside `threadIdForTesting`, around line 764) +- Test: `tests/test_notmuchworker.cpp` + +**Context you need:** `NotmuchWorker::threadIdForTesting(const QString &query)` already exists at `src/notmuchworker.cpp:764` and does exactly this lookup, but it is a plain method named for tests and explicitly documented as "Not a slot, so it cannot be reached across the thread boundary by accident". This task adds a real slot beside it rather than promoting it, because the slot must be asynchronous (it answers by signal) while the existing helper returns a value synchronously, and four tests already depend on the synchronous form. + +- [ ] **Step 1: Write the failing test** + +Add to the `private slots:` list in `tests/test_notmuchworker.cpp`, after `loadMessageOnAnUnknownIdReturnsNothing()`: + +```cpp + void resolvingAMessageIdAnswersItsThreadId(); + void resolvingAnUnknownMessageIdAnswersEmpty(); + void resolvingAMessageIdQuotesTheId(); +``` + +Add the bodies, after `TestNotmuchWorker::loadMessageOnAnUnknownIdReturnsNothing()`: + +```cpp +void TestNotmuchWorker::resolvingAMessageIdAnswersItsThreadId() +{ + // What --message needs (item 200): the CLI knows a Message-ID and the + // window needs the thread id, because opening the message means opening + // its conversation with that message selected. + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy resolved(&worker, &NotmuchWorker::threadForMessageResolved); + + worker.resolveThreadForMessage(QStringLiteral("a2@example.org")); + + QCOMPARE(resolved.count(), 1); + QCOMPARE(resolved.first().at(0).toString(), QStringLiteral("a2@example.org")); + + // a2 is a REPLY, so its thread id is the thread's, not its own. Asserted + // against the thread a1 resolves to: the two must agree, which is the + // whole point of resolving through the thread rather than the message. + const QString threadId = resolved.first().at(1).toString(); + QVERIFY(!threadId.isEmpty()); + QCOMPARE(threadId, + worker.threadIdForTesting(QStringLiteral("id:a1@example.org"))); +} + +void TestNotmuchWorker::resolvingAnUnknownMessageIdAnswersEmpty() +{ + // Answers rather than staying silent: the window shows the miss in the + // status bar, and a slot that never replies would leave it waiting. + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy resolved(&worker, &NotmuchWorker::threadForMessageResolved); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + worker.resolveThreadForMessage(QStringLiteral("nonexistent@example.org")); + + QCOMPARE(resolved.count(), 1); + QCOMPARE(resolved.first().at(0).toString(), + QStringLiteral("nonexistent@example.org")); + QVERIFY(resolved.first().at(1).toString().isEmpty()); + + // Not an error: a stale id from another program is an ordinary miss, the + // same class as a stale row after a reindex. + QCOMPARE(errors.count(), 0); +} + +void TestNotmuchWorker::resolvingAMessageIdQuotesTheId() +{ + // The security-relevant case. This id comes from argv, not from notmuch, + // and notmuch's parser rejects almost nothing: an unquoted id carrying + // query syntax would be PARSED as syntax, matching something else or + // nothing, with no error anywhere (searchterm.h:30-35). + // + // Asserted as a miss that stays a miss: the id cannot match, and it must + // not blow up, error, or resolve to some unrelated thread. + NotmuchWorker worker(m_fixture.configPath()); + QSignalSpy resolved(&worker, &NotmuchWorker::threadForMessageResolved); + QSignalSpy errors(&worker, &NotmuchWorker::errorOccurred); + + worker.resolveThreadForMessage( + QStringLiteral("a2@example.org\" or from:alice or \"x")); + + QCOMPARE(resolved.count(), 1); + QVERIFY2(resolved.first().at(1).toString().isEmpty(), + "an id carrying query syntax resolved to a thread: it was not quoted"); + QCOMPARE(errors.count(), 0); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cmake --build build 2>&1 | tail -5 +``` + +Expected: FAILS to compile, `'threadForMessageResolved' is not a member of 'NotmuchWorker'`. + +- [ ] **Step 3: Declare the slot and the signal** + +In `src/notmuchworker.h`, add to the `public slots:` section that begins at line 141, after the `loadMessage` declaration: + +```cpp + /// Answers which thread a Message-ID belongs to (item 200). + /// + /// For `--message`, which knows an id and needs the conversation: opening + /// a message means opening its thread with that message selected, never an + /// `id:` query showing one card out of a conversation (item 91). + /// + /// Answers with an EMPTY thread id when the message is unknown rather than + /// staying silent, since the window reports the miss and a slot that never + /// replies would leave it waiting forever. + /// + /// **The id is quoted before it reaches notmuch.** Unlike every other id in + /// this class, this one came from argv rather than from notmuch itself, and + /// notmuch parses garbage happily while matching nothing. + void resolveThreadForMessage(const QString &messageId); +``` + +And to the `signals:` section, after `messageLoaded`: + +```cpp + /// The answer to resolveThreadForMessage(). The message id is echoed back + /// so a caller can tell which request this answers; the thread id is empty + /// when nothing matched. + void threadForMessageResolved(const QString &messageId, + const QString &threadId); +``` + +- [ ] **Step 4: Write the implementation** + +In `src/notmuchworker.cpp`, add after `threadIdForTesting()` (which ends around line 786): + +```cpp +void NotmuchWorker::resolveThreadForMessage(const QString &messageId) +{ + if (messageId.isEmpty()) { + emit threadForMessageResolved(messageId, QString()); + return; + } + + // SearchTerm::quote(), not the QStringLiteral("id:\"%1\"") this file uses + // elsewhere. Every other id here came out of notmuch; this one came off the + // command line of another program, so it is untrusted in the ordinary + // sense. quote() escapes backslashes before quotes, which is the order that + // matters, and caps the length. + const QString term = SearchTerm::field(QStringLiteral("id"), messageId); + if (term.isEmpty()) { + emit threadForMessageResolved(messageId, QString()); + return; + } + + // threadIdForTesting() is the same lookup and is deliberately NOT reused + // by name: it is documented as not being a slot so it cannot cross the + // thread boundary by accident, and renaming it would rewrite four existing + // tests for no gain. The shared part is one query, which is small enough + // that a helper would be more indirection than it saves. + emit threadForMessageResolved(messageId, firstThreadIdMatching(term)); +} +``` + +Rename the body of `threadIdForTesting` to a private helper both call. Replace the existing definition at line 764 with: + +```cpp +QString NotmuchWorker::firstThreadIdMatching(const QString &query) +{ + if (!openReadOnly()) + return QString(); + + NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData())); + if (!nmQuery) + return QString(); + + notmuch_threads_t *rawThreads = nullptr; + if (notmuch_query_search_threads(nmQuery.get(), &rawThreads) + != NOTMUCH_STATUS_SUCCESS) { + return QString(); + } + NmThreads threads(rawThreads); + if (!notmuch_threads_valid(threads.get())) + return QString(); + + NmThread thread(notmuch_threads_get(threads.get())); + if (!thread) + return QString(); + return QString::fromUtf8(notmuch_thread_get_thread_id(thread.get())); +} + +QString NotmuchWorker::threadIdForTesting(const QString &query) +{ + return firstThreadIdMatching(query); +} +``` + +Declare the helper in `src/notmuchworker.h`, in the `private:` section: + +```cpp + /// The first thread id matching \p query, or empty. + /// + /// Shared by threadIdForTesting() and resolveThreadForMessage(). The + /// callers differ in what they do with it and in whether they are slots; + /// the lookup is the same. + QString firstThreadIdMatching(const QString &query); +``` + +Add the include at the top of `src/notmuchworker.cpp`, with the other project includes: + +```cpp +#include "searchterm.h" +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cmake --build build && ctest --test-dir build -R notmuchworker --output-on-failure +``` + +Expected: PASS. The whole `notmuchworker` suite, not only the three new cases — the refactor of `threadIdForTesting` touches four existing tests. + +- [ ] **Step 6: Commit** + +```bash +git add src/notmuchworker.h src/notmuchworker.cpp tests/test_notmuchworker.cpp +git commit -S -m "feat: resolve a Message-ID to its thread id + +For --message (item 200), which knows an id and needs the conversation: +opening a message means opening its thread with that message selected, never +an id: query showing one card out of a conversation (item 91). + +The id is quoted through SearchTerm, unlike every other id in this class. +Those came out of notmuch; this one comes off another program's command line, +and notmuch parses garbage happily while matching nothing, so an unquoted id +carrying query syntax would be read AS syntax with no error anywhere. + +threadIdForTesting() keeps its name and gains a shared helper rather than being +promoted: it is documented as not being a slot, and the new entry point has to +answer asynchronously. + +Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01P3HQXLauwQgzxR4YfJBB3x" +``` + +--- + +## Task 5: MainWindow::applySelectors() + +**Files:** +- Modify: `src/mainwindow.h` (public section around line 100, private slots around line 605) +- Modify: `src/mainwindow.cpp` (near `uiStatePath()` at line 92, the worker wiring at line 2681) +- Test: `tests/test_mainwindow.cpp` + +- [ ] **Step 1: Write the failing test** + +Add to the `private slots:` list in `tests/test_mainwindow.cpp`, after `aGeneratedStartupQueryActuallyRuns()`: + +```cpp + void theSocketPathIsUnderTheStateDirectory(); + void anAccountSelectorMovesTheDropdown(); + void anUnknownAccountSelectorLeavesTheDropdownAlone(); + void aThreadSelectorOpensThatThread(); + void anEmptySelectorSetChangesNothing(); +``` + +Add the bodies at the end of the file, before the `QTEST_MAIN` line: + +```cpp +void TestMainWindow::theSocketPathIsUnderTheStateDirectory() +{ + // Beside uistate.conf, and built the same way: GenericStateLocation, not + // StateLocation, because the latter appends both the organization and the + // application name and both are "qtmaildir". + const QString socket = MainWindow::singleInstanceSocketPath(); + const QString state = MainWindow::uiStatePath(); + + QVERIFY(!socket.isEmpty()); + QCOMPARE(QFileInfo(socket).absolutePath(), + QFileInfo(state).absolutePath()); + QVERIFY2(!socket.endsWith(QStringLiteral("/qtmaildir/qtmaildir")), + "StateLocation was used: the path doubles the application name"); +} + +void TestMainWindow::anAccountSelectorMovesTheDropdown() +{ + // --account work, with the config's own startup account being something + // else. The selector wins, which is what "open this account's view" means. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("qtmaildir.conf")); + { + QSettings s(path, QSettings::IniFormat); + // [general] keys are read WITHOUT the prefix: QSettings' INI backend + // treats a section literally named [general] as its own fallback. + s.setValue(QStringLiteral("startup_query"), QStringLiteral("Inbox")); + s.setValue(QStringLiteral("startup_account"), + QStringLiteral("personal")); + s.beginGroup(QStringLiteral("account.work")); + s.setValue(QStringLiteral("maildir"), QStringLiteral("work")); + s.endGroup(); + s.beginGroup(QStringLiteral("account.personal")); + s.setValue(QStringLiteral("maildir"), QStringLiteral("personal")); + s.endGroup(); + s.sync(); + } + Config config; + config.load(path); + + MainWindow window(config); + QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("personal")); + + LaunchSelectors selectors; + selectors.account = QStringLiteral("work"); + window.applySelectors(selectors); + + QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work")); +} + +void TestMainWindow::anUnknownAccountSelectorLeavesTheDropdownAlone() +{ + // The miss path. The window opens on its configured view and says so in + // the status bar; it does not clear the dropdown, and it does not refuse + // to start. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("qtmaildir.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.setValue(QStringLiteral("startup_account"), QStringLiteral("work")); + s.beginGroup(QStringLiteral("account.work")); + s.setValue(QStringLiteral("maildir"), QStringLiteral("work")); + s.endGroup(); + s.sync(); + } + Config config; + config.load(path); + + MainWindow window(config); + + LaunchSelectors selectors; + selectors.account = QStringLiteral("nosuchaccount"); + window.applySelectors(selectors); + + QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work")); + + // "statusMessage", which is the object name buildUi() sets at + // mainwindow.cpp:674. There is no widget named "statusLabel", and + // findChild would return null and assert nothing. + auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage")); + QVERIFY(status); + QVERIFY2(status->text().contains(QStringLiteral("nosuchaccount")), + "the miss must name the value, so a stale caller can be debugged"); +} + +void TestMainWindow::aThreadSelectorOpensThatThread() +{ + // --thread, against a real database. The row the selector names is the row + // that ends up current. + WorkerBackedWindow backed; + QVERIFY(backed.fixture().addMessage( + QStringLiteral("inbox"), QStringLiteral("one@example.org"), + QStringLiteral("First subject"), QStringLiteral("a@example.org"), + // Friday, verified with `date -d 2026-08-14 +%A`. Qt::RFC2822Date + // validates the weekday against the date. + QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"), + QStringLiteral("Body one."))); + QVERIFY(backed.fixture().addMessage( + QStringLiteral("inbox"), QStringLiteral("two@example.org"), + QStringLiteral("Second subject"), QStringLiteral("b@example.org"), + // Saturday, verified with `date -d 2026-08-15 +%A`. + QStringLiteral("Sat, 15 Aug 2026 10:00:00 +0200"), + QStringLiteral("Body two."))); + QVERIFY2(backed.build(), qPrintable(backed.error())); + + // The thread id is not knowable in advance, so it is read back from the + // index the same way the CLI's caller would have obtained it. + NotmuchWorker probe(backed.config().notmuchConfig()); + const QString threadId = + probe.threadIdForTesting(QStringLiteral("id:two@example.org")); + QVERIFY(!threadId.isEmpty()); + + MainWindow window(backed.config()); + auto *model = window.findChild<ThreadListModel *>(); + QVERIFY(model); + auto *view = window.findChild<ThreadListView *>(); + QVERIFY(view); + + LaunchSelectors selectors; + selectors.threadId = threadId; + window.applySelectors(selectors); + + // QTRY, never qWait: the worker is on another thread and a fixed sleep + // passes when the result never arrives at all. + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + + // Asserted on the thread the row STANDS FOR, through threadFor(), never + // threadAt(index.row()): a tree numbers rows per parent. + QTRY_VERIFY_WITH_TIMEOUT(view->currentIndex().isValid(), 15000); + QCOMPARE(model->threadFor(view->currentIndex()).threadId, threadId); +} + +void TestMainWindow::anEmptySelectorSetChangesNothing() +{ + // A bare `qtmaildir` against a running window means "raise yourself". It + // must not re-run a query or move the selection: the user is looking at + // something, and a raise is not a navigation. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("qtmaildir.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.setValue(QStringLiteral("startup_account"), QStringLiteral("work")); + s.beginGroup(QStringLiteral("account.work")); + s.setValue(QStringLiteral("maildir"), QStringLiteral("work")); + s.endGroup(); + s.sync(); + } + Config config; + config.load(path); + + MainWindow window(config); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(queryEdit); + queryEdit->setText(QStringLiteral("tag:flagged")); + + window.applySelectors(LaunchSelectors()); + + QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work")); + QCOMPARE(queryEdit->text(), QStringLiteral("tag:flagged")); +} +``` + +Add the include at the top of `tests/test_mainwindow.cpp`, with the other project includes: + +```cpp +#include "launchselectors.h" +``` + +Nothing else needs adding: `QLabel`, `QFileInfo` (via `QDir`/`QtTest`), `threadlistmodel.h` and `threadlistview.h` are already included in that file. + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cmake --build build 2>&1 | tail -5 +``` + +Expected: FAILS to compile, `'applySelectors' is not a member of 'MainWindow'`. + +- [ ] **Step 3: Declare the new members** + +In `src/mainwindow.h`, add to the `public:` section, right after the `uiStatePath()` declaration (around line 165): + +```cpp + /// Path of the single-instance socket (item 200). + /// + /// Beside uiStatePath() and built the same way, so the two cannot drift. + /// GenericStateLocation, not StateLocation: the latter appends both the + /// organization and the application name, and both are "qtmaildir". + static QString singleInstanceSocketPath(); +``` + +And to the `public:` section around line 100, after `pendingChangeSnapshot()`: + +```cpp + /// Applies what a launch asked for (item 200). + /// + /// ONE entry point, called both by main() at startup and by the socket + /// handler when a later launch arrives. Two paths through separate code + /// would drift, which is the lesson this file has already learned from + /// every other pair. + /// + /// An EMPTY selector set deliberately changes nothing: a bare launch + /// against a running window means "raise yourself", and a raise is not a + /// navigation. The user is looking at something. + /// + /// A selector that matches nothing leaves the window on its configured + /// view and names the miss in the status bar. Not an empty result, which + /// makes a stale link look like a broken client; not a refusal, which is + /// right for a script and wrong for a desktop launch. + void applySelectors(const LaunchSelectors &selectors); +``` + +Add the include at the top of `src/mainwindow.h`: + +```cpp +#include "launchselectors.h" +``` + +And in the `private slots:` section, beside `recoverStaleThread`: + +```cpp + /// The worker's answer to a --message selector. + void onThreadForMessageResolved(const QString &messageId, + const QString &threadId); +``` + +- [ ] **Step 4: Write the implementation** + +In `src/mainwindow.cpp`, add after `uiStatePath()` (which ends at line 101): + +```cpp +QString MainWindow::singleInstanceSocketPath() +{ + // Built exactly like uiStatePath(), including the GenericStateLocation + // choice and the reason for it. A socket is machine-written state, so it + // belongs beside the UI state and never in the hand-edited config + // directory. + const QString base = + QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation); + return base + QStringLiteral("/qtmaildir/qtmaildir.sock"); +} +``` + +Add the method body, near the other public methods: + +```cpp +void MainWindow::applySelectors(const LaunchSelectors &selectors) +{ + // Nothing asked for. A bare launch against a running window means "raise + // yourself", which main() and the socket handler do around this call; from + // here there is nothing to change, and re-running a query would take the + // user off whatever they were reading. + if (selectors.isEmpty()) + return; + + // The account FIRST, and the order matters: a built-in filter composes + // with the dropdown, so a query run before the account moved would carry + // the old scope. This is the same ordering the startup path uses. + if (!selectors.account.isEmpty()) { + const int index = m_accountBox->findData(selectors.account); + if (index >= 0) { + m_accountBox->setCurrentIndex(index); + } else { + // Named, so a caller passing a stale key can be debugged from the + // client rather than from the caller. + showTransientStatus( + tr("No account named '%1'.").arg(selectors.account)); + } + } + + // A message id names a message INSIDE a conversation, so it has to be + // resolved to its thread before anything can be opened. Asked of the + // worker, which owns the only database handle; the answer arrives in + // onThreadForMessageResolved(). + if (!selectors.messageId.isEmpty()) { + if (m_worker) { + QMetaObject::invokeMethod( + m_worker, "resolveThreadForMessage", Qt::QueuedConnection, + Q_ARG(QString, selectors.messageId)); + } + // The thread selector, if any, is deliberately NOT also applied here: + // the message's own thread is what will open, and running a second + // query underneath it would race the one the resolve is about to + // start. + return; + } + + if (!selectors.threadId.isEmpty()) { + // recoverStaleThread() is reused whole. It runs thread:<id>, remembers + // the target across the two queued round trips the load takes, expands + // the thread when its row arrives and selects the message once the + // replies land. Item 91's double-click already reuses it; this is the + // third caller. + // + // The empty message id is meaningful to it: land on the ROOT row, + // which is the thread's first message. + recoverStaleThread(selectors.threadId, QString()); + } +} + +void MainWindow::onThreadForMessageResolved(const QString &messageId, + const QString &threadId) +{ + if (threadId.isEmpty()) { + // The miss path, and the window stays where it is. A message id from + // another program can be stale for every ordinary reason: the mail was + // deleted, moved by another client, or never indexed here. + showTransientStatus(tr("No message matched '%1'.").arg(messageId)); + return; + } + + // The THREAD, with that message selected. An id: query on the message + // alone would show one card out of its conversation, which item 91 settled + // is the wrong reading of "open this message". + recoverStaleThread(threadId, messageId); +} +``` + +In `wireWorker()`, add beside the other worker connections (after the `messageLoaded` connect at line 2691): + +```cpp + connect(m_worker, &NotmuchWorker::threadForMessageResolved, + this, &MainWindow::onThreadForMessageResolved); +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cmake --build build && ctest --test-dir build -R mainwindow --output-on-failure +``` + +Expected: PASS. The whole `mainwindow` suite, not only the five new cases. + +- [ ] **Step 6: Commit** + +```bash +git add src/mainwindow.h src/mainwindow.cpp tests/test_mainwindow.cpp +git commit -S -m "feat: apply the launch selectors to the window + +One entry point, called both at startup and by the socket handler when a later +launch arrives. Two paths would drift, which is the lesson this file has +already learned from every other pair. + +The account moves first, because a built-in filter composes with the dropdown +and a query run before it would carry the old scope. The thread case reuses +recoverStaleThread() whole, as item 91's double-click already does. A message +id resolves to its thread first: opening a message means opening its +conversation with that message selected. + +An empty selector set changes nothing. A bare launch against a running window +means raise yourself, and a raise is not a navigation. + +Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01P3HQXLauwQgzxR4YfJBB3x" +``` + +--- + +## Task 6: Wiring it into main() + +**Files:** +- Modify: `src/main.cpp:38-66` and `src/main.cpp:138-152` + +**No test for this task.** `main.cpp` is not compiled into `qtmaildir_lib` (see `src/CMakeLists.txt:67`, only the executable compiles it), so no test binary can reach it. That is why Tasks 2, 3 and 5 put every decidable thing in the library: what is left here is wiring, and it is verified by the hand test in Task 8. + +- [ ] **Step 1: Replace the argument loop** + +In `src/main.cpp`, replace the whole `for (int i = 1; i < argc; ++i)` block (lines 44-66) with: + +```cpp + // Answered before anything heavier starts: registering web engine schemes + // and constructing a QApplication to print one line would be absurd, and + // --version has to work on a machine where the GUI cannot open at all. + // + // Still hand-checked rather than left to QCommandLineParser: Qt's own + // addVersionOption()/addHelpOption() exit through QCoreApplication, which + // does not exist yet at this point. + QStringList arguments; + arguments.reserve(argc); + for (int i = 0; i < argc; ++i) + arguments.append(QString::fromLocal8Bit(argv[i])); + + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--version") == 0 + || std::strcmp(argv[i], "-v") == 0) { + std::printf("qtmaildir %s\n", QTMAILDIR_VERSION_DISPLAY); + return 0; + } + if (std::strcmp(argv[i], "--help") == 0 + || std::strcmp(argv[i], "-h") == 0) { + // The text lives with the parser, so the options and their + // descriptions cannot drift apart. + std::printf("%s", + LaunchSelectors::helpText( + QStringLiteral(QTMAILDIR_VERSION_DISPLAY)) + .toLocal8Bit() + .constData()); + return 0; + } + } + + QString selectorError; + const LaunchSelectors selectors = + LaunchSelectors::parse(arguments, &selectorError); + if (!selectorError.isEmpty()) { + std::fprintf(stderr, "qtmaildir: %s\n", + selectorError.toLocal8Bit().constData()); + return 2; + } +``` + +Add the includes at the top of `src/main.cpp`, with the other project includes: + +```cpp +#include "launchselectors.h" +#include "singleinstance.h" +``` + +- [ ] **Step 2: Add the connect-or-listen step** + +In `src/main.cpp`, immediately after the `QApplication app(argc, argv);` block and its `setApplicationName`/`setOrganizationName`/`setApplicationVersion` calls (which end around line 83), insert: + +```cpp + // Connect first, become the server only if that fails. A live instance is + // handed the selectors and this process exits without ever opening a + // database: notmuch permits one handle per process, so two windows are two + // handles, which this avoids as a side effect of the feature. + // + // On its own stack frame in main(), like the QTranslator below: it owns the + // socket for the life of the process and must outlive exec(). + SingleInstance instance(MainWindow::singleInstanceSocketPath()); + if (!instance.tryBecomeServer()) { + if (instance.sendToRunningInstance(selectors)) + return 0; + // No running instance answered and no socket could be created either. + // Carry on and open a window: losing single-instance behaviour is a + // degradation, and losing the mail client is not acceptable. + } +``` + +- [ ] **Step 3: Apply the selectors and handle later launches** + +In `src/main.cpp`, replace the `MainWindow window(config); window.show();` pair and what follows (lines 138-152) with: + +```cpp + MainWindow window(config); + window.show(); + + // What this launch asked for. After show(), so the window is up before a + // query starts running against it. + window.applySelectors(selectors); + + // A later launch. The selectors arrive on the socket and go through the + // same applySelectors() this startup path just used. + QObject::connect(&instance, &SingleInstance::selectorsReceived, &window, + [&window](const LaunchSelectors &arrived) { + // Raised whatever the selectors say, an empty set + // included: a bare launch against a running window + // means "show me the window". + // + // Under Wayland this is a REQUEST, not a command. The + // compositor may honour it as a focus hint or ignore + // it by policy, which is its decision and not a defect + // to work around: the selectors still apply and the + // window still shows the right thing. + window.setWindowState(window.windowState() + & ~Qt::WindowMinimized); + window.show(); + window.raise(); + window.activateWindow(); + window.applySelectors(arrived); + }); + + // After show(), and out here rather than inside the constructor. A modal + // raised from the constructor cannot be dismissed under the offscreen + // platform, so it hung the test suite with no output (item 84). Showing it + // here also gives the dialog a visible parent to sit on. + const QStringList problems = window.configProblems(); + if (!problems.isEmpty()) { + QMessageBox::warning(&window, QObject::tr("Configuration problems"), + problems.join(QLatin1Char('\n'))); + } + + return app.exec(); +``` + +- [ ] **Step 4: Build and run the whole suite** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure +``` + +Expected: every test passes. A failure in a suite this task did not touch means the new library sources broke something; fix it before committing. + +- [ ] **Step 5: Commit** + +```bash +git add src/main.cpp +git commit -S -m "feat: accept the launch selectors on the command line + +Connect first, become the server only if that fails. A live instance is handed +the selectors and this process exits without ever opening a database, so two +launches no longer mean two notmuch handles. + +--version and --help stay hand-checked rather than going through +QCommandLineParser: Qt's own versions exit through QCoreApplication, which does +not exist at that point, and --version has to work where the GUI cannot open. + +Raising under Wayland is a request rather than a command. The compositor may +honour it as a focus hint or ignore it by policy; the selectors apply either +way, which is the half that has to work. + +Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01P3HQXLauwQgzxR4YfJBB3x" +``` + +--- + +## Task 7: Translations and documentation + +**Files:** +- Modify: `translations/qtmaildir_it_IT.ts` +- Modify: `README.md` (a new section after `## Requirements`, line 30) +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Refresh the translation source** + +```bash +lupdate-qt6 src/ -ts translations/qtmaildir_it_IT.ts -no-obsolete -locations none +``` + +Expected: a clean run reporting **zero context warnings**. A warning saying `tr() cannot be called without context` means a literal needs `QT_TRANSLATE_NOOP("TheClass", "Text")` rather than a bare `tr()`; the strings added by this plan are all inside classes or use `QCoreApplication::translate` with an explicit context, so a warning here is a real defect to fix. + +- [ ] **Step 2: Translate the new strings** + +Fill in every `<translation type="unfinished">` that this change introduced. The new strings and their Italian: + +| Source | Italian | +|---|---| +| `Open this account's view.` | `Apre la vista di questo account.` | +| `Open this thread.` | `Apre questa conversazione.` | +| `Open this message, inside its thread.` | `Apre questo messaggio, nella sua conversazione.` | +| `Unknown option: %1` | `Opzione sconosciuta: %1` | +| `Launch payload too large` | `Dati di avvio troppo grandi` | +| `Unrecognised launch payload` | `Dati di avvio non riconosciuti` | +| `Truncated launch payload` | `Dati di avvio troncati` | +| `No account named '%1'.` | `Nessun account chiamato '%1'.` | +| `No message matched '%1'.` | `Nessun messaggio corrisponde a '%1'.` | + +The `helpText()` block is one long string; translate the prose lines and **leave the option names (`--account`, `--thread`, `--message`, `-h`, `-v`) exactly as they are**. They are wire format: a translated option name is an option the user cannot type. + +- [ ] **Step 3: Verify the translations compile and the suite agrees** + +```bash +cmake --build build && ctest --test-dir build -R translations --output-on-failure +``` + +Expected: PASS. `lrelease` must report **0 unfinished**: it silently DROPS an unfinished string and ships it as English inside an otherwise Italian UI. + +- [ ] **Step 4: Add the README section** + +In `README.md`, insert a new section after `## Requirements` (which begins at line 30) and before `## Building`: + +```markdown +## Usage + +``` +qtmaildir [options] + + -h, --help Show this help and exit + -v, --version Show the version and exit + --account <key> Open this account's view + --thread <id> Open this thread + --message <id> Open this message, inside its thread +``` + +The three selectors combine: `--account work --message '<abc@example.org>'` +opens that message in the work account's view. + +**A second launch does not open a second window.** When qtmaildir is already +running, a launch hands its selectors to the running window, asks it to raise +itself, and exits. This is what lets another program, a notification or a +script open a particular message in the client the user already has open. It +also means one process, and so one notmuch database handle. + +Under a Wayland compositor, raising a window is a request rather than a +command: the compositor may honour it, or apply its own focus policy. The +selectors are applied either way. + +A selector that matches nothing, a stale thread id or an account key that is +not configured, leaves the window on its normal startup view and says what +missed in the status bar. It is never a reason to refuse to start. +``` + +- [ ] **Step 5: Add the changelog entry** + +In `CHANGELOG.md`, under `## [Unreleased]`, add: + +```markdown +### Added + +- `--account`, `--thread` and `--message` on the command line, so another + program can open qtmaildir at a particular account's view, conversation or + message. The three combine. +- A second launch now hands its selectors to the already-running window and + asks it to raise itself, rather than opening a second window. One process, + and so one notmuch database handle. +``` + +- [ ] **Step 6: Commit** + +```bash +git add translations/qtmaildir_it_IT.ts README.md CHANGELOG.md +git commit -S -m "docs: document the launch selectors and translate them + +The option names stay untranslated in the Italian help text: they are wire +format, and a translated option name is an option the user cannot type. + +Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01P3HQXLauwQgzxR4YfJBB3x" +``` + +--- + +## Task 8: Hand over for the hand test + +**Files:** none. + +Three properties of this feature cannot be tested here, for reasons `AGENTS.md` records, and they are handed to the user rather than covered by a test that would pass whatever the code does. + +- [ ] **Step 1: Run the full suite one more time** + +```bash +ctest --test-dir build --output-on-failure +``` + +Expected: all tests pass. Report the actual count rather than asserting success. + +- [ ] **Step 2: Verify the clean version string** + +```bash +cmake -S . -B /tmp/qtmaildir-clean -G Ninja -DCMAKE_BUILD_TYPE=Debug \ + -DQTMAILDIR_BUILD_NUMBER=OFF && cmake --build /tmp/qtmaildir-clean +/tmp/qtmaildir-clean/src/qtmaildir --version +``` + +Expected: a bare `qtmaildir X.Y.Z`, with no build number. This confirms `--version` still answers before `QApplication` exists, which is the one property the parser rewrite could quietly have broken. + +Note this is `--version` only, which prints and exits. **Do not launch the GUI.** + +- [ ] **Step 3: Hand it over** + +Tell the user the branch is ready and what to look at. Do not launch the application; running it is theirs. + +What to ask them to check: + +1. **With qtmaildir closed**, run `qtmaildir --account <one of their keys>` and confirm the window opens on that account's view. +2. **With qtmaildir already open**, run the same command from another terminal and confirm **no second window appears**, the existing window switches account, and it comes to the front. Whether it takes focus is Hyprland's decision; the switch is the part that must work. +3. **A stale selector**: `qtmaildir --thread 0000000000000000` against the running window. Expect the normal view and a status-bar line naming the id. +4. **`qtmaildir --message '<some real Message-ID>'`**, taken from a message they can see, and confirm the conversation opens with that message selected rather than one card on its own. +5. **`qtmaildir --nonsense`**, and confirm it prints the error and exits 2 rather than opening a window. + +--- + +## Self-review notes + +**Spec coverage.** Every section of `2026-09-13-cli-selectors-design.md` maps to a task: the command line to Task 2, single instance to Task 3, the Message-ID round trip to Task 4, applying the selectors and the miss path to Task 5, raising and the startup order to Task 6, translations/docs to Task 7, and the hand test to Task 8. The `Qt6::Network` constraint is Task 1. + +**One deviation from the spec, deliberately.** The spec says `QCommandLineParser` "replaces the `strcmp` loop". It replaces it for the three selectors; `--version` and `--help` keep their hand-check, because Qt's `addVersionOption()`/`addHelpOption()` exit through `QCoreApplication`, which does not exist at that point in `main()`. The spec's real constraint, that those two must answer without a `QApplication`, is met. + +**One thing the spec did not know.** `NotmuchWorker::threadIdForTesting()` already performs the Message-ID lookup synchronously and has four existing callers. Task 4 adds a slot beside it over a shared private helper rather than promoting it, because the new entry point must answer by signal while the existing one returns a value. |
