# Test-labeler Implementation Plan — Phase 1

**Goal:** Get the binary crate compiling with a clap-derived subcommand tree, tokio async runtime, miette-installed error handling, and a stub `test labeler` command that parses its arguments and prints a "not yet implemented" message.

**Architecture:** Single-crate Rust 2024 binary. Root `Cli` in `cli.rs` dispatches to a `Command` enum in `commands.rs`, which nests a `Test` enum in `commands/test.rs`, which holds the `Labeler(LabelerCmd)` variant in `commands/test/labeler.rs`. Sibling-file module layout (no `mod.rs`). Shared cross-feature primitives live under `common.rs` and `common/`.

**Tech Stack:** `clap` (derive), `tokio` (`rt`, `macros`, `time`, `net`), `miette` (`fancy`), `thiserror`, `tracing`, `tracing-subscriber` (`env-filter`, `fmt`), `url`.

**Scope:** Phase 1 of 6 (Phase 1 in the source design).

**Codebase verified:** 2026-04-13

---

## Acceptance Criteria Coverage

**Verifies: None.**

Phase 1 is an infrastructure/bootstrap phase. The design's "Done when" specifies operational verification (`cargo build` succeeds, `--help` renders, stub invocation prints a placeholder). Acceptance criteria for CLI invocation modes (`test-labeler.AC1.*`) and all later stages arrive in Phases 2–6 as the stages they test come online. Explicitly, **no AC cases from the design are tested in this phase**.

---

## Codebase state at start of phase

Verified 2026-04-13:

- **`Cargo.toml`** exists at repository root. Package name `atproto-devtool`, version `0.0.0`, Rust edition `2024`, `rust-version = "1.85"`, dual-licensed `MIT OR Apache-2.0`. `[dependencies]` table is **empty**.
- **`src/main.rs`** exists containing only the default `fn main() { println!("Hello, world!"); }` stub. Not yet async; nothing imported.
- No other `src/` modules. No `tests/`, no `examples/`, no `benches/`.
- No `CLAUDE.md`, no `AGENTS.md`. Global Rust conventions (user `~/.claude/CLAUDE.md`) apply: sibling-file module pattern, Rust 2024, `#[expect]` not `#[allow]`, sentence-case headings, Oxford commas, no fully-qualified `std::fmt::Display`, no workspace for single-crate projects, comments end with periods.
- `docs/design-plans/2026-04-13-test-labeler.md` present and committed. `docs/implementation-plans/2026-04-13-test-labeler/` is being populated by this plan.
- `.gitignore` contains `/target` and `/.worktrees/`.
- No existing patterns to follow — this phase lays the foundation.

**Implication for tasks below:** every file listed under "Create" is genuinely new. No "create or update" conditionals anywhere. `Cargo.toml`'s `[dependencies]` table is the only pre-existing file that is _modified_.

---

## External dependency notes

Crate versions verified against `cargo read` on 2026-04-13:

- `clap` 4.6.0 — derive macros via `features = ["derive"]`. Rust-version 1.85, edition 2024.
- `tokio` 1.51.1 — features `rt`, `macros`, `time`, `net` are sufficient for the single-threaded `current_thread` runtime this binary uses. `tokio::main(flavor = "current_thread")` attribute macro requires the `macros` feature.
- `miette` 7.6.0 — `features = ["fancy"]` pulls in `GraphicalReportHandler`, the panic handler (`miette::set_panic_hook`), the `MietteHandlerOpts` builder used to honour `NO_COLOR`, and the `fancy` renderer for `Diagnostic` types. `miette::Result<T>` is `Result<T, miette::Report>`.
- `thiserror` 2.0.18 — `#[derive(thiserror::Error)]` for typed error enums returned from stage code.
- `tracing` 0.1.44 and `tracing-subscriber` 0.3.23 — subscriber built with `EnvFilter` + `fmt` layer writing to `stderr`. Filter directive upgraded to `debug` when `--verbose` is passed.
- `url` 2.5.8 — newtype wrapper around parsed URLs used by every later stage; pulled in early so CLI parsing can reject non-URL targets with a clap error.

There are no non-obvious gotchas for this infrastructure phase. The current-thread tokio flavor matches the single-sequential-stage nature of the pipeline and avoids needlessly pulling `tokio` multi-thread scheduler code.

---

<!-- START_TASK_1 -->
### Task 1: Populate `Cargo.toml` with Phase 1 dependencies

**Files:**
- Modify: `Cargo.toml` (the `[dependencies]` table, currently empty, lines 9–10)

**Implementation:**

Add the following dependencies under `[dependencies]`, keeping the existing `[package]` section unchanged. Group in alphabetical order within a single table (the crate is not a workspace yet, per the user's global rule: "Only create a Cargo workspace when the project genuinely has two or more crates"):

```toml
[dependencies]
clap = { version = "4.6", features = ["derive"] }
humantime = "2.1"
miette = { version = "7.6", features = ["fancy"] }
thiserror = "2.0"
tokio = { version = "1.51", features = ["rt", "macros", "time", "net"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
url = "2.5"
```

`humantime` is used by Task 4's `parse_subscribe_timeout` value parser — using a maintained crate is preferable to hand-rolling a duration parser in the CLI layer.

No dev-dependencies in this phase.

**Verification:**

Run: `cargo check`
Expected: compiles successfully (the stub `src/main.rs` remains unchanged at this point and still prints "Hello, world!"; the new dependencies resolve and download).

Run: `cargo build`
Expected: succeeds.

**Commit:**

```bash
git add Cargo.toml Cargo.lock
git commit -m "Add Phase 1 dependencies for CLI bootstrap"
```
<!-- END_TASK_1 -->

<!-- START_TASK_2 -->
### Task 2: Create `src/common.rs` + `src/common/diagnostics.rs` skeleton

**Files:**
- Create: `src/common.rs`
- Create: `src/common/diagnostics.rs`

**Implementation:**

`src/common.rs` declares the `common` module tree. Because this project uses the sibling-file layout (no `mod.rs`), `common.rs` contains only `pub mod` declarations:

```rust
//! Cross-feature primitives shared by every `atproto-devtool` subcommand.

pub mod diagnostics;
```

`src/common/diagnostics.rs` holds miette helpers that every feature will need. Phase 1 seeds it with just two items: installation of the miette handler respecting `NO_COLOR`, and a thin wrapper type for a re-usable `NamedSource` constructed from raw bytes. Both are used by Phase 3 onwards, but installing them here lets `main.rs` wire the handler without reaching into a feature module.

```rust
//! Shared miette configuration and `NamedSource` helpers.

use std::sync::Arc;

use miette::{GraphicalTheme, MietteHandlerOpts, NamedSource};

// Re-exports so Phase 3 onwards can import `LabeledSpan` / `SourceSpan` from a
// single path. Unused in Phase 1 itself; declared here to keep the diagnostics
// surface area in one file.
pub use miette::{LabeledSpan, SourceSpan};

/// Install the miette panic hook and graphical report handler.
///
/// Honours `NO_COLOR=1` by dropping to an unstyled theme. Call this exactly once
/// from `main` before any `miette::Result`-returning code runs.
pub fn install_miette_handler(no_color: bool) -> miette::Result<()> {
    // `NO_COLOR` is also respected automatically by miette when set in the
    // environment; passing an explicit theme here covers the `--no-color` flag
    // path without having to touch process-wide env vars (which is `unsafe` in
    // Rust 2024).
    miette::set_hook(Box::new(move |_| {
        let theme = if no_color {
            GraphicalTheme::unicode_nocolor()
        } else {
            GraphicalTheme::unicode()
        };
        Box::new(
            MietteHandlerOpts::new()
                .graphical_theme(theme)
                .context_lines(3)
                .build(),
        )
    }))?;

    // Install miette's panic hook so panics render through the same handler.
    miette::set_panic_hook();

    Ok(())
}

/// Build a `NamedSource` from a name and raw bytes.
///
/// The bytes are cloned into an `Arc<str>`/`Arc<[u8]>` via miette's constructor,
/// so callers may drop the original slice after this returns.
pub fn named_source_from_bytes(name: impl Into<String>, bytes: &[u8]) -> NamedSource<Arc<[u8]>> {
    NamedSource::new(name, Arc::<[u8]>::from(bytes))
}

/// Build a `NamedSource` from a name and a UTF-8 string slice.
pub fn named_source_from_str(name: impl Into<String>, text: &str) -> NamedSource<String> {
    NamedSource::new(name, text.to_string())
}
```

**Note on the panic hook:** `miette::set_panic_hook` is distinct from `miette::set_hook` — the former replaces `std::panic::take_hook`, the latter installs the runtime `ReportHandler`. Both are needed for the UX described in the design.

**Verification:**

Run: `cargo check`
Expected: compiles. No references from `main.rs` yet; the module is unused until Task 5 wires it.

**Commit:**

```bash
git add src/common.rs src/common/diagnostics.rs
git commit -m "Add common::diagnostics scaffold"
```
<!-- END_TASK_2 -->

<!-- START_SUBCOMPONENT_A (tasks 3-4) -->
<!-- START_TASK_3 -->
### Task 3: Create `src/cli.rs` with the root `Cli` parser

**Files:**
- Create: `src/cli.rs`

**Implementation:**

`src/cli.rs` defines the root clap `Parser` struct and its `run` entry point. `Command` enum dispatch lives in its own file (Task 4) so new features can be added with a one-line enum edit.

```rust
//! Root clap parser and dispatch entry point.

use clap::Parser;
use miette::Result;
use tracing_subscriber::{EnvFilter, fmt};

use crate::commands::Command;
use crate::common::diagnostics::install_miette_handler;

/// Top-level `atproto-devtool` CLI.
#[derive(Debug, Parser)]
#[command(
    name = "atproto-devtool",
    version,
    about = "Diagnostics and conformance tooling for atproto services.",
    long_about = None,
)]
pub struct Cli {
    /// Enable verbose (DEBUG-level) logging to stderr.
    #[arg(long, global = true)]
    verbose: bool,

    /// Disable ANSI color in rendered diagnostics.
    #[arg(long, global = true)]
    no_color: bool,

    #[command(subcommand)]
    command: Command,
}

/// Parse `std::env::args()`, install global handlers, and dispatch.
///
/// Returns `miette::Result<()>` so main can print the report via the installed
/// handler. An `Err` here produces exit code 1; clap parse errors already exit
/// with code 2 before this function is called.
pub async fn run() -> Result<()> {
    let cli = Cli::parse();

    install_miette_handler(cli.no_color)?;
    install_tracing(cli.verbose);

    cli.command.run().await
}

fn install_tracing(verbose: bool) {
    let default_filter = if verbose { "debug" } else { "warn" };
    let filter = EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| EnvFilter::new(default_filter));

    let _ = fmt()
        .with_env_filter(filter)
        .with_writer(std::io::stderr)
        .try_init();
}
```

**Verification:**

Run: `cargo check`
Expected: compile error — `crate::commands::Command` does not yet exist. That is expected; Task 4 creates it and is the next task. Proceed to Task 4 **without** committing Task 3 in isolation.

**Important:** Tasks 3 and 4 are co-dependent and form Subcomponent A. Task 4 creates the `commands::Command` type `cli.rs` references. **Do not commit Task 3 on its own — it will not compile.** Commit at the end of Task 4 with both files staged together.
<!-- END_TASK_3 -->

<!-- START_TASK_4 -->
### Task 4: Create `src/commands.rs` + nested `test` / `labeler` subcommand skeleton

**Files:**
- Create: `src/commands.rs`
- Create: `src/commands/test.rs`
- Create: `src/commands/test/labeler.rs`

**Implementation:**

`src/commands.rs` declares the `Command` enum plus the `commands` module tree. Only one top-level variant exists today (`Test`); future features extend this enum.

```rust
//! Top-level subcommand dispatch.

use clap::Subcommand;
use miette::Result;

pub mod test;

use self::test::TestCmd;

#[derive(Debug, Subcommand)]
pub enum Command {
    /// Conformance and diagnostic checks against atproto services.
    #[command(subcommand)]
    Test(TestCmd),
}

impl Command {
    pub async fn run(self) -> Result<()> {
        match self {
            Command::Test(cmd) => cmd.run().await,
        }
    }
}
```

`src/commands/test.rs` declares the `TestCmd` enum and its own submodule tree:

```rust
//! `atproto-devtool test ...` subcommand tree.

use clap::Subcommand;
use miette::Result;

pub mod labeler;

use self::labeler::LabelerCmd;

#[derive(Debug, Subcommand)]
pub enum TestCmd {
    /// Run the labeler conformance suite against an atproto labeler.
    Labeler(LabelerCmd),
}

impl TestCmd {
    pub async fn run(self) -> Result<()> {
        match self {
            TestCmd::Labeler(cmd) => cmd.run().await,
        }
    }
}
```

`src/commands/test/labeler.rs` holds the clap `Args` struct and a stub `run` that prints a placeholder. Later phases flesh out the pipeline entry point; the signature `pub async fn run(self) -> Result<()>` is final.

Phase 1 intentionally accepts the target as a single `String` (not yet parsed into a typed `LabelerTarget`) to avoid pulling in identity primitives before Phase 2. The final `--subscribe-timeout` and `--did` flags are _declared_ here so `--help` is complete, matching the design's AC1.6, but the run body does not yet use them. No AC is tested in this phase.

```rust
//! `atproto-devtool test labeler <target>` command.

use std::time::Duration;

use clap::Args;
use miette::Result;

/// Run the labeler conformance suite against a handle, DID, or endpoint URL.
#[derive(Debug, Args)]
pub struct LabelerCmd {
    /// Handle (`alice.example`), DID (`did:plc:...` / `did:web:...`), or labeler endpoint URL.
    pub target: String,

    /// Explicit DID override. Required (and combined with the target URL) when
    /// `target` is a raw endpoint URL and you want identity/crypto checks to run.
    #[arg(long)]
    pub did: Option<String>,

    /// Per-connection time budget for the subscription-layer checks.
    ///
    /// Minimum 1 second; values below 1 second are rejected at parse time.
    #[arg(
        long,
        default_value = "5s",
        value_parser = parse_subscribe_timeout,
    )]
    pub subscribe_timeout: Duration,
}

impl LabelerCmd {
    pub async fn run(self) -> Result<()> {
        println!("atproto-devtool test labeler (not yet implemented)");
        println!("  target            = {}", self.target);
        println!("  did               = {:?}", self.did);
        println!("  subscribe_timeout = {:?}", self.subscribe_timeout);
        Ok(())
    }
}

pub(crate) fn parse_subscribe_timeout(raw: &str) -> Result<Duration, String> {
    let parsed = humantime::parse_duration(raw)
        .map_err(|e| format!("invalid --subscribe-timeout value `{raw}`: {e}"))?;

    if parsed < Duration::from_secs(1) {
        return Err(format!(
            "--subscribe-timeout must be at least 1 second (got {parsed:?})"
        ));
    }

    Ok(parsed)
}
```

`humantime::parse_duration` accepts `5s`, `1500ms`, `2m`, `1h30m`, etc. — strictly richer than a hand-rolled parser, and maintained. `parse_subscribe_timeout` is marked `pub(crate)` so Phase 5 Task 4 can test it directly for `test-labeler.AC4.7`.

Note on `--subscribe-timeout`: the design's `test-labeler.AC4.7` requires that `--subscribe-timeout 0` (and other invalid durations) be rejected as a clap parse error. Phase 1 declares the flag with the value parser so the help text and validation rejection are in place; later phases (Phase 5) wire the parsed value into the subscription stage and add the AC4.7 test.

**Verification:**

With Task 3's `src/cli.rs` already on disk, run: `cargo check`
Expected: compiles cleanly.

Run: `cargo build`
Expected: succeeds.

Run: `cargo run -- test labeler --help`
Expected: renders help text including the `--did`, `--subscribe-timeout`, and `--verbose`/`--no-color` global flags. (Manual visual check; no snapshot yet.)

**Commit:** commit Tasks 3 and 4 together.

```bash
git add src/cli.rs src/commands.rs src/commands/test.rs src/commands/test/labeler.rs
git commit -m "Add CLI root and test labeler subcommand stub"
```
<!-- END_TASK_4 -->
<!-- END_SUBCOMPONENT_A -->

<!-- START_TASK_5 -->
### Task 5: Add `src/lib.rs` and rewrite `src/main.rs` as a tokio + miette bootstrap

**Files:**
- Create: `src/lib.rs`
- Modify: `src/main.rs` (replace existing `fn main()` at lines 1–3 in its entirety)

**Why both a library and a binary:** Rust integration tests under `tests/*.rs` link against the crate as an external library. A pure-binary crate (one with only `src/main.rs`) cannot be exercised from `tests/*.rs` — integration tests would be unable to reach `crate::commands::test::labeler::pipeline::run_pipeline` or the other per-stage entry points. Phases 3–6 all add `tests/*.rs` integration tests that construct fake clients and call into the pipeline directly, so Phase 1 must establish a library target up front. Cargo auto-detects the dual lib+bin layout when both `src/lib.rs` and `src/main.rs` are present; no `[[bin]]` or `[lib]` stanzas are required.

`src/lib.rs` contents:

```rust
//! `atproto-devtool` library crate.
//!
//! The binary at `src/main.rs` is a thin tokio bootstrap over [`cli::run`].
//! All pipeline modules are re-exported here so integration tests under
//! `tests/*.rs` can reach them as `atproto_devtool::commands::test::labeler::...`.

pub mod cli;
pub mod commands;
pub mod common;
```

`src/main.rs` contents:

```rust
//! `atproto-devtool` binary entry point.

use miette::Result;

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
    atproto_devtool::cli::run().await
}
```

**Verification:**

Run: `cargo build`
Expected: succeeds.

Run: `cargo run -- test labeler --help`
Expected: prints subcommand help including `--did`, `--subscribe-timeout`, `--verbose`, `--no-color`, and the positional `<TARGET>`. Exits 0.

Run: `cargo run -- test labeler example.bsky.social`
Expected: prints the "not yet implemented" placeholder with the parsed target, `did = None`, and `subscribe_timeout = 5s`. Exits 0.

Run: `cargo run -- test labeler --subscribe-timeout 500ms example.bsky.social`
Expected: clap parse error, exit code 2, stderr message contains `"at least 1 second"` (the floor check fires because `humantime` parses `500ms` successfully but `parse_subscribe_timeout` rejects anything under 1 second).

Run: `cargo run -- test labeler --subscribe-timeout 0 example.bsky.social`
Expected: clap parse error, exit code 2. Stderr contains humantime's `"time unit needed"` message (because `humantime::parse_duration` requires a unit suffix on every numeric component and rejects bare `0` at parse time, before the floor check runs). Any non-zero exit with a clap error is acceptable here — we are only verifying that bare-integer input is rejected, not the specific error text.

Run: `cargo run -- test labeler`
Expected: clap parse error, exit code 2, stderr message about the missing `<TARGET>` argument.

Run: `cargo run -- --help`
Expected: root help lists `test` subcommand and the global `--verbose` / `--no-color` flags.

**Commit:**

```bash
git add src/lib.rs src/main.rs
git commit -m "Wire tokio + miette bootstrap for atproto-devtool"
```
<!-- END_TASK_5 -->

<!-- START_TASK_6 -->
### Task 6: Final phase verification

**Files:** none modified.

**Steps:**

Run the full verification battery in order, from the crate root:

1. `cargo fmt --check` — expected: exit 0, no output.
2. `cargo clippy -- -D warnings` — expected: clean (no warnings and no errors). If clippy flags a stylistic nit in the stub duration parser, address it in this task rather than deferring.
3. `cargo build` — expected: succeeds.
4. `cargo run -- test labeler --help` — expected: help text as in Task 5.
5. `cargo run -- test labeler example.bsky.social` — expected: placeholder output as in Task 5.

**Done when:** all five commands succeed with the expected output. No commit is created by this task (it is verification-only) unless clippy surfaced a fix, in which case commit that fix atomically with message `chore: fix Phase 1 clippy lint`.
<!-- END_TASK_6 -->

---

## Phase 1 done-when checklist

- `cargo build` succeeds from a clean state.
- `cargo run -- test labeler --help` prints subcommand help with `--did`, `--subscribe-timeout`, `--verbose`, `--no-color`, and the positional `<TARGET>` argument.
- `cargo run -- test labeler example.bsky.social` prints the parsed-args placeholder and exits 0.
- `cargo run -- test labeler --subscribe-timeout 500ms example.bsky.social` exits 2 with a clap parse error mentioning the 1-second floor (`"at least 1 second"` substring).
- `cargo run -- test labeler --subscribe-timeout 0 example.bsky.social` exits 2 with a clap parse error (humantime's `"time unit needed"` message — the bare `0` is rejected at parse time before the floor check).
- `cargo clippy -- -D warnings` and `cargo fmt --check` are clean.
- Commit history contains the four atomic commits listed above (dependencies, `common::diagnostics`, CLI + subcommand scaffold, lib + main bootstrap).
- No acceptance criteria from the design plan are tested yet — this is an infrastructure phase by design.
