Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-release-core/src/registry.rs

//! Registry-state check for `release doctor --publish` (release-improvements
//! item #10).
//!
//! Today's preflight verify-BUILDS a crate against its currently-published
//! siblings (check 3), but it never asks the registry the ONE direct question:
//! *"what version is actually published?"*. So a local-vs-published version
//! DESYNC slips through: the local tree carries `gatling 0.1.4` while crates.io
//! already serves `0.1.6` (a teammate's / a prior partial cascade's upload).
//! A dependent then verify-builds against the NEWER published `gatling` API and
//! breaks — and the cascade tears mid-flight, leaving some crates live-and-
//! immutable and the rest unshipped.
//!
//! This module adds a PLUGGABLE [`RegistryBackend`] answering exactly that
//! question (the max published, non-yanked version per crate), plus a pure
//! [`classify`] that maps local-vs-published into a [`RegistryState`]. Preflight
//! turns a [`RegistryState::Desync`] into a blocking issue and feeds it into the
//! topological root-cause report, so a desync is blamed at its root.
//!
//! Backends (all best-effort — an unreachable registry degrades to "unknown",
//! never a false block):
//!   * [`CratesIoBackend`] (DEFAULT) — the crates.io SPARSE INDEX
//!     (`https://index.crates.io/<path>/<crate>`), falling back to the JSON API
//!     (`https://crates.io/api/v1/crates/<crate>`). crates.io returns HTTP 403
//!     to a request with NO `User-Agent`, so a descriptive UA is baked in.
//!   * [`CustomBackend`] — a configurable sparse-index base URL, so the operator
//!     can point the check at a private registry / holger mirror.
//!
//! Uses the already-vendored pure-Rust `ureq` client (the same one the publish
//! flow's `wait_for_index` uses) — no reqwest/openssl/native-tls, matching the
//! constellation's pure-Rust charter.

use std::cmp::Ordering;
use std::time::Duration;

/// The ONE canonical crates.io User-Agent for every release HTTP call (index poll,
/// probe, post-publish index wait). crates.io 403s a request that sends none, and its
/// crawler policy asks for a contact address. Defined here — beside the only pure-read
/// registry client — and re-exported by the conductor's pacing module, so the two
/// divergent strings (`"nordisk-release …"` vs `"nornir-release-bot/0.1 …"`) can never
/// re-fork.
pub const RELEASE_USER_AGENT: &str = "nordisk-release (+https://git.gunnar.rs/nordisk/edda)";

/// Descriptive User-Agent — crates.io 403s a request that sends none. ONE source of
/// truth: the canonical release UA (reconciling `publish.rs` + `registry.rs`).
const USER_AGENT: &str = RELEASE_USER_AGENT;

/// The default crates.io sparse-index base.
const CRATES_IO_SPARSE_BASE: &str = "https://index.crates.io";

/// A pluggable registry backend answering the question preflight was missing:
/// the MAX published (non-yanked) version of a crate.
pub trait RegistryBackend {
    /// Max published, non-yanked version of `krate`, or `None` when the crate is
    /// unpublished OR the registry is unreachable/unparsable (best-effort — a
    /// probe miss becomes [`RegistryState::Unpublished`]/[`Unknown`], never a
    /// false block).
    ///
    /// [`Unknown`]: RegistryState::Unknown
    fn published_max_version(&self, krate: &str) -> Option<String>;

    /// Is `version` of `krate` currently AVAILABLE (published, non-yanked) on this
    /// registry? `Some(true)`/`Some(false)` when the crate's index could be read
    /// and parsed; `None` when the registry is unreachable/unparsable — INCONCLUSIVE,
    /// which callers MUST degrade safely on (never a hard-fail on a `None`).
    ///
    /// This backs the cross-repo lock reconcile's SKIP-IF-LIVE rule: a locked pin
    /// whose version is still available on the target registry is publishable as-is
    /// and must NOT be force-moved (the real crates.io path, where every published
    /// version stays live). A shadowed/absent version (`Some(false)` — the Gate-B
    /// `/sparring` rehearsal, where a local publish hides the crates.io pull-through
    /// of the old version) genuinely needs moving to the shipped one.
    fn has_version(&self, krate: &str, version: &str) -> Option<bool>;

    /// EVERY published, non-yanked version of `krate`, or an empty vec when the crate
    /// is unpublished OR the registry is unreachable/unparsable (best-effort, exactly
    /// like [`published_max_version`](RegistryBackend::published_max_version) — a probe
    /// miss must never become a false block).
    ///
    /// `has_version` answers "is THIS one there?" and `published_max_version` answers
    /// "what is the newest?"; neither can answer **"what is the LOWEST version that
    /// satisfies a declared requirement?"** — and that is the question a
    /// minimum-requirement gate has to ask. Measured 2026-08-03: seven crates shipped
    /// declaring a sibling requirement that did not contain the API they call
    /// (`znippy-zip-core`, `lgz`, `ljar`, `lzip-parallel` all declared
    /// `linflate = "0.1.9"` while calling `clamp_expected_size`, which first appeared in
    /// `linflate 0.1.11`). A `path = …` dep masks it completely — cargo resolves through
    /// the path locally, so the workspace is green while the manifest understates its
    /// own floor. Checking the published MAX cannot see it: the max does export the
    /// symbol. Only the lowest admitted version can.
    ///
    /// DEFAULTS TO EMPTY — "this backend cannot enumerate versions", which every caller
    /// must read as INCONCLUSIVE and degrade on, never as "no versions exist". That is
    /// deliberate: a backend that has not opted in (a test fake, a registry with no
    /// listing endpoint) must not be able to manufacture a block, and an empty vec is
    /// already the shape a probe miss returns.
    fn published_versions(&self, _krate: &str) -> Vec<String> {
        Vec::new()
    }
}

/// crates.io backend (DEFAULT): sparse index first, JSON API fallback.
pub struct CratesIoBackend;

impl RegistryBackend for CratesIoBackend {
    fn published_max_version(&self, krate: &str) -> Option<String> {
        // 1. SPARSE INDEX (preferred — cheap, cache-friendly, one crate per file).
        let url = sparse_index_url(CRATES_IO_SPARSE_BASE, krate);
        if let Some(body) = http_get(&url) {
            if let Some(v) = parse_sparse_index_max_version(&body) {
                return Some(v);
            }
        }
        // 2. JSON API fallback (needs the UA or crates.io answers 403).
        let url = format!("https://crates.io/api/v1/crates/{krate}");
        let body = http_get(&url)?;
        parse_json_api_max_version(&body)
    }

    fn has_version(&self, krate: &str, version: &str) -> Option<bool> {
        // SPARSE INDEX first: a 200 body lists every published version, so a hit or a
        // miss is CONCLUSIVE. A transport error / 404 (`http_get` → None) falls through
        // to the JSON API; if THAT also fails we return None (inconclusive), never a
        // false "absent".
        let url = sparse_index_url(CRATES_IO_SPARSE_BASE, krate);
        if let Some(body) = http_get(&url) {
            return Some(parse_sparse_index_versions(&body).iter().any(|v| v == version));
        }
        let url = format!("https://crates.io/api/v1/crates/{krate}");
        let body = http_get(&url)?;
        Some(parse_json_api_versions(&body).iter().any(|v| v == version))
    }

    fn published_versions(&self, krate: &str) -> Vec<String> {
        // SPARSE INDEX first (same precedence as `has_version`), JSON API as fallback.
        let url = sparse_index_url(CRATES_IO_SPARSE_BASE, krate);
        if let Some(body) = http_get(&url) {
            let vs = parse_sparse_index_versions(&body);
            if !vs.is_empty() {
                return vs;
            }
        }
        let url = format!("https://crates.io/api/v1/crates/{krate}");
        match http_get(&url) {
            Some(body) => parse_json_api_versions(&body),
            None => Vec::new(),
        }
    }
}

/// A custom/other backend: a configurable SPARSE-INDEX base URL (private
/// registry / holger). crates.io is the default when this is unset.
pub struct CustomBackend {
    /// Sparse-index base, e.g. `https://holger.internal/index` (no trailing
    /// slash needed — trimmed).
    pub base_url: String,
}

impl RegistryBackend for CustomBackend {
    fn published_max_version(&self, krate: &str) -> Option<String> {
        let url = sparse_index_url(self.base_url.trim_end_matches('/'), krate);
        let body = http_get(&url)?;
        parse_sparse_index_max_version(&body)
    }

    fn has_version(&self, krate: &str, version: &str) -> Option<bool> {
        // The served `/sparring` index (rehearsal): a fetchable body enumerates exactly
        // what the index serves, so a shadowed old version is CONCLUSIVELY absent. An
        // unreachable index → None (inconclusive), degrade-safe.
        let url = sparse_index_url(self.base_url.trim_end_matches('/'), krate);
        let body = http_get(&url)?;
        Some(parse_sparse_index_versions(&body).iter().any(|v| v == version))
    }

    fn published_versions(&self, krate: &str) -> Vec<String> {
        let url = sparse_index_url(self.base_url.trim_end_matches('/'), krate);
        match http_get(&url) {
            Some(body) => parse_sparse_index_versions(&body),
            None => Vec::new(),
        }
    }
}

/// The resolved backend choice — the pure decision behind [`resolve`], split out
/// so the precedence rule is unit-testable without constructing trait objects.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BackendChoice {
    /// crates.io (sparse index + JSON fallback).
    CratesIo,
    /// A custom sparse-index base URL.
    Custom(String),
}

/// Pure precedence rule: an optional `--registry-url` CLI override ALWAYS wins
/// (→ custom); otherwise `backend = "custom"` with a non-empty `url` selects
/// custom, and everything else (including the default `crates-io`, and a
/// `custom` with an empty url) selects crates.io.
pub fn choose(backend: &str, url: &str, flag_url: Option<&str>) -> BackendChoice {
    if let Some(u) = flag_url.filter(|u| !u.trim().is_empty()) {
        return BackendChoice::Custom(u.trim().to_string());
    }
    match backend {
        "custom" if !url.trim().is_empty() => BackendChoice::Custom(url.trim().to_string()),
        _ => BackendChoice::CratesIo,
    }
}

/// Build a backend from the resolved config `[registry]` values plus an optional
/// `--registry-url` CLI override (see [`choose`] for the precedence rule).
pub fn resolve(backend: &str, url: &str, flag_url: Option<&str>) -> Box<dyn RegistryBackend> {
    match choose(backend, url, flag_url) {
        BackendChoice::Custom(base_url) => Box::new(CustomBackend { base_url }),
        BackendChoice::CratesIo => Box::new(CratesIoBackend),
    }
}

/// Build a crate's sparse-index URL under `base`, following cargo's index-path
/// convention: `1/{n}`, `2/{n}`, `3/{a}/{n}`, else `{ab}/{cd}/{n}` (crate names
/// are lower-cased for the path).
pub fn sparse_index_url(base: &str, krate: &str) -> String {
    let name = krate.to_lowercase();
    let path = match name.chars().count() {
        0 => name.clone(),
        1 => format!("1/{name}"),
        2 => format!("2/{name}"),
        3 => format!("3/{}/{}", &name[..1], name),
        _ => format!("{}/{}/{}", &name[..2], &name[2..4], name),
    };
    format!("{base}/{path}")
}

/// Parse the crates.io SPARSE-INDEX body — newline-delimited JSON, one object
/// per published version (`vers`, `yanked`). Returns the semver-MAX of the
/// NON-YANKED versions (robust to out-of-order patch backports; the last
/// non-yanked line is the common case). `None` when nothing parses / all yanked.
pub fn parse_sparse_index_max_version(body: &str) -> Option<String> {
    let mut best: Option<(semver::Version, String)> = None;
    for line in body.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
            continue;
        };
        if v.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false) {
            continue;
        }
        let Some(vers) = v.get("vers").and_then(|s| s.as_str()) else {
            continue;
        };
        let Ok(parsed) = semver::Version::parse(vers) else {
            continue;
        };
        if best.as_ref().map(|(b, _)| parsed > *b).unwrap_or(true) {
            best = Some((parsed, vers.to_string()));
        }
    }
    best.map(|(_, raw)| raw)
}

/// Every published, NON-YANKED version in a sparse-index body (newline-delimited
/// JSON, one object per version). Order as read; yanked and unparsable lines are
/// dropped. Used by [`RegistryBackend::has_version`]'s exact-version presence check
/// (a yanked version can no longer be selected, so it counts as absent).
pub fn parse_sparse_index_versions(body: &str) -> Vec<String> {
    let mut out = Vec::new();
    for line in body.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
            continue;
        };
        if v.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false) {
            continue;
        }
        if let Some(vers) = v.get("vers").and_then(|s| s.as_str()) {
            out.push(vers.to_string());
        }
    }
    out
}

/// Every published, NON-YANKED `num` in a JSON-API body (the `has_version` fallback
/// when the sparse index is unreachable). Order as read; yanked/unparsable dropped.
pub fn parse_json_api_versions(body: &str) -> Vec<String> {
    let Ok(json) = serde_json::from_str::<serde_json::Value>(body) else {
        return Vec::new();
    };
    let Some(arr) = json.get("versions").and_then(|v| v.as_array()) else {
        return Vec::new();
    };
    arr.iter()
        .filter(|e| !e.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false))
        .filter_map(|e| e.get("num").and_then(|n| n.as_str()).map(str::to_string))
        .collect()
}

/// Parse the crates.io JSON API body (`{"versions":[{"num","yanked"},…],
/// "crate":{"max_stable_version"}}`). Semver-max of the non-yanked `num`s;
/// falls back to `crate.max_stable_version` / `crate.max_version`.
pub fn parse_json_api_max_version(body: &str) -> Option<String> {
    let json: serde_json::Value = serde_json::from_str(body).ok()?;
    if let Some(arr) = json.get("versions").and_then(|v| v.as_array()) {
        let mut best: Option<(semver::Version, String)> = None;
        for e in arr {
            if e.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false) {
                continue;
            }
            let Some(num) = e.get("num").and_then(|n| n.as_str()) else {
                continue;
            };
            let Ok(parsed) = semver::Version::parse(num) else {
                continue;
            };
            if best.as_ref().map(|(b, _)| parsed > *b).unwrap_or(true) {
                best = Some((parsed, num.to_string()));
            }
        }
        if best.is_some() {
            return best.map(|(_, raw)| raw);
        }
    }
    json.get("crate")
        .and_then(|c| c.get("max_stable_version").or_else(|| c.get("max_version")))
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
}

/// How a crate's LOCAL version relates to what the registry has published.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegistryState {
    /// local > published — the bump leads the registry → WILL PUBLISH (good).
    WillPublish,
    /// local == published — nothing new to ship; the cascade will SKIP it.
    AlreadyPublished,
    /// local < published — the LOCAL tree is BEHIND the registry. THE BUG: a
    /// dependent verify-builds against the newer published API and breaks. A
    /// blocker.
    Desync,
    /// The registry has no published version — this is a first publish.
    Unpublished,
    /// A version string didn't parse (local or published) — degrade to unknown,
    /// never a false block.
    Unknown,
}

/// Pure local-vs-published classification. `published = None` ⇒ the crate is
/// [`Unpublished`](RegistryState::Unpublished) (first publish). An unparsable
/// version ⇒ [`Unknown`](RegistryState::Unknown).
pub fn classify(local: &str, published: Option<&str>) -> RegistryState {
    let Some(published) = published else {
        return RegistryState::Unpublished;
    };
    let (Ok(l), Ok(p)) = (semver::Version::parse(local), semver::Version::parse(published))
    else {
        return RegistryState::Unknown;
    };
    match l.cmp(&p) {
        Ordering::Greater => RegistryState::WillPublish,
        Ordering::Equal => RegistryState::AlreadyPublished,
        Ordering::Less => RegistryState::Desync,
    }
}

/// A live probe over any [`RegistryBackend`]: fetch the published max version and
/// classify `local` against it. Handy for callers that only need the verdict.
pub fn state_of(backend: &dyn RegistryBackend, krate: &str, local: &str) -> RegistryState {
    classify(local, backend.published_max_version(krate).as_deref())
}

/// A ureq 3 agent with ureq 2's defaults, so the bump changes no behaviour:
/// ureq 2 (built without `proxy-from-env`) never read `HTTP(S)_PROXY` and
/// followed at most 5 redirects; ureq 3 reads the environment and allows 10.
/// (ureq 2 -> 3 bump, 2026-10-02.)
fn http_agent(timeout: Duration) -> ureq::Agent {
    ureq::Agent::config_builder()
        .timeout_global(Some(timeout))
        .proxy(None)
        .max_redirects(5)
        .build()
        .new_agent()
}

/// Thin ureq GET with the baked-in UA + a 10s timeout, body as a `String`.
/// `None` on any transport/decoding error (best-effort probe).
fn http_get(url: &str) -> Option<String> {
    http_agent(Duration::from_secs(10))
        .get(url)
        .header("User-Agent", USER_AGENT)
        .call()
        .ok()?
        .into_body()
        .read_to_string()
        .ok()
}

// ─────────────────── crate-name OWNERSHIP preflight ───────────────────
//
// Publishing a crate whose NAME already exists on crates.io owned by someone
// ELSE returns `403 Forbidden: this crate exists but you don't seem to be an
// owner`. `cargo publish --dry-run` does NOT catch it (it never contacts the
// ownership endpoint), so a name collision (the real `gatling`/`rotary` traps —
// both squatted by third parties) only surfaced mid-cascade as a fail-stop. This
// probe answers "is this name free, or ours?" so preflight (incl. the dry-run)
// refuses BEFORE the irreversible cascade.

/// Read the crates.io API token: `$CARGO_REGISTRY_TOKEN`, else `[registry].token`
/// (or `[registries.crates-io].token`) in `~/.cargo/credentials.toml`. `None`
/// when no token is configured (→ the ownership check degrades to a warning).
pub fn read_crates_io_token() -> Option<String> {
    if let Ok(t) = std::env::var("CARGO_REGISTRY_TOKEN") {
        if !t.trim().is_empty() {
            return Some(t);
        }
    }
    let home = std::env::var_os("HOME")?;
    let txt = std::fs::read_to_string(std::path::Path::new(&home).join(".cargo/credentials.toml"))
        .ok()?;
    let doc: toml::Value = txt.parse::<toml::Table>().map(toml::Value::Table).ok()?;
    let pick = |t: &toml::Value| t.get("token").and_then(|v| v.as_str()).map(str::to_string);
    doc.get("registry")
        .and_then(pick)
        .or_else(|| doc.get("registries").and_then(|r| r.get("crates-io")).and_then(pick))
}

/// Our own crates.io login, resolved from the API `token` via `GET /me`. `None`
/// if the call fails (→ the ownership check is skipped, never a false block).
pub fn crates_io_login(token: &str) -> Option<String> {
    let body = http_agent(Duration::from_secs(10))
        .get("https://crates.io/api/v1/me")
        .header("User-Agent", USER_AGENT)
        .header("Authorization", token)
        .call()
        .ok()?
        .into_body()
        .read_to_string()
        .ok()?;
    let json: serde_json::Value = serde_json::from_str(&body).ok()?;
    json.get("user")?.get("login")?.as_str().map(str::to_string)
}

/// The owner logins of `krate` on crates.io (public endpoint, no auth). `None`
/// when the crate does not exist (free to claim) or the registry is unreachable.
pub fn crate_owner_logins(krate: &str) -> Option<Vec<String>> {
    let json: serde_json::Value =
        serde_json::from_str(&http_get(&format!("https://crates.io/api/v1/crates/{krate}/owners"))?)
            .ok()?;
    let logins: Vec<String> = json
        .get("users")?
        .as_array()?
        .iter()
        .filter_map(|u| u.get("login").and_then(|l| l.as_str()).map(str::to_string))
        .collect();
    (!logins.is_empty()).then_some(logins)
}

/// A name we intend to publish that ALREADY EXISTS on crates.io owned by someone
/// other than us — publishing it will 403.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnershipBlock {
    pub krate: String,
    pub owners: Vec<String>,
}

/// For each name we plan to publish, flag the ones that exist on crates.io but are
/// NOT owned by `my_login` (case-insensitive). Degrade-safe: a name that doesn't
/// exist (free) or is unreachable is NOT flagged; `my_login = None` returns empty
/// (the caller warns that the check couldn't run). Pure decision given the probe
/// via the injected `owners_of` (real calls in production, deterministic in test).
pub fn ownership_blocks_with(
    names: &[String],
    my_login: Option<&str>,
    owners_of: impl Fn(&str) -> Option<Vec<String>>,
) -> Vec<OwnershipBlock> {
    let Some(me) = my_login else { return Vec::new() };
    names
        .iter()
        .filter_map(|n| {
            let owners = owners_of(n)?; // None ⇒ free/unreachable ⇒ not a block.
            (!owners.iter().any(|o| o.eq_ignore_ascii_case(me)))
                .then(|| OwnershipBlock { krate: n.clone(), owners })
        })
        .collect()
}

/// Live wrapper over [`ownership_blocks_with`] using the real crates.io owners API.
pub fn ownership_blocks(names: &[String], my_login: Option<&str>) -> Vec<OwnershipBlock> {
    ownership_blocks_with(names, my_login, crate_owner_logins)
}

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

    /// The ownership gate flags ONLY names that exist on crates.io owned by
    /// someone else — a free name or one we own passes; no login → skip (never a
    /// false block). Deterministic via an injected owners probe (no network).
    #[test]
    fn ownership_blocks_flags_only_foreign_existing_names() {
        let owners = |k: &str| -> Option<Vec<String>> {
            match k {
                "gatling" => Some(vec!["AbdelStark".to_string()]), // exists, not ours
                "rotary" => Some(vec!["udoprog".to_string()]),     // exists, not ours
                "minigun" => None,                                 // free to claim
                "znippy-common" => Some(vec!["Nordisk".to_string(), "alice".to_string()]), // ours (ci)
                _ => None,
            }
        };
        let names: Vec<String> =
            ["gatling", "rotary", "minigun", "znippy-common"].iter().map(|s| s.to_string()).collect();
        let flagged: Vec<String> =
            ownership_blocks_with(&names, Some("alice"), owners).into_iter().map(|b| b.krate).collect();
        assert_eq!(flagged, vec!["gatling", "rotary"], "only foreign existing names block");
        // No login → the check can't run → nothing blocked (degrade-safe).
        assert!(ownership_blocks_with(&names, None, owners).is_empty());
    }

    /// The sparse-index line parser: newline-delimited JSON, semver-MAX of the
    /// non-yanked `vers` — the last non-yanked line in the common in-order case.
    #[test]
    fn sparse_index_parses_max_non_yanked_version() {
        // Real crates.io shape (trimmed): three published lines, in order.
        let body = concat!(
            r#"{"name":"gatling","vers":"0.1.3","yanked":false}"#,
            "\n",
            r#"{"name":"gatling","vers":"0.1.4","yanked":false}"#,
            "\n",
        );
        assert_eq!(parse_sparse_index_max_version(body).as_deref(), Some("0.1.4"));
    }

    /// A YANKED newest line must be ignored — the max is the newest NON-yanked.
    #[test]
    fn sparse_index_skips_yanked_and_is_order_independent() {
        // 0.2.0 is yanked; lines are OUT of order — max must still be 0.1.9.
        let body = concat!(
            r#"{"name":"c","vers":"0.1.9","yanked":false}"#,
            "\n",
            r#"{"name":"c","vers":"0.2.0","yanked":true}"#,
            "\n",
            r#"{"name":"c","vers":"0.1.4","yanked":false}"#,
            "\n",
        );
        assert_eq!(parse_sparse_index_max_version(body).as_deref(), Some("0.1.9"));
    }

    /// Blank lines, garbage lines, and an all-yanked crate degrade to `None`
    /// rather than panicking or picking a yanked version.
    #[test]
    fn sparse_index_tolerates_junk_and_all_yanked() {
        assert_eq!(parse_sparse_index_max_version(""), None);
        assert_eq!(parse_sparse_index_max_version("not json\n\n{bad"), None);
        let all_yanked = concat!(
            r#"{"name":"z","vers":"1.0.0","yanked":true}"#,
            "\n",
            r#"{"name":"z","vers":"1.1.0","yanked":true}"#,
            "\n",
        );
        assert_eq!(parse_sparse_index_max_version(all_yanked), None);
    }

    /// `has_version` presence check (backs the reconcile SKIP-IF-LIVE rule): the
    /// full non-yanked version list from a sparse-index body, and that a yanked
    /// version reads as ABSENT (it can no longer be selected).
    #[test]
    fn sparse_index_lists_all_non_yanked_versions_for_presence() {
        let body = concat!(
            r#"{"name":"nornir-testmatrix","vers":"0.2.6","yanked":false}"#,
            "\n",
            r#"{"name":"nornir-testmatrix","vers":"0.2.7","yanked":false}"#,
            "\n",
            r#"{"name":"nornir-testmatrix","vers":"0.2.5","yanked":true}"#,
            "\n",
        );
        let vs = parse_sparse_index_versions(body);
        assert_eq!(vs, vec!["0.2.6".to_string(), "0.2.7".to_string()]);
        // The skade fail-stop: the LIVE locked 0.2.7 is present ⇒ never move it.
        assert!(vs.iter().any(|v| v == "0.2.7"));
        // A yanked version is absent (unselectable) ⇒ would need moving.
        assert!(!vs.iter().any(|v| v == "0.2.5"));
        // JSON-API fallback lists the same way.
        let api = r#"{"versions":[{"num":"0.2.7","yanked":false},{"num":"0.2.5","yanked":true}]}"#;
        assert_eq!(parse_json_api_versions(api), vec!["0.2.7".to_string()]);
    }

    /// JSON API fallback: semver-max of the non-yanked `num`s, then the
    /// `crate.max_stable_version` field.
    #[test]
    fn json_api_parses_versions_then_max_stable() {
        let body = r#"{
            "crate": {"max_stable_version": "0.9.9"},
            "versions": [
                {"num": "0.1.4", "yanked": false},
                {"num": "0.2.0", "yanked": true},
                {"num": "0.1.9", "yanked": false}
            ]
        }"#;
        assert_eq!(parse_json_api_max_version(body).as_deref(), Some("0.1.9"));
        // No versions array → fall back to crate.max_stable_version.
        let only_crate = r#"{"crate": {"max_stable_version": "3.2.1"}}"#;
        assert_eq!(parse_json_api_max_version(only_crate).as_deref(), Some("3.2.1"));
    }

    /// The classify table (release-improvements item #10, requirement 2).
    #[test]
    fn classify_covers_the_full_table() {
        assert_eq!(classify("0.1.7", Some("0.1.4")), RegistryState::WillPublish);
        assert_eq!(classify("0.1.4", Some("0.1.4")), RegistryState::AlreadyPublished);
        assert_eq!(classify("0.1.4", Some("0.1.6")), RegistryState::Desync);
        assert_eq!(classify("0.1.0", None), RegistryState::Unpublished);
        assert_eq!(classify("not-a-semver", Some("0.1.0")), RegistryState::Unknown);
    }

    /// The exact incident this feature exists to catch: local `gatling` behind
    /// the published registry version — DESYNC blocker.
    #[test]
    fn classify_flags_the_gatling_desync() {
        // Local tree still at 0.1.4 while crates.io already serves 0.1.6.
        assert_eq!(classify("0.1.4", Some("0.1.6")), RegistryState::Desync);
    }

    /// LIVE (network) smoke: the default crates.io backend resolves the real
    /// published max of `gatling` (currently 0.1.4) and classifies a behind-local
    /// as a desync. `#[ignore]` — run with `--ignored` when a network is present.
    #[test]
    #[ignore]
    fn live_cratesio_backend_resolves_gatling() {
        let v = CratesIoBackend.published_max_version("gatling").expect("gatling published");
        eprintln!("live gatling published max = {v}");
        let published = semver::Version::parse(&v).expect("valid semver");
        // A local tree behind the published max is a desync (the incident class).
        let behind = format!("{}.{}.{}", published.major, published.minor, published.patch.saturating_sub(1));
        assert_eq!(classify(&behind, Some(&v)), RegistryState::Desync);
    }

    /// cargo index-path convention for the sparse URL.
    #[test]
    fn sparse_index_url_follows_cargo_path_convention() {
        assert_eq!(sparse_index_url("https://index.crates.io", "a"), "https://index.crates.io/1/a");
        assert_eq!(sparse_index_url("https://index.crates.io", "ab"), "https://index.crates.io/2/ab");
        assert_eq!(sparse_index_url("https://index.crates.io", "abc"), "https://index.crates.io/3/a/abc");
        assert_eq!(
            sparse_index_url("https://index.crates.io", "gatling"),
            "https://index.crates.io/ga/tl/gatling"
        );
        // Trailing slash on a custom base is trimmed by the caller (CustomBackend).
        assert_eq!(sparse_index_url("https://reg/idx", "Serde"), "https://reg/idx/se/rd/serde");
    }

    /// `choose`: the CLI `--registry-url` flag beats config; `custom`+url selects
    /// custom; default (and `custom` with an empty url) → crates.io.
    #[test]
    fn choose_precedence_flag_then_config_then_default() {
        // Flag wins over everything (even an explicit crates-io config).
        assert_eq!(
            choose("crates-io", "", Some("https://flag/idx")),
            BackendChoice::Custom("https://flag/idx".into())
        );
        // custom + url → custom.
        assert_eq!(
            choose("custom", "https://cfg/idx", None),
            BackendChoice::Custom("https://cfg/idx".into())
        );
        // default (crates-io, no url, no flag) → crates.io.
        assert_eq!(choose("crates-io", "", None), BackendChoice::CratesIo);
        // custom but EMPTY url → falls back to crates.io.
        assert_eq!(choose("custom", "", None), BackendChoice::CratesIo);
        // empty/whitespace flag is ignored (falls through to config).
        assert_eq!(choose("custom", "https://cfg/idx", Some("  ")), BackendChoice::Custom("https://cfg/idx".into()));
    }
}

// ─── the injectable registry PROBE seam ─────────────────────────────────────
//
// Relocated here from the conductor's `preflight` (which now re-exports it): the
// probe is a pure READ over the registry, the same question this module already
// answers, and the doctor's order analysis needs it without pulling the whole
// constructive preflight engine.

/// Injectable registry probe so the version/consistency check is deterministically
/// testable without hitting crates.io. `Some(true)` = version live, `Some(false)` =
/// absent, `None` = couldn't determine (network down) → the check degrades to a note.
pub trait RegistryProbe {
    fn version_live(&self, krate: &str, version: &str) -> Option<bool>;

    /// Whether crates.io ALREADY serves a NON-yanked published version satisfying
    /// `req` (a semver [`semver::VersionReq`] string, e.g. `"0.1"` ⇒ `^0.1`). This
    /// answers "can the consumer resolve this dep from the registry as-is?" — the
    /// basis for treating a cross-repo edge as non-order-gating. `Some(false)` = no
    /// live version matches (must publish first); `None` = couldn't determine
    /// (network / unparsable `req`) → the caller stays conservative and keeps the
    /// edge gating. The default returns `None` (a probe that only knows exact-version
    /// liveness cannot answer); [`CratesIoProbe`] overrides it.
    fn req_satisfied(&self, _krate: &str, _req: &str) -> Option<bool> {
        None
    }
}

/// Live crates.io probe (sparse index) reusing the vendored `ureq` client — same
/// pattern as the release conductor's post-publish index wait.
pub struct CratesIoProbe;

impl CratesIoProbe {
    /// The published versions of `krate` as `(num, yanked)`. `None` on any
    /// network/parse failure (the callers degrade to a conservative answer).
    fn fetch_versions(krate: &str) -> Option<Vec<(String, bool)>> {
        let url = format!("https://crates.io/api/v1/crates/{krate}");
        let agent = "nornir-release-bot/0.1 (cargo-pipeline; +https://crates.io)";
        let resp = http_agent(std::time::Duration::from_secs(10))
            .get(&url)
            .header("User-Agent", agent)
            .call()
            .ok()?;
        let body = resp.into_body().read_to_string().ok()?;
        let json: serde_json::Value = serde_json::from_str(&body).ok()?;
        let versions = json.get("versions").and_then(|v| v.as_array())?;
        Some(
            versions
                .iter()
                .filter_map(|e| {
                    let num = e.get("num").and_then(|n| n.as_str())?.to_string();
                    let yanked = e.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false);
                    Some((num, yanked))
                })
                .collect(),
        )
    }
}

impl RegistryProbe for CratesIoProbe {
    fn version_live(&self, krate: &str, version: &str) -> Option<bool> {
        let versions = Self::fetch_versions(krate)?;
        Some(versions.iter().any(|(num, _yanked)| num == version))
    }

    fn req_satisfied(&self, krate: &str, req: &str) -> Option<bool> {
        // An unparsable requirement can't be reasoned about → conservative (gating).
        let req = semver::VersionReq::parse(req).ok()?;
        let versions = Self::fetch_versions(krate)?;
        Some(versions.iter().any(|(num, yanked)| {
            !*yanked
                && semver::Version::parse(num).map(|v| req.matches(&v)).unwrap_or(false)
        }))
    }
}