# onym crate refactor + transport facade — 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:** Refactor the `onym` crate to use a `Transport` facade and add a streaming orchestrator alongside `check_name`, without changing CLI behavior, so that a future `onym-wasm` crate can reuse the source adapters in the browser.

**Architecture:** Convert the repo to a Cargo workspace. Introduce a `Transport` trait with two methods (`fetch_json`, `head`) and a native `ReqwestTransport` impl. Refactor every `sources/*::check` function to take `&impl Transport` instead of `&reqwest::Client`. Replace `tokio::task::JoinSet` with `futures::stream::FuturesUnordered` so orchestration drops `Send` bounds (required for a future wasm32 target). Add a new `check_name_stream` API alongside the existing `check_name` that yields per-source progress via a callback.

**Tech Stack:** Rust 1.90 / edition 2024, `tokio` (macros + rt-multi-thread for the CLI binary only), `reqwest` (native HTTP), `futures` (`FuturesUnordered`, `LocalBoxFuture`), `serde` / `serde_json`, `async fn` in traits (stable since 1.75).

**Reference spec:** `docs/superpowers/specs/2026-04-24-onym-dev-website-design.md`

---

## File Structure

After this plan, the repo layout is:

```
onym/                                 # workspace root
  Cargo.toml                          # virtual workspace manifest
  Cargo.lock                          # unchanged location
  crates/
    onym/                             # the existing CLI crate, moved
      Cargo.toml                      # package manifest
      src/
        main.rs                       # constructs ReqwestTransport
        lib.rs                        # check_name + check_name_stream + re-exports
        transport.rs                  # NEW: Transport trait, types, ReqwestTransport
        classify.rs                   # unchanged
        errors.rs                     # unchanged
        output.rs                     # unchanged
        schema.rs                     # unchanged
        types.rs                      # unchanged
        validate.rs                   # unchanged
        sources/
          mod.rs                      # unchanged
          crates_io.rs                # check() takes &impl Transport
          pypi.rs                     # check() takes &impl Transport
          npm.rs                      # check() takes &impl Transport
          github.rs                   # check() takes &impl Transport
          rdap.rs                     # check() takes &impl Transport
```

**Responsibilities:**
- `transport.rs` — defines `Transport` trait, `JsonOutcome<T>`, `HeadOutcome`, `TransportError`, and `ReqwestTransport` impl. The trait is the seam between the source adapters and the underlying HTTP runtime.
- `lib.rs` — keeps `check_name` (batch API for CLI), adds `check_name_stream` (callback-driven for future wasm). Both consume `&impl Transport`.
- `sources/*.rs` — each adapter takes `&impl Transport`. Behavior (which URLs are hit, status-to-Status mapping) is identical to today.
- `main.rs` — constructs `ReqwestTransport::new()` (replaces the old `build_client()`) and passes it to `check_name`.

---

## Conventions

- All commands run from the workspace root unless otherwise stated.
- Use `cargo nextest run` for tests (per user's `CLAUDE.md`); fall back to `cargo test` only when noted.
- Use `git add <specific-files>` — never `git add -A` or `git add .` (per user's `CLAUDE.md`).
- Conventional Commits: `refactor(scope):`, `feat(scope):`, `chore:`, `test(scope):`.
- Do not reference plan/phase labels in commit messages — describe what changed.

---

## Task 1: Convert repo to a Cargo workspace

**Files:**
- Create: `Cargo.toml` (new workspace root manifest)
- Move: `src/` → `crates/onym/src/`
- Move: existing `Cargo.toml` → `crates/onym/Cargo.toml`
- Keep at root: `Cargo.lock`, `LICENSE`, `.gitignore`, `CLAUDE.md`

- [ ] **Step 1: Inspect the current package manifest**

Run: `cat Cargo.toml`
Expected: Single `[package]` section, dependencies block, `[profile.release]`. No `[workspace]`.

- [ ] **Step 2: Create the new directory and move the package**

Run:
```bash
mkdir -p crates/onym
git mv src crates/onym/src
git mv Cargo.toml crates/onym/Cargo.toml
```

Expected: `crates/onym/src/lib.rs` and `crates/onym/Cargo.toml` exist; the old `src/` is gone.

- [ ] **Step 3: Remove the `[profile.release]` section from the moved package manifest**

Edit `crates/onym/Cargo.toml`: delete the lines:
```toml
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
```

`[profile.*]` only applies at the workspace root in a workspace.

- [ ] **Step 4: Write the new workspace root manifest**

Create `Cargo.toml` at the workspace root with this content:
```toml
[workspace]
resolver = "3"
members = ["crates/onym"]

[profile.release]
lto = "thin"
codegen-units = 1
strip = true
```

- [ ] **Step 5: Build and run all tests to verify the move**

Run: `cargo build`
Expected: clean build, no errors.

Run: `cargo nextest run`
Expected: all existing tests pass (validate, classify, errors, schema, rdap unit tests).

- [ ] **Step 6: Verify the CLI still runs**

Run: `cargo run --release -- check onym 2>&1 | head -20`
Expected: a normal results table for the name `onym`. Exit code may be 0/1/2 depending on availability; should not be 4 or 5.

- [ ] **Step 7: Commit**

```bash
git add Cargo.toml crates/onym/Cargo.toml crates/onym/src
git commit -m "refactor: convert repo to a Cargo workspace"
```

---

## Task 2: Add the `futures` dependency

**Files:**
- Modify: `crates/onym/Cargo.toml`

- [ ] **Step 1: Add `futures` to dependencies**

Edit `crates/onym/Cargo.toml`. In the `[dependencies]` table, add:
```toml
futures = "0.3"
```

The full dependency list should now include (alphabetically): `anyhow`, `chrono`, `clap`, `comfy-table`, `futures`, `reqwest`, `serde`, `serde_json`, `tokio`.

- [ ] **Step 2: Build to verify the dependency resolves**

Run: `cargo build`
Expected: clean build; `futures 0.3.x` shown in `cargo build` output the first time.

- [ ] **Step 3: Commit**

```bash
git add crates/onym/Cargo.toml Cargo.lock
git commit -m "chore: add futures crate dependency"
```

---

## Task 3: Define `Transport` trait skeleton (no impls yet)

**Files:**
- Create: `crates/onym/src/transport.rs`
- Modify: `crates/onym/src/lib.rs`

- [ ] **Step 1: Create the transport module**

Create `crates/onym/src/transport.rs` with:
```rust
use serde::de::DeserializeOwned;

/// Result of a JSON GET request.
#[derive(Debug)]
pub enum JsonOutcome<T> {
    /// 2xx response with a parsed body.
    Found(T),
    /// 404 response.
    NotFound,
    /// Any other status code (3xx, 4xx other than 404, 5xx).
    Other(u16),
}

/// Result of a HEAD request.
#[derive(Debug)]
pub enum HeadOutcome {
    /// 2xx response.
    Found,
    /// 404 response.
    NotFound,
    /// Any other status code.
    Other(u16),
}

/// Errors that propagate out of the transport (network layer).
/// HTTP status codes are surfaced via `JsonOutcome` / `HeadOutcome` instead.
#[derive(Debug)]
pub enum TransportError {
    /// Request timed out.
    Timeout,
    /// Connection / DNS / TLS failure or other transport-level issue.
    Network(String),
    /// 2xx body could not be parsed as JSON or did not match the schema.
    Decode(String),
}

impl std::fmt::Display for TransportError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Timeout => f.write_str("request timed out"),
            Self::Network(msg) => write!(f, "network error: {msg}"),
            Self::Decode(msg) => write!(f, "decode error: {msg}"),
        }
    }
}

impl std::error::Error for TransportError {}

/// Abstracts the HTTP fetches needed by the source adapters.
/// One impl per runtime: `ReqwestTransport` for the native CLI, a `FetchTransport`
/// (in a future `onym-wasm` crate) for the browser.
pub trait Transport {
    fn fetch_json<T: DeserializeOwned>(
        &self,
        url: &str,
    ) -> impl std::future::Future<Output = Result<JsonOutcome<T>, TransportError>>;

    fn head(
        &self,
        url: &str,
    ) -> impl std::future::Future<Output = Result<HeadOutcome, TransportError>>;
}
```

- [ ] **Step 2: Wire the module into the crate**

Edit `crates/onym/src/lib.rs`. Add `pub mod transport;` near the other `pub mod` declarations (after `pub mod schema;`):
```rust
pub mod classify;
pub mod errors;
pub mod output;
pub mod schema;
pub mod sources;
pub mod transport;
pub mod types;
pub mod validate;
```

- [ ] **Step 3: Build**

Run: `cargo build`
Expected: clean build.

- [ ] **Step 4: Commit**

```bash
git add crates/onym/src/transport.rs crates/onym/src/lib.rs
git commit -m "feat(transport): add Transport trait and outcome types"
```

---

## Task 4: Implement `ReqwestTransport`

**Files:**
- Modify: `crates/onym/src/transport.rs`

- [ ] **Step 1: Write the failing test**

Append this to `crates/onym/src/transport.rs`:
```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn reqwest_transport_can_be_constructed() {
        let _transport = ReqwestTransport::new().expect("client builds");
    }
}
```

Run: `cargo test --lib transport::tests::reqwest_transport_can_be_constructed`
Expected: FAIL with "cannot find type `ReqwestTransport` in this scope".

- [ ] **Step 2: Implement `ReqwestTransport`**

Append this to `crates/onym/src/transport.rs` (above the `#[cfg(test)]` block):
```rust
use std::time::Duration;

use reqwest::Client;

const HTTP_TIMEOUT: Duration = Duration::from_secs(15);

pub struct ReqwestTransport {
    client: Client,
}

impl ReqwestTransport {
    pub fn new() -> Result<Self, TransportError> {
        let user_agent = format!(
            "onym/{} (https://github.com/rvben/onym)",
            env!("CARGO_PKG_VERSION")
        );
        let client = Client::builder()
            .user_agent(user_agent)
            .timeout(HTTP_TIMEOUT)
            .build()
            .map_err(|e| TransportError::Network(format!("http client init failed: {e}")))?;
        Ok(Self { client })
    }
}

fn map_reqwest_error(err: reqwest::Error) -> TransportError {
    if err.is_timeout() {
        TransportError::Timeout
    } else if err.is_decode() {
        TransportError::Decode(err.to_string())
    } else {
        TransportError::Network(err.to_string())
    }
}

impl Transport for ReqwestTransport {
    async fn fetch_json<T: DeserializeOwned>(
        &self,
        url: &str,
    ) -> Result<JsonOutcome<T>, TransportError> {
        let resp = self.client.get(url).send().await.map_err(map_reqwest_error)?;
        let status = resp.status();
        if status.as_u16() == 404 {
            return Ok(JsonOutcome::NotFound);
        }
        if status.is_success() {
            let body: T = resp.json().await.map_err(map_reqwest_error)?;
            return Ok(JsonOutcome::Found(body));
        }
        Ok(JsonOutcome::Other(status.as_u16()))
    }

    async fn head(&self, url: &str) -> Result<HeadOutcome, TransportError> {
        let resp = self.client.head(url).send().await.map_err(map_reqwest_error)?;
        let status = resp.status();
        if status.as_u16() == 404 {
            return Ok(HeadOutcome::NotFound);
        }
        if status.is_success() {
            return Ok(HeadOutcome::Found);
        }
        Ok(HeadOutcome::Other(status.as_u16()))
    }
}
```

- [ ] **Step 3: Run the test to verify it passes**

Run: `cargo test --lib transport::tests::reqwest_transport_can_be_constructed`
Expected: PASS (1 test passed).

- [ ] **Step 4: Run clippy on the new module**

Run: `cargo clippy --all-targets -- -D warnings`
Expected: no warnings.

- [ ] **Step 5: Commit**

```bash
git add crates/onym/src/transport.rs
git commit -m "feat(transport): add ReqwestTransport for native HTTP"
```

---

## Task 5: Add `MockTransport` for adapter unit tests

**Files:**
- Modify: `crates/onym/src/transport.rs`

- [ ] **Step 1: Append `MockTransport` under `#[cfg(test)]`**

Replace the existing `#[cfg(test)] mod tests { ... }` block at the bottom of `crates/onym/src/transport.rs` with:
```rust
#[cfg(test)]
pub mod test_support {
    //! Test-only helpers, exposed within the crate via `pub(crate)` paths.

    use std::cell::RefCell;
    use std::collections::HashMap;

    use serde::de::DeserializeOwned;

    use super::{HeadOutcome, JsonOutcome, Transport, TransportError};

    /// Canned response for a single URL.
    pub enum Canned {
        Json(serde_json::Value),
        NotFound,
        Other(u16),
        Network(String),
    }

    /// Test transport that returns canned responses keyed by URL.
    /// Per-URL responses are consumed in FIFO order; if a URL has no remaining
    /// canned response, the call panics with the URL — this surfaces missing
    /// fixtures loudly in tests.
    pub struct MockTransport {
        json: RefCell<HashMap<String, Vec<Canned>>>,
        head: RefCell<HashMap<String, Vec<Canned>>>,
    }

    impl MockTransport {
        pub fn new() -> Self {
            Self {
                json: RefCell::new(HashMap::new()),
                head: RefCell::new(HashMap::new()),
            }
        }

        pub fn json(self, url: &str, canned: Canned) -> Self {
            self.json
                .borrow_mut()
                .entry(url.to_string())
                .or_default()
                .push(canned);
            self
        }

        pub fn head(self, url: &str, canned: Canned) -> Self {
            self.head
                .borrow_mut()
                .entry(url.to_string())
                .or_default()
                .push(canned);
            self
        }

        fn pop(map: &RefCell<HashMap<String, Vec<Canned>>>, url: &str) -> Canned {
            let mut guard = map.borrow_mut();
            let queue = guard
                .get_mut(url)
                .unwrap_or_else(|| panic!("MockTransport: no canned response for {url}"));
            queue
                .pop()
                .unwrap_or_else(|| panic!("MockTransport: queue exhausted for {url}"))
        }
    }

    impl Transport for MockTransport {
        async fn fetch_json<T: DeserializeOwned>(
            &self,
            url: &str,
        ) -> Result<JsonOutcome<T>, TransportError> {
            match Self::pop(&self.json, url) {
                Canned::Json(value) => {
                    let parsed: T = serde_json::from_value(value)
                        .map_err(|e| TransportError::Decode(e.to_string()))?;
                    Ok(JsonOutcome::Found(parsed))
                }
                Canned::NotFound => Ok(JsonOutcome::NotFound),
                Canned::Other(code) => Ok(JsonOutcome::Other(code)),
                Canned::Network(msg) => Err(TransportError::Network(msg)),
            }
        }

        async fn head(&self, url: &str) -> Result<HeadOutcome, TransportError> {
            match Self::pop(&self.head, url) {
                Canned::Json(_) => panic!("Canned::Json on a HEAD request for {url}"),
                Canned::NotFound => Ok(HeadOutcome::NotFound),
                Canned::Other(code) => Ok(HeadOutcome::Other(code)),
                Canned::Network(msg) => Err(TransportError::Network(msg)),
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn reqwest_transport_can_be_constructed() {
        let _transport = ReqwestTransport::new().expect("client builds");
    }

    #[tokio::test]
    async fn mock_transport_returns_canned_json() {
        use test_support::{Canned, MockTransport};

        #[derive(serde::Deserialize)]
        struct Body {
            ok: bool,
        }

        let mock = MockTransport::new()
            .json("https://example.test/x", Canned::Json(serde_json::json!({"ok": true})));

        let outcome: JsonOutcome<Body> = mock.fetch_json("https://example.test/x").await.unwrap();
        match outcome {
            JsonOutcome::Found(b) => assert!(b.ok),
            _ => panic!("expected Found"),
        }
    }

    #[tokio::test]
    async fn mock_transport_returns_not_found() {
        use test_support::{Canned, MockTransport};

        let mock = MockTransport::new().json("https://example.test/y", Canned::NotFound);
        let outcome: JsonOutcome<serde_json::Value> =
            mock.fetch_json("https://example.test/y").await.unwrap();
        assert!(matches!(outcome, JsonOutcome::NotFound));
    }
}
```

- [ ] **Step 2: Run the new tests**

Run: `cargo test --lib transport::tests`
Expected: 3 tests pass (`reqwest_transport_can_be_constructed`, `mock_transport_returns_canned_json`, `mock_transport_returns_not_found`).

- [ ] **Step 3: Run clippy**

Run: `cargo clippy --all-targets -- -D warnings`
Expected: no warnings.

- [ ] **Step 4: Commit**

```bash
git add crates/onym/src/transport.rs
git commit -m "test(transport): add MockTransport for adapter testing"
```

---

## Task 6: Refactor `crates_io` adapter to use `Transport`

**Files:**
- Modify: `crates/onym/src/sources/crates_io.rs`

- [ ] **Step 1: Read the current adapter**

Run: `cat crates/onym/src/sources/crates_io.rs`
Note the signature and the response shape.

- [ ] **Step 2: Replace the adapter with the Transport-based version**

Overwrite `crates/onym/src/sources/crates_io.rs` with:
```rust
use anyhow::{Result, anyhow};
use serde::Deserialize;

use crate::classify::classify;
use crate::transport::{JsonOutcome, Transport};
use crate::types::{Status, TakenInfo};

#[derive(Deserialize)]
struct Response {
    #[serde(rename = "crate")]
    krate: Crate,
    versions: Option<Vec<Version>>,
}

#[derive(Deserialize)]
struct Crate {
    description: Option<String>,
    downloads: Option<u64>,
    recent_downloads: Option<u64>,
    max_version: Option<String>,
    updated_at: Option<String>,
}

#[derive(Deserialize)]
struct Version {
    num: String,
    created_at: Option<String>,
    yanked: Option<bool>,
}

pub async fn check<T: Transport>(transport: &T, name: &str) -> Result<Status> {
    let url = format!("https://crates.io/api/v1/crates/{name}");
    let outcome: JsonOutcome<Response> = transport
        .fetch_json(&url)
        .await
        .map_err(|e| anyhow!(e.to_string()))?;
    match outcome {
        JsonOutcome::NotFound => Ok(Status::Free),
        JsonOutcome::Found(body) => {
            let last_release = latest_release(&body);
            let mut info = TakenInfo {
                version: body.krate.max_version,
                description: body.krate.description,
                downloads: body.krate.downloads,
                recent_downloads: body.krate.recent_downloads,
                last_release: last_release.or(body.krate.updated_at),
                ..Default::default()
            };
            info.classification = classify(&info);
            Ok(Status::Taken(Box::new(info)))
        }
        JsonOutcome::Other(code) => Ok(Status::Unknown {
            reason: format!("HTTP {code}"),
        }),
    }
}

fn latest_release(body: &Response) -> Option<String> {
    let versions = body.versions.as_ref()?;
    let target = body.krate.max_version.as_deref()?;
    versions
        .iter()
        .find(|v| v.num == target && v.yanked != Some(true))?
        .created_at
        .clone()
}
```

> **Note:** the previous adapter's exact field shape is preserved. If it differs from what's shown above (the original may have had a different parsing approach), keep the original parsing logic but change the function signature to take `&T: Transport` and use `transport.fetch_json()` instead of `client.get(url).send().await?.json().await?`. The pattern for translating is:
>
> ```rust
> // OLD:
> let resp = client.get(url).send().await?;
> match resp.status() {
>     StatusCode::NOT_FOUND => Ok(Status::Free),
>     status if status.is_success() => {
>         let body: Response = resp.json().await?;
>         /* build TakenInfo */
>     }
>     status => Ok(Status::Unknown { reason: format!("HTTP {status}") }),
> }
>
> // NEW:
> match transport.fetch_json::<Response>(&url).await.map_err(|e| anyhow!(e.to_string()))? {
>     JsonOutcome::NotFound => Ok(Status::Free),
>     JsonOutcome::Found(body) => { /* build TakenInfo */ }
>     JsonOutcome::Other(code) => Ok(Status::Unknown { reason: format!("HTTP {code}") }),
> }
> ```

- [ ] **Step 3: Build and verify**

Run: `cargo build`
Expected: this file compiles. Other adapters and `lib.rs` will still fail to compile because they call `crates_io::check(&client, ...)` with the old signature — that's fixed in Task 11. For now, expect compile errors elsewhere.

If the only errors are in `lib.rs` and other `sources/*.rs` files, proceed. Otherwise re-read the diff and fix.

- [ ] **Step 4: Stage but do not commit yet**

```bash
git add crates/onym/src/sources/crates_io.rs
```

Continue to Task 7. We commit the adapter refactors together at the end of Task 11 once `lib.rs` and `main.rs` are also updated, so the working tree is buildable at every commit.

---

## Task 7: Refactor `pypi` adapter to use `Transport`

**Files:**
- Modify: `crates/onym/src/sources/pypi.rs`

- [ ] **Step 1: Replace the function signature and body**

Edit `crates/onym/src/sources/pypi.rs`. Change the imports and the `check` function:

Replace:
```rust
use anyhow::Result;
use reqwest::{Client, StatusCode};
use serde::Deserialize;

use crate::classify::classify;
use crate::types::{Status, TakenInfo};
```

With:
```rust
use anyhow::{Result, anyhow};
use serde::Deserialize;

use crate::classify::classify;
use crate::transport::{JsonOutcome, Transport};
use crate::types::{Status, TakenInfo};
```

Replace the `pub async fn check(client: &Client, name: &str) -> Result<Status>` function body with:
```rust
pub async fn check<T: Transport>(transport: &T, name: &str) -> Result<Status> {
    let url = format!("https://pypi.org/pypi/{name}/json");
    let outcome: JsonOutcome<Response> = transport
        .fetch_json(&url)
        .await
        .map_err(|e| anyhow!(e.to_string()))?;
    match outcome {
        JsonOutcome::NotFound => Ok(Status::Free),
        JsonOutcome::Found(body) => {
            let last_release = body
                .releases
                .as_ref()
                .and_then(|r| latest_upload_time(r, body.info.version.as_deref()));
            let mut info = TakenInfo {
                version: body.info.version,
                description: body.info.summary,
                owner: body.info.author,
                last_release,
                ..Default::default()
            };
            info.classification = classify(&info);
            Ok(Status::Taken(Box::new(info)))
        }
        JsonOutcome::Other(code) => Ok(Status::Unknown {
            reason: format!("HTTP {code}"),
        }),
    }
}
```

Leave `latest_upload_time` and the `Response` / `Info` structs unchanged.

- [ ] **Step 2: Stage**

```bash
git add crates/onym/src/sources/pypi.rs
```

---

## Task 8: Refactor `npm` adapter to use `Transport`

**Files:**
- Modify: `crates/onym/src/sources/npm.rs`

- [ ] **Step 1: Replace the function signature and body**

Edit `crates/onym/src/sources/npm.rs`. Change the imports and the `check` function:

Replace:
```rust
use anyhow::Result;
use reqwest::{Client, StatusCode};
use serde::Deserialize;

use crate::classify::classify;
use crate::types::{Status, TakenInfo};
```

With:
```rust
use anyhow::{Result, anyhow};
use serde::Deserialize;

use crate::classify::classify;
use crate::transport::{JsonOutcome, Transport};
use crate::types::{Status, TakenInfo};
```

Replace the `pub async fn check(client: &Client, name: &str) -> Result<Status>` function body with:
```rust
pub async fn check<T: Transport>(transport: &T, name: &str) -> Result<Status> {
    let url = format!("https://registry.npmjs.org/{name}");
    let outcome: JsonOutcome<Response> = transport
        .fetch_json(&url)
        .await
        .map_err(|e| anyhow!(e.to_string()))?;
    match outcome {
        JsonOutcome::NotFound => Ok(Status::Free),
        JsonOutcome::Found(body) => {
            let version = body.dist_tags.and_then(|t| t.latest);
            let last_release = body
                .time
                .as_ref()
                .and_then(|t| t.get("modified"))
                .and_then(|v| v.as_str())
                .map(String::from);
            let mut info = TakenInfo {
                version,
                description: body.description,
                last_release,
                ..Default::default()
            };
            info.classification = classify(&info);
            Ok(Status::Taken(Box::new(info)))
        }
        JsonOutcome::Other(code) => Ok(Status::Unknown {
            reason: format!("HTTP {code}"),
        }),
    }
}
```

Leave `Response` and `DistTags` unchanged.

- [ ] **Step 2: Stage**

```bash
git add crates/onym/src/sources/npm.rs
```

---

## Task 9: Refactor `github` adapter to use `Transport`

**Files:**
- Modify: `crates/onym/src/sources/github.rs`

- [ ] **Step 1: Replace the entire file**

Overwrite `crates/onym/src/sources/github.rs` with:
```rust
use anyhow::{Result, anyhow};

use crate::transport::{HeadOutcome, Transport};
use crate::types::{Status, TakenInfo};

pub async fn check<T: Transport>(transport: &T, name: &str) -> Result<Status> {
    let url = format!("https://github.com/{name}");
    let outcome = transport.head(&url).await.map_err(|e| anyhow!(e.to_string()))?;
    match outcome {
        HeadOutcome::NotFound => Ok(Status::Free),
        HeadOutcome::Found => Ok(Status::Taken(Box::new(TakenInfo {
            description: Some("user or organization name is registered".into()),
            ..Default::default()
        }))),
        HeadOutcome::Other(code) => Ok(Status::Unknown {
            reason: format!("HTTP {code}"),
        }),
    }
}
```

- [ ] **Step 2: Stage**

```bash
git add crates/onym/src/sources/github.rs
```

---

## Task 10: Refactor `rdap` adapter to use `Transport`

**Files:**
- Modify: `crates/onym/src/sources/rdap.rs`

- [ ] **Step 1: Replace the imports and `check` function**

Edit `crates/onym/src/sources/rdap.rs`. Change the imports:

Replace:
```rust
use anyhow::Result;
use reqwest::{Client, StatusCode};
use serde::Deserialize;

use crate::types::{Status, TakenInfo};
```

With:
```rust
use anyhow::{Result, anyhow};
use serde::Deserialize;

use crate::transport::{JsonOutcome, Transport};
use crate::types::{Status, TakenInfo};
```

Replace the `pub async fn check(client: &Client, name: &str, tld: &str) -> Result<Status>` function body with:
```rust
pub async fn check<T: Transport>(transport: &T, name: &str, tld: &str) -> Result<Status> {
    let domain = format!("{name}.{tld}");
    for base in endpoints_for(tld) {
        let url = format!("{base}{domain}");
        let outcome: JsonOutcome<Response> = transport
            .fetch_json(&url)
            .await
            .map_err(|e| anyhow!(e.to_string()))?;
        match outcome {
            JsonOutcome::NotFound => return Ok(Status::Free),
            JsonOutcome::Found(body) => {
                let registrar = body.entities.as_deref().and_then(extract_registrar);
                return Ok(Status::Taken(Box::new(TakenInfo {
                    owner: registrar,
                    description: Some(format!("{domain} is registered")),
                    ..Default::default()
                })));
            }
            JsonOutcome::Other(_) => continue,
        }
    }
    Ok(Status::Unknown {
        reason: format!("no authoritative RDAP server answered for .{tld}"),
    })
}
```

Leave `Response`, `Entity`, `endpoints_for`, `extract_registrar`, `DEFAULT_TLDS`, and the `#[cfg(test)] mod tests` block unchanged.

> **Behavior preservation:** The original code did `client.get(&url).send().await?` which propagates network errors out of the loop. The new code does `transport.fetch_json(&url).await.map_err(|e| anyhow!(...))?` which has the same propagation. Status-based fall-through (429/503/other) maps to `JsonOutcome::Other(_) => continue`, identical to the original behavior.

- [ ] **Step 2: Stage**

```bash
git add crates/onym/src/sources/rdap.rs
```

---

## Task 11: Refactor `lib.rs::check_name` and update `main.rs`

**Files:**
- Modify: `crates/onym/src/lib.rs`
- Modify: `crates/onym/src/main.rs`

- [ ] **Step 1: Rewrite `lib.rs`**

Overwrite `crates/onym/src/lib.rs` with:
```rust
pub mod classify;
pub mod errors;
pub mod output;
pub mod schema;
pub mod sources;
pub mod transport;
pub mod types;
pub mod validate;

use std::pin::Pin;

use futures::stream::{FuturesUnordered, StreamExt};

use crate::sources::rdap::DEFAULT_TLDS;
use crate::transport::Transport;
use crate::types::{CheckResult, NameReport, Status};

type CheckFuture<'a> = Pin<Box<dyn std::future::Future<Output = CheckResult> + 'a>>;

pub async fn check_name<T: Transport>(transport: &T, name: &str, tlds: &[String]) -> NameReport {
    let mut results = Vec::new();
    let mut futures = build_check_futures(transport, name, tlds);
    while let Some(r) = futures.next().await {
        results.push(r);
    }
    results.sort_by_key(|r| source_order(&r.source));
    NameReport {
        name: name.to_string(),
        results,
    }
}

/// Streaming variant: invokes `on_result` for each `CheckResult` as soon as it
/// completes, in completion order. Intended for UIs that render per-source
/// progress (e.g. the wasm/web build).
pub async fn check_name_stream<T, F>(
    transport: &T,
    name: &str,
    tlds: &[String],
    mut on_result: F,
) where
    T: Transport,
    F: FnMut(CheckResult),
{
    let mut futures = build_check_futures(transport, name, tlds);
    while let Some(r) = futures.next().await {
        on_result(r);
    }
}

pub fn default_tlds() -> Vec<String> {
    DEFAULT_TLDS.iter().map(|s| (*s).to_string()).collect()
}

fn build_check_futures<'a, T: Transport>(
    transport: &'a T,
    name: &'a str,
    tlds: &'a [String],
) -> FuturesUnordered<CheckFuture<'a>> {
    let futures: FuturesUnordered<CheckFuture<'a>> = FuturesUnordered::new();

    futures.push(Box::pin(wrap("crates.io", async move {
        sources::crates_io::check(transport, name).await
    })));
    futures.push(Box::pin(wrap("pypi", async move {
        sources::pypi::check(transport, name).await
    })));
    futures.push(Box::pin(wrap("npm", async move {
        sources::npm::check(transport, name).await
    })));
    futures.push(Box::pin(wrap("github", async move {
        sources::github::check(transport, name).await
    })));

    for tld in tlds {
        let source = format!(".{tld}");
        let tld = tld.clone();
        futures.push(Box::pin(async move {
            let status = match sources::rdap::check(transport, name, &tld).await {
                Ok(s) => s,
                Err(e) => Status::Unknown {
                    reason: e.to_string(),
                },
            };
            CheckResult { source, status }
        }));
    }

    futures
}

async fn wrap<F>(source: &'static str, f: F) -> CheckResult
where
    F: std::future::Future<Output = anyhow::Result<Status>>,
{
    let status = match f.await {
        Ok(s) => s,
        Err(e) => Status::Unknown {
            reason: e.to_string(),
        },
    };
    CheckResult {
        source: source.into(),
        status,
    }
}

fn source_order(source: &str) -> u8 {
    match source {
        "crates.io" => 0,
        "pypi" => 1,
        "npm" => 2,
        "github" => 3,
        s if s.starts_with('.') => 4,
        _ => 5,
    }
}
```

> **Notes:**
> - The old `build_client()` function is removed. Callers now construct a `ReqwestTransport` directly.
> - `JoinSet` is replaced with `FuturesUnordered<Pin<Box<dyn Future + 'a>>>`. Behavior is identical for I/O-bound work — fetches still run concurrently on tokio's reactor; we just don't spawn separate tasks. This drops the `Send` bound, which is required for the future wasm crate to reuse this code.
> - The `pub async fn check_name` public signature changes from `(&Client, ...)` to `(&impl Transport, ...)`. This is the public API change called out in the spec's Open Questions.

- [ ] **Step 2: Update `main.rs`**

Read `crates/onym/src/main.rs` first to find the call site (likely uses `build_client()` and `check_name(&client, ...)`).

Run: `cat crates/onym/src/main.rs | head -120`

In the function that handles the `check` subcommand, replace:
```rust
let client = onym::build_client()?;
// or: use onym::build_client; let client = build_client()?;
```

With:
```rust
let transport = onym::transport::ReqwestTransport::new()
    .map_err(|e| onym::errors::CliError::new(
        onym::errors::ErrorKind::Internal,
        format!("transport init failed: {e}"),
    ))?;
```

And replace any `check_name(&client, ...)` call with `check_name(&transport, ...)`.

If the file imports `reqwest::Client` only for the now-removed call, remove that import.

- [ ] **Step 3: Build the workspace**

Run: `cargo build`
Expected: clean build. If there are remaining `&Client` references in `main.rs` or other call sites, fix them by replacing with `&transport`.

- [ ] **Step 4: Run all tests**

Run: `cargo nextest run`
Expected: all existing tests still pass. Some test files may need to update calls to `check_name` if they call it directly — search for `check_name(` and `build_client(` and update accordingly.

- [ ] **Step 5: Run clippy**

Run: `cargo clippy --all-targets -- -D warnings`
Expected: no warnings.

- [ ] **Step 6: Smoke-test the CLI**

Run: `cargo run --release -- check onym 2>&1 | head -20`
Expected: a results table identical in shape to before the refactor (sources listed, classifications shown).

Run: `cargo run --release -- check 'foo bar' 2>&1`
Expected: stderr JSON envelope `{"error":{"kind":"invalid_input",...}}`, exit code 3.

- [ ] **Step 7: Commit the entire adapter + lib refactor**

```bash
git add crates/onym/src/lib.rs crates/onym/src/main.rs \
        crates/onym/src/sources/crates_io.rs \
        crates/onym/src/sources/pypi.rs \
        crates/onym/src/sources/npm.rs \
        crates/onym/src/sources/github.rs \
        crates/onym/src/sources/rdap.rs
git commit -m "refactor: route source adapters through the Transport trait"
```

---

## Task 12: Add unit tests for the `crates_io` adapter

**Files:**
- Modify: `crates/onym/src/sources/crates_io.rs`

- [ ] **Step 1: Append the test module**

Append to `crates/onym/src/sources/crates_io.rs`:
```rust
#[cfg(test)]
mod tests {
    use super::*;
    use crate::transport::test_support::{Canned, MockTransport};

    const URL: &str = "https://crates.io/api/v1/crates/example";

    #[tokio::test]
    async fn returns_free_on_404() {
        let mock = MockTransport::new().json(URL, Canned::NotFound);
        let status = check(&mock, "example").await.unwrap();
        assert!(matches!(status, Status::Free));
    }

    #[tokio::test]
    async fn returns_taken_with_metadata_on_200() {
        let body = serde_json::json!({
            "crate": {
                "description": "example crate",
                "downloads": 12345,
                "recent_downloads": 678,
                "max_version": "1.2.3",
                "updated_at": "2024-01-01T00:00:00Z",
            },
            "versions": [
                { "num": "1.2.3", "created_at": "2024-01-01T00:00:00Z", "yanked": false }
            ]
        });
        let mock = MockTransport::new().json(URL, Canned::Json(body));
        let status = check(&mock, "example").await.unwrap();
        let Status::Taken(info) = status else {
            panic!("expected Taken");
        };
        assert_eq!(info.version.as_deref(), Some("1.2.3"));
        assert_eq!(info.recent_downloads, Some(678));
    }

    #[tokio::test]
    async fn returns_unknown_on_other_status() {
        let mock = MockTransport::new().json(URL, Canned::Other(503));
        let status = check(&mock, "example").await.unwrap();
        assert!(matches!(status, Status::Unknown { .. }));
    }
}
```

- [ ] **Step 2: Run the tests**

Run: `cargo nextest run -p onym sources::crates_io`
Expected: 3 tests pass.

- [ ] **Step 3: Commit**

```bash
git add crates/onym/src/sources/crates_io.rs
git commit -m "test(crates_io): add adapter tests using MockTransport"
```

---

## Task 13: Add unit tests for the `pypi` and `npm` adapters

**Files:**
- Modify: `crates/onym/src/sources/pypi.rs`
- Modify: `crates/onym/src/sources/npm.rs`

- [ ] **Step 1: Append tests to `pypi.rs`**

Append to `crates/onym/src/sources/pypi.rs`:
```rust
#[cfg(test)]
mod tests {
    use super::*;
    use crate::transport::test_support::{Canned, MockTransport};

    const URL: &str = "https://pypi.org/pypi/example/json";

    #[tokio::test]
    async fn returns_free_on_404() {
        let mock = MockTransport::new().json(URL, Canned::NotFound);
        let status = check(&mock, "example").await.unwrap();
        assert!(matches!(status, Status::Free));
    }

    #[tokio::test]
    async fn returns_taken_on_200_with_minimal_body() {
        let body = serde_json::json!({
            "info": {
                "version": "1.0.0",
                "summary": "an example",
                "author": "alice"
            },
            "releases": {
                "1.0.0": [ { "upload_time_iso_8601": "2024-05-01T00:00:00Z" } ]
            }
        });
        let mock = MockTransport::new().json(URL, Canned::Json(body));
        let status = check(&mock, "example").await.unwrap();
        let Status::Taken(info) = status else {
            panic!("expected Taken");
        };
        assert_eq!(info.version.as_deref(), Some("1.0.0"));
        assert_eq!(info.owner.as_deref(), Some("alice"));
    }

    #[tokio::test]
    async fn returns_unknown_on_500() {
        let mock = MockTransport::new().json(URL, Canned::Other(500));
        let status = check(&mock, "example").await.unwrap();
        assert!(matches!(status, Status::Unknown { .. }));
    }
}
```

- [ ] **Step 2: Append tests to `npm.rs`**

Append to `crates/onym/src/sources/npm.rs`:
```rust
#[cfg(test)]
mod tests {
    use super::*;
    use crate::transport::test_support::{Canned, MockTransport};

    const URL: &str = "https://registry.npmjs.org/example";

    #[tokio::test]
    async fn returns_free_on_404() {
        let mock = MockTransport::new().json(URL, Canned::NotFound);
        let status = check(&mock, "example").await.unwrap();
        assert!(matches!(status, Status::Free));
    }

    #[tokio::test]
    async fn returns_taken_on_200() {
        let body = serde_json::json!({
            "dist-tags": { "latest": "2.0.0" },
            "description": "an example",
            "time": { "modified": "2024-06-01T00:00:00Z" }
        });
        let mock = MockTransport::new().json(URL, Canned::Json(body));
        let status = check(&mock, "example").await.unwrap();
        let Status::Taken(info) = status else {
            panic!("expected Taken");
        };
        assert_eq!(info.version.as_deref(), Some("2.0.0"));
        assert_eq!(info.last_release.as_deref(), Some("2024-06-01T00:00:00Z"));
    }

    #[tokio::test]
    async fn returns_unknown_on_other_status() {
        let mock = MockTransport::new().json(URL, Canned::Other(429));
        let status = check(&mock, "example").await.unwrap();
        assert!(matches!(status, Status::Unknown { .. }));
    }
}
```

- [ ] **Step 3: Run the tests**

Run: `cargo nextest run -p onym 'sources::pypi|sources::npm'`
Expected: 6 tests pass.

- [ ] **Step 4: Commit**

```bash
git add crates/onym/src/sources/pypi.rs crates/onym/src/sources/npm.rs
git commit -m "test(pypi,npm): add adapter tests using MockTransport"
```

---

## Task 14: Add unit tests for the `github` and `rdap` adapters

**Files:**
- Modify: `crates/onym/src/sources/github.rs`
- Modify: `crates/onym/src/sources/rdap.rs`

- [ ] **Step 1: Append tests to `github.rs`**

Append to `crates/onym/src/sources/github.rs`:
```rust
#[cfg(test)]
mod tests {
    use super::*;
    use crate::transport::test_support::{Canned, MockTransport};

    const URL: &str = "https://github.com/example";

    #[tokio::test]
    async fn returns_free_on_404() {
        let mock = MockTransport::new().head(URL, Canned::NotFound);
        let status = check(&mock, "example").await.unwrap();
        assert!(matches!(status, Status::Free));
    }

    #[tokio::test]
    async fn returns_taken_on_200() {
        let mock = MockTransport::new().head(URL, Canned::Other(200));
        let status = check(&mock, "example").await.unwrap();
        // Note: HeadOutcome::Other is the catch-all for non-404 non-2xx,
        // but for github we want 200 → Found. The MockTransport must convert
        // a Canned::Other(200) into HeadOutcome::Found.
        // Adjust if your MockTransport uses a separate Canned::Found variant.
        match status {
            Status::Taken(_) => {}
            Status::Unknown { .. } => {
                // Acceptable if the mock surfaces 200 as Other(200); this
                // documents the gap. See alternative test below.
            }
            other => panic!("unexpected status: {other:?}"),
        }
    }

    #[tokio::test]
    async fn returns_unknown_on_500() {
        let mock = MockTransport::new().head(URL, Canned::Other(500));
        let status = check(&mock, "example").await.unwrap();
        assert!(matches!(status, Status::Unknown { .. }));
    }
}
```

> **NOTE on the Canned::Found gap:** `Canned` as defined in Task 5 has `NotFound`, `Other(u16)`, `Json(...)`, and `Network(...)` — it cannot express `HeadOutcome::Found` directly. Add a `Found` variant to `Canned` and update `MockTransport::head` to map `Canned::Found → HeadOutcome::Found`.

- [ ] **Step 2: Add the `Canned::Found` variant**

Edit `crates/onym/src/transport.rs`. In the `test_support` module's `pub enum Canned`, add the `Found` variant:
```rust
pub enum Canned {
    Json(serde_json::Value),
    Found,        // 2xx with no body (used for HEAD outcomes)
    NotFound,
    Other(u16),
    Network(String),
}
```

In the `Transport for MockTransport` impl, update the `head` method:
```rust
async fn head(&self, url: &str) -> Result<HeadOutcome, TransportError> {
    match Self::pop(&self.head, url) {
        Canned::Json(_) => panic!("Canned::Json on a HEAD request for {url}"),
        Canned::Found => Ok(HeadOutcome::Found),
        Canned::NotFound => Ok(HeadOutcome::NotFound),
        Canned::Other(code) => Ok(HeadOutcome::Other(code)),
        Canned::Network(msg) => Err(TransportError::Network(msg)),
    }
}
```

Also update the `fetch_json` method to handle `Canned::Found`:
```rust
async fn fetch_json<T: DeserializeOwned>(
    &self,
    url: &str,
) -> Result<JsonOutcome<T>, TransportError> {
    match Self::pop(&self.json, url) {
        Canned::Json(value) => {
            let parsed: T = serde_json::from_value(value)
                .map_err(|e| TransportError::Decode(e.to_string()))?;
            Ok(JsonOutcome::Found(parsed))
        }
        Canned::Found => panic!("Canned::Found on a JSON request for {url} (use Canned::Json)"),
        Canned::NotFound => Ok(JsonOutcome::NotFound),
        Canned::Other(code) => Ok(JsonOutcome::Other(code)),
        Canned::Network(msg) => Err(TransportError::Network(msg)),
    }
}
```

- [ ] **Step 3: Update the github "returns_taken" test to use `Canned::Found`**

Edit `crates/onym/src/sources/github.rs`. Replace the `returns_taken_on_200` test with:
```rust
#[tokio::test]
async fn returns_taken_on_200() {
    let mock = MockTransport::new().head(URL, Canned::Found);
    let status = check(&mock, "example").await.unwrap();
    let Status::Taken(info) = status else {
        panic!("expected Taken");
    };
    assert!(info.description.unwrap().contains("registered"));
}
```

- [ ] **Step 4: Append tests to `rdap.rs`**

Append to `crates/onym/src/sources/rdap.rs` (inside the existing `#[cfg(test)] mod tests` block, before the closing `}`):
```rust
    use crate::transport::test_support::{Canned, MockTransport};

    fn url(tld: &str, base: &str, name: &str) -> String {
        format!("{base}{name}.{tld}")
    }

    #[tokio::test]
    async fn returns_free_when_first_endpoint_returns_404() {
        let tld = "io";
        let primary = endpoints_for(tld)[0];
        let mock = MockTransport::new().json(&url(tld, primary, "freeexample"), Canned::NotFound);
        let status = check(&mock, "freeexample", tld).await.unwrap();
        assert!(matches!(status, Status::Free));
    }

    #[tokio::test]
    async fn falls_through_on_429_then_resolves_at_fallback() {
        let tld = "io";
        let endpoints = endpoints_for(tld);
        assert!(endpoints.len() >= 2, "test requires fallback endpoints");
        let mock = MockTransport::new()
            .json(&url(tld, endpoints[0], "takenexample"), Canned::Other(429))
            .json(
                &url(tld, endpoints[1], "takenexample"),
                Canned::Json(serde_json::json!({ "entities": [] })),
            );
        let status = check(&mock, "takenexample", tld).await.unwrap();
        assert!(matches!(status, Status::Taken(_)));
    }

    #[tokio::test]
    async fn returns_unknown_when_all_endpoints_fall_through() {
        let tld = "io";
        let endpoints = endpoints_for(tld);
        let mut mock = MockTransport::new();
        for ep in &endpoints {
            mock = mock.json(&url(tld, ep, "noresp"), Canned::Other(500));
        }
        let status = check(&mock, "noresp", tld).await.unwrap();
        assert!(matches!(status, Status::Unknown { .. }));
    }
```

- [ ] **Step 5: Run the new tests**

Run: `cargo nextest run -p onym 'sources::github|sources::rdap'`
Expected: 6 tests pass (3 github, 3 new rdap, plus 3 existing rdap tests).

- [ ] **Step 6: Run the full suite**

Run: `cargo nextest run`
Expected: all tests pass. Full count is approximately:
- existing tests (validate, classify, errors, schema, rdap endpoints): unchanged
- new tests added in this plan: 3 (transport) + 3 (crates_io) + 3 (pypi) + 3 (npm) + 3 (github) + 3 (rdap) = 18 new

- [ ] **Step 7: Commit**

```bash
git add crates/onym/src/transport.rs \
        crates/onym/src/sources/github.rs \
        crates/onym/src/sources/rdap.rs
git commit -m "test(github,rdap): add adapter tests, extend MockTransport with Canned::Found"
```

---

## Task 15: Add a test for `check_name_stream` ordering and completeness

**Files:**
- Create: `crates/onym/tests/streaming.rs`

- [ ] **Step 1: Create the integration test**

Create `crates/onym/tests/streaming.rs` with:
```rust
//! Integration test for the `check_name_stream` API.

use std::cell::RefCell;

use onym::transport::test_support::{Canned, MockTransport};
use onym::types::Status;
use onym::{check_name_stream, default_tlds};

#[tokio::test]
async fn streams_one_result_per_source() {
    let name = "example";
    let mut mock = MockTransport::new()
        .json(
            &format!("https://crates.io/api/v1/crates/{name}"),
            Canned::NotFound,
        )
        .json(
            &format!("https://pypi.org/pypi/{name}/json"),
            Canned::NotFound,
        )
        .json(
            &format!("https://registry.npmjs.org/{name}"),
            Canned::NotFound,
        )
        .head(&format!("https://github.com/{name}"), Canned::NotFound);

    // Add canned responses for each TLD's first endpoint.
    for tld in default_tlds() {
        let domain = format!("{name}.{tld}");
        // The rdap adapter walks endpoints_for(tld); we only need the first
        // endpoint to return NotFound for the test to terminate.
        let primary = first_rdap_endpoint(&tld);
        mock = mock.json(&format!("{primary}{domain}"), Canned::NotFound);
    }

    let collected = RefCell::new(Vec::new());
    check_name_stream(&mock, name, &default_tlds(), |result| {
        collected.borrow_mut().push(result);
    })
    .await;

    let collected = collected.into_inner();
    let expected_sources = expected_source_set();
    let actual_sources: std::collections::HashSet<_> =
        collected.iter().map(|r| r.source.clone()).collect();
    assert_eq!(actual_sources, expected_sources);
    for r in &collected {
        assert!(matches!(r.status, Status::Free), "source {} not Free", r.source);
    }
}

fn expected_source_set() -> std::collections::HashSet<String> {
    let mut s: std::collections::HashSet<String> =
        ["crates.io", "pypi", "npm", "github"].iter().map(|s| s.to_string()).collect();
    for tld in default_tlds() {
        s.insert(format!(".{tld}"));
    }
    s
}

/// Returns the primary RDAP endpoint base URL for a TLD by replicating the
/// adapter's mapping. Keep this in sync with `src/sources/rdap.rs::endpoints_for`.
fn first_rdap_endpoint(tld: &str) -> &'static str {
    match tld {
        "io" | "ai" => "https://rdap.identitydigital.services/rdap/domain/",
        "dev" | "app" | "page" | "new" | "foo" | "day" | "how" | "soy" => {
            "https://pubapi.registry.google/rdap/domain/"
        }
        "com" | "net" => "https://rdap.verisign.com/com/v1/domain/",
        "org" => "https://rdap.publicinterestregistry.org/rdap/domain/",
        "co" => "https://rdap.nic.co/domain/",
        _ => "https://rdap.org/domain/",
    }
}
```

- [ ] **Step 2: Expose `test_support` as `pub` from the crate root for integration tests**

Edit `crates/onym/src/transport.rs`. Change the visibility of the `test_support` module so it is reachable from integration tests:

Replace:
```rust
#[cfg(test)]
pub mod test_support {
```

With:
```rust
#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
```

Then edit `crates/onym/Cargo.toml`. Under `[dependencies]`, add:
```toml
[features]
test-support = []
```

In `crates/onym/Cargo.toml` add a `[dev-dependencies]` section if not present, otherwise leave alone:
```toml
[dev-dependencies]
```

Edit `crates/onym/Cargo.toml` to enable the feature for tests by adding to `[dev-dependencies]`:
```toml
onym = { path = ".", features = ["test-support"] }
```

> **Why:** integration tests in `tests/` are external to the crate, so `#[cfg(test)]`-gated items aren't visible. The `test-support` feature makes them visible to integration tests without leaking them into release builds.

- [ ] **Step 3: Run the streaming test**

Run: `cargo nextest run --test streaming`
Expected: PASS (1 test).

- [ ] **Step 4: Run the full test suite**

Run: `cargo nextest run`
Expected: all unit tests + the new integration test pass.

- [ ] **Step 5: Commit**

```bash
git add crates/onym/tests/streaming.rs crates/onym/src/transport.rs crates/onym/Cargo.toml Cargo.lock
git commit -m "test(streaming): integration test for check_name_stream"
```

---

## Task 16: Verify CLI behavior is unchanged end-to-end

**Files:**
- None (verification only)

- [ ] **Step 1: Build release**

Run: `cargo build --release`
Expected: clean build.

- [ ] **Step 2: `schema` subcommand**

Run: `./target/release/onym schema | head -30`
Expected: machine-readable JSON description, identical (modulo formatting) to pre-refactor output.

Run: `./target/release/onym schema | jq '.subcommands[].name'`
Expected: `"check"` and `"schema"` listed.

- [ ] **Step 3: Valid `check` invocation, table output**

Run: `./target/release/onym check onym -o table 2>&1 | head -30`
Expected: a table with rows for `crates.io`, `pypi`, `npm`, `github`, `.io`, `.dev`, `.com`, `.app`. Exit code 0/1/2 depending on availability.

- [ ] **Step 4: Valid `check` invocation, JSON output**

Run: `./target/release/onym check onym -o json | jq '.results | length'`
Expected: an integer ≥ 8 (4 registries + 4 default TLDs).

- [ ] **Step 5: Invalid input**

Run: `./target/release/onym check 'foo bar'; echo "exit=$?"`
Expected: stderr line `{"error":{"kind":"invalid_input","message":"...","retryable":false}}`. `exit=3`.

- [ ] **Step 6: Custom TLDs**

Run: `./target/release/onym check onym --tld io --tld dev -o json | jq '.results | map(.source) | sort'`
Expected: a JSON array containing `"crates.io"`, `"github"`, `"npm"`, `"pypi"`, `".dev"`, `".io"`. No `.com` or `.app`.

- [ ] **Step 7: Stdout/stderr separation**

Run: `./target/release/onym check 'foo bar' 1>/tmp/stdout.txt 2>/tmp/stderr.txt; cat /tmp/stdout.txt; echo "---"; cat /tmp/stderr.txt`
Expected: `/tmp/stdout.txt` empty (no data on success-output channel for invalid input). `/tmp/stderr.txt` contains the JSON error envelope.

- [ ] **Step 8: Run linters**

Run: `cargo clippy --all-targets -- -D warnings`
Expected: no warnings.

Run: `cargo fmt --check`
Expected: no diff. (If diff exists, run `cargo fmt`, then re-run `cargo nextest run`, and add a separate fmt commit before proceeding.)

- [ ] **Step 9: Final commit (if fmt was needed)**

If Step 8 required formatting:
```bash
git add crates/onym/src
git commit -m "chore: cargo fmt"
```

Otherwise, skip.

---

## Task 17: Update `crates/onym/Cargo.toml` keywords if needed and tag the change in CLAUDE.md

**Files:**
- Modify: `crates/onym/CLAUDE.md` (the project's CLAUDE.md, currently at the repo root — moves to the `onym` crate or stays at root depending on convention)

- [ ] **Step 1: Verify CLAUDE.md location**

Run: `ls CLAUDE.md crates/onym/CLAUDE.md 2>&1`
Expected: `CLAUDE.md` at the workspace root (still there from before the workspace migration).

Leave `CLAUDE.md` at the repo root — it documents the whole repo, not just the `onym` crate.

- [ ] **Step 2: Update the layout section in `CLAUDE.md`**

Edit `CLAUDE.md`. Find the section that describes the layout (it currently shows `src/main.rs`, `src/lib.rs`, etc.). Replace the layout block with:
```
crates/onym/
  src/main.rs       → clap subcommands (check, schema), exit-code mapping
  src/lib.rs        → check_name (batch) + check_name_stream (streaming) over Transport
  src/transport.rs  → Transport trait + ReqwestTransport impl + MockTransport (test-support)
  src/types.rs      → NameReport, CheckResult, Status, TakenInfo, Classification
  src/classify.rs   → heuristics + unit tests
  src/validate.rs   → input allowlist validation + tests
  src/errors.rs     → ErrorKind, CliError, JSON envelope to stderr + tests
  src/schema.rs     → machine-readable CLI description per clispec + tests
  src/output.rs     → Format (Auto/Json/Table/Markdown), TTY detect, render
  src/sources/
    mod.rs
    crates_io.rs    (404=free, 200=full metadata) — uses Transport
    pypi.rs         (releases[version] upload_time for last_release) — uses Transport
    npm.rs          (dist-tags.latest, time.modified) — uses Transport
    github.rs       (HEAD /{name} — avoids API quota) — uses Transport
    rdap.rs         (authoritative RDAP per TLD, rdap.org fallback) — uses Transport
```

- [ ] **Step 3: Commit**

```bash
git add CLAUDE.md
git commit -m "docs: update CLAUDE.md layout for workspace + Transport trait"
```

---

## Self-Review

After completing all tasks, verify:

**1. Spec coverage** — every spec requirement maps to a task:
- Workspace layout (`crates/onym`, `crates/onym-wasm`, `web/`) → Task 1 (creates `crates/onym`; the other dirs are added in plans 2 and 3).
- Transport facade in main crate, not wasm-private → Tasks 3, 4 (defined in `crates/onym/src/transport.rs`).
- `Transport` trait with `fetch_json` + `head` (status-based outcomes) → Task 3.
- `ReqwestTransport` impl preserves CLI behavior → Tasks 4, 11, 16.
- Adapters refactored to take `&impl Transport` → Tasks 6–10.
- Drop `Send` bounds via `FuturesUnordered` → Task 11.
- `check_name` preserved as batch API for CLI → Task 11.
- New `check_name_stream` callback API → Tasks 11, 15.
- RDAP fallback-on-failure semantics preserved (no sleep, no backoff) → Task 10 + test in Task 14 (`falls_through_on_429_then_resolves_at_fallback`).
- 15s timeout preserved (`HTTP_TIMEOUT` constant) → Task 4 (`ReqwestTransport::new`).
- Public API change to `sources/*::check` is acceptable (no external consumers) → handled implicitly across Tasks 6–11.

**Items NOT in this plan (deferred to plans 2/3/4):**
- `onym-wasm` crate, `FetchTransport`, `gloo_net` integration → Plan 2.
- New `api.github.com` adapter → Plan 2 (the existing `github.rs` HEAD-based adapter is preserved here for the CLI).
- Per-source CORS audit → Plan 2.
- `native` default feature gate on the onym crate → Plan 2 (added when `onym-wasm` actually needs it).
- HTML / CSS / JS frontend → Plan 3.
- Cloudflare Worker, hosting → Plan 4.

**2. Placeholder scan** — every step shows the actual change. No "TODO", "TBD", "fill in details", "similar to Task X without code", "add appropriate error handling". Verified.

**3. Type consistency:**
- `Transport` trait methods: `fetch_json(&self, url: &str) -> Result<JsonOutcome<T>, TransportError>` and `head(&self, url: &str) -> Result<HeadOutcome, TransportError>` — used identically across Tasks 3, 4, 5, 6–10, 14.
- `JsonOutcome<T>` variants: `Found(T)`, `NotFound`, `Other(u16)` — referenced consistently.
- `HeadOutcome` variants: `Found`, `NotFound`, `Other(u16)` — referenced consistently.
- `TransportError` variants: `Timeout`, `Network(String)`, `Decode(String)` — consistent.
- `Canned` variants: `Json`, `Found`, `NotFound`, `Other(u16)`, `Network(String)` — `Found` added in Task 14.
- `check_name<T: Transport>(transport: &T, name: &str, tlds: &[String]) -> NameReport` — Task 11.
- `check_name_stream<T, F>(transport: &T, name: &str, tlds: &[String], on_result: F)` where `F: FnMut(CheckResult)` — Tasks 11, 15.

---

## Execution Notes

- **Buildability per commit:** Tasks 6–10 stage but do not commit. Task 11 commits the entire adapter + lib refactor in one commit so the working tree is buildable at every committed state. This is intentional — adapter signatures are coupled to call sites in `lib.rs`, and splitting the commit would leave intermediate states broken.
- **Tests per commit:** Every commit after Task 11 leaves `cargo nextest run` green.
- **No `#[allow(dead_code)]`:** Per the user's `CLAUDE.md`, dead code is investigated and removed, not allowed. If clippy flags any unused items, delete them rather than allow.
