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/src/source_drift.rs

//! Same-version SOURCE-DRIFT detection — the "edited without a version bump" trap.
//!
//! `cargo publish` is immutable per `<crate>@<version>`, but nothing stops a local
//! tree from EDITING a crate's source while leaving the version number untouched.
//! When that happens the registry keeps serving the OLD bytes, so a dependent's
//! `cargo publish --verify` (which strips `[patch.crates-io]` and resolves the
//! registry copy) compiles against stale code and fail-stops — one cargo-verify at
//! a time, deep in the cascade. This is exactly what bit the fat release
//! (`znippy-plugin-python`, `holger-traits`, `funnel-core`: new local API, same
//! version, broken registry copy).
//!
//! This module compares a crate's LOCAL shippable source against the copy already
//! PUBLISHED at the same version and returns a [`DriftVerdict`]:
//!   * [`DriftVerdict::Drifted`] → the source changed but the version did not — a
//!     hard preflight blocker demanding a version bump (`preflight::source_drift_issues`).
//!   * [`DriftVerdict::Unchanged`] → byte-identical → the crate can be SKIPPED
//!     (no bump, no republish). This is the lever that kills the rate-limit grind
//!     on large unchanged crate sets (the facett "timeout").
//!   * [`DriftVerdict::Unknown`] → couldn't determine (not published yet, network
//!     or extraction failure, `cargo package --list` unavailable) → a NOTE, never
//!     a false block. Correctness rule: we only ever BLOCK on a positive,
//!     content-proven mismatch.
//!
//! The published tarball's `Cargo.toml` is cargo-NORMALIZED (path-deps stripped,
//! inheritance resolved, keys reordered), so it is excluded; the RAW manifest is
//! compared via the tarball's verbatim `Cargo.toml.orig`, canonicalised as TOML on
//! both sides so mere formatting never trips a false block. `Cargo.lock` and
//! `.cargo_vcs_info.json` are synthesized and always excluded.

use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use std::path::Path;
use std::process::Command;

/// Verdict comparing a crate's LOCAL shippable source to the copy PUBLISHED at the
/// same version.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DriftVerdict {
    /// Local source is (semantically) identical to the published copy at this
    /// version → safe to SKIP republishing.
    Unchanged,
    /// Local source DIFFERS from the published copy at the SAME version — a
    /// version bump is REQUIRED before publish. `differing` lists the affected
    /// crate-root-relative paths (added / removed / changed), sorted and capped.
    Drifted { differing: Vec<String> },
    /// Couldn't determine — degrade to a note, NEVER a false block.
    Unknown(String),
}

/// Max bytes we will pull for a single published `.crate` (a generous ceiling —
/// real source crates are well under this; guards against a pathological download).
const MAX_CRATE_BYTES: u64 = 64 * 1024 * 1024;

/// Cap on the number of differing paths surfaced in a [`DriftVerdict::Drifted`].
const MAX_DIFFERING_SHOWN: usize = 12;

/// An entry excluded from the drift comparison: either cargo-SYNTHESIZED
/// (`Cargo.lock`, `.cargo_vcs_info.json`) or pure VCS metadata (`.gitignore`,
/// `.gitattributes`) that can never change how a DEPENDENT compiles this crate.
/// The normalized `Cargo.toml` is excluded here too — the RAW manifest is compared
/// separately via `Cargo.toml.orig`. Excluding these avoids a false hard-block on a
/// trivial metadata edit while still catching every build-affecting source change.
fn is_excluded_entry(rel: &str) -> bool {
    matches!(
        rel,
        "Cargo.toml" | "Cargo.lock" | ".cargo_vcs_info.json" | ".gitignore" | ".gitattributes"
    )
}

/// Canonicalise a manifest's bytes for comparison: parse as TOML and re-serialise
/// deterministically, so verbatim-but-reformatted manifests (trailing newline, key
/// order) compare EQUAL. On any parse failure, fall back to the raw bytes (a
/// non-manifest or malformed file still compares exactly).
fn canonical_manifest(bytes: &[u8]) -> Vec<u8> {
    match std::str::from_utf8(bytes).ok().and_then(|s| s.parse::<toml::Table>().map(toml::Value::Table).ok()) {
        Some(v) => toml::to_string(&v).map(String::into_bytes).unwrap_or_else(|_| bytes.to_vec()),
        None => bytes.to_vec(),
    }
}

/// PURE, deterministic diff of two comparison maps keyed by crate-root-relative
/// path. Both maps are assumed already normalized (synthesized entries dropped,
/// `Cargo.toml.orig` remapped to the `Cargo.toml` key and canonicalised). This is
/// the unit-tested core — no IO.
pub fn diff_source_maps(
    local: &BTreeMap<String, Vec<u8>>,
    published: &BTreeMap<String, Vec<u8>>,
) -> DriftVerdict {
    let mut differing: Vec<String> = Vec::new();
    let keys: BTreeSet<&String> = local.keys().chain(published.keys()).collect();
    for k in keys {
        match (local.get(k), published.get(k)) {
            (Some(a), Some(b)) if a == b => {}
            (Some(_), Some(_)) => differing.push(format!("{k} (changed)")),
            (Some(_), None) => differing.push(format!("{k} (added locally)")),
            (None, Some(_)) => differing.push(format!("{k} (removed locally)")),
            (None, None) => {}
        }
    }
    if differing.is_empty() {
        DriftVerdict::Unchanged
    } else {
        DriftVerdict::Drifted { differing }
    }
}

/// Render a capped, human-readable list of the differing paths.
pub fn summarize_differing(differing: &[String]) -> String {
    if differing.len() <= MAX_DIFFERING_SHOWN {
        differing.join(", ")
    } else {
        format!(
            "{}, (+{} more)",
            differing[..MAX_DIFFERING_SHOWN].join(", "),
            differing.len() - MAX_DIFFERING_SHOWN
        )
    }
}

/// Decide whether a crate may SKIP re-publishing during a `--bump` cascade: ONLY
/// when it already has a published version AND its source is byte-identical to it
/// ([`DriftVerdict::Unchanged`]). A first publish (no `published_max`), a drift, or
/// an inconclusive verdict all ⇒ do NOT skip (bump + publish) — the cascade never
/// silently skips a crate it could not PROVE is unchanged. Pure and testable.
pub fn can_skip_republish(published_max: Option<&str>, verdict: &DriftVerdict) -> bool {
    published_max.is_some() && matches!(verdict, DriftVerdict::Unchanged)
}

/// The published registry, as a source of truth for a crate's shipped bytes —
/// injectable so [`assess`] is testable without network.
pub trait PublishedSourceOracle {
    /// Fetch the NORMALIZED published comparison map for `<krate>@<version>`
    /// (synthesized entries dropped, `Cargo.toml.orig` → canonical `Cargo.toml`),
    /// or an `Err(reason)` on any failure (⇒ [`DriftVerdict::Unknown`]).
    fn published_sources(&self, krate: &str, version: &str) -> Result<BTreeMap<String, Vec<u8>>, String>;
}

/// The real crates.io CDN oracle.
pub struct CratesIoSources;

impl PublishedSourceOracle for CratesIoSources {
    fn published_sources(&self, krate: &str, version: &str) -> Result<BTreeMap<String, Vec<u8>>, String> {
        let bytes = download_crate(krate, version)?;
        extract_published_map(&bytes)
    }
}

/// GET the published `.crate` tarball from the crates.io CDN (static host, no
/// redirect) via the vendored `ureq` client with the baked-in UA and a timeout.
fn download_crate(krate: &str, version: &str) -> Result<Vec<u8>, String> {
    let url = format!("https://static.crates.io/crates/{krate}/{krate}-{version}.crate");
    // ureq 3 (2026-10-02): no env proxy and 5 redirects, as ureq 2 did.
    let resp = ureq::Agent::config_builder()
        .timeout_global(Some(std::time::Duration::from_secs(30)))
        .proxy(None)
        .max_redirects(5)
        .build()
        .new_agent()
        .get(&url)
        .header("User-Agent", "nornir-release (+https://git.gunnar.rs/nordisk/edda)")
        .call()
        .map_err(|e| format!("download {url}: {e}"))?;
    let mut buf = Vec::new();
    resp.into_body()
        .into_reader()
        .take(MAX_CRATE_BYTES)
        .read_to_end(&mut buf)
        .map_err(|e| format!("read {url}: {e}"))?;
    if buf.is_empty() {
        return Err(format!("empty tarball at {url}"));
    }
    Ok(buf)
}

/// Gunzip + untar a `.crate` blob into the NORMALIZED published comparison map.
/// Entry paths carry a leading `<krate>-<version>/` prefix which is stripped.
pub fn extract_published_map(crate_bytes: &[u8]) -> Result<BTreeMap<String, Vec<u8>>, String> {
    let gz = flate2::read::GzDecoder::new(crate_bytes);
    let mut ar = tar::Archive::new(gz);
    let mut map: BTreeMap<String, Vec<u8>> = BTreeMap::new();
    for entry in ar.entries().map_err(|e| format!("tar entries: {e}"))? {
        let mut entry = entry.map_err(|e| format!("tar entry: {e}"))?;
        if !entry.header().entry_type().is_file() {
            continue;
        }
        let path = entry.path().map_err(|e| format!("tar path: {e}"))?.to_path_buf();
        // Strip the top-level `<krate>-<version>/` directory component.
        let rel: String = {
            let mut comps = path.components();
            comps.next(); // drop the prefix dir
            comps.as_path().to_string_lossy().replace('\\', "/")
        };
        if rel.is_empty() {
            continue;
        }
        let mut bytes = Vec::new();
        entry.read_to_end(&mut bytes).map_err(|e| format!("read {rel}: {e}"))?;
        // Normalize: the verbatim `Cargo.toml.orig` becomes the canonical
        // `Cargo.toml` comparison key; the cargo-normalized `Cargo.toml` and other
        // synthesized entries are dropped.
        if rel == "Cargo.toml.orig" {
            map.insert("Cargo.toml".to_string(), canonical_manifest(&bytes));
        } else if !is_excluded_entry(&rel) {
            map.insert(rel, bytes);
        }
    }
    Ok(map)
}

/// Where an entry named by `cargo package --list` actually lives on disk when it
/// is NOT at `<crate_dir>/<listed name>`.
///
/// A `readme`/`license-file` pointing OUTSIDE the package (`readme = "../README.md"`,
/// the shape every znippy and skade member uses to share one repo-root README) is
/// COPIED by cargo into the package root under its bare file name. `cargo package
/// --list` therefore prints `README.md`, but `<crate_dir>/README.md` does not exist —
/// and the naive join made [`local_shipped_sources`] fail with
/// `read <crate>/README.md: No such file or directory`, which [`assess`] turns into
/// [`DriftVerdict::Unknown`]. Six of znippy's twelve crates read Unknown that way, so
/// crates byte-identical to their published copy never reached the SKIP set and an
/// `--execute` would have bumped and re-uploaded them for nothing.
///
/// Returns `listed name → real on-disk path`, and ONLY for a manifest key whose
/// declared path resolves to an existing file. An in-package `readme = "docs/R.md"`
/// is listed under its own relative path and already resolves, so it never lands
/// here. A workspace-inherited (`readme.workspace = true`) value is not resolvable
/// from this manifest alone and is deliberately left out — the old Unknown stands,
/// which is a NOTE, never a false block.
fn out_of_package_asset_paths(crate_dir: &Path) -> BTreeMap<String, std::path::PathBuf> {
    let mut map = BTreeMap::new();
    let Ok(text) = std::fs::read_to_string(crate_dir.join("Cargo.toml")) else {
        return map;
    };
    let Ok(doc) = text.parse::<toml::Table>().map(toml::Value::Table) else {
        return map;
    };
    let Some(pkg) = doc.get("package") else {
        return map;
    };
    for key in ["readme", "license-file"] {
        // `readme = false` / `readme.workspace = true` are not string paths.
        let Some(declared) = pkg.get(key).and_then(|v| v.as_str()) else {
            continue;
        };
        // Only a path that LEAVES the package is copied in under its bare name; an
        // in-package `docs/README.md` keeps its relative path in the listing and
        // already resolves, so it must never enter this map.
        let p = Path::new(declared);
        let escapes = p.is_absolute()
            || p.components().next() == Some(std::path::Component::ParentDir);
        if !escapes {
            continue;
        }
        let on_disk = crate_dir.join(declared);
        let Some(listed) = p.file_name() else {
            continue;
        };
        let listed = listed.to_string_lossy().into_owned();
        if !crate_dir.join(&listed).exists() && on_disk.is_file() {
            map.insert(listed, on_disk);
        }
    }
    map
}

/// Collect the LOCAL shippable source into the NORMALIZED comparison map: ask
/// `cargo package --list` for the exact file set cargo would ship, then read each
/// from disk (`Cargo.toml` canonicalised under its own key; the synthesized
/// `Cargo.toml.orig`/`Cargo.lock`/`.cargo_vcs_info.json` skipped — they aren't
/// authored source on disk).
///
/// An out-of-package `readme`/`license-file` is resolved through
/// [`out_of_package_asset_paths`] — cargo ships it under its bare file name, which
/// does not exist at that name inside the crate dir.
pub fn local_shipped_sources(crate_dir: &Path) -> Result<BTreeMap<String, Vec<u8>>, String> {
    let manifest = crate_dir.join("Cargo.toml");
    let cargo = cargo_bin();
    let out = Command::new(&cargo)
        .args(["package", "--list", "--allow-dirty", "--manifest-path"])
        .arg(&manifest)
        .output()
        .map_err(|e| format!("spawn `{} package --list`: {e}", cargo.to_string_lossy()))?;
    if !out.status.success() {
        return Err(format!(
            "`cargo package --list` failed: {}",
            String::from_utf8_lossy(&out.stderr).lines().last().unwrap_or("").trim()
        ));
    }
    let listing = String::from_utf8_lossy(&out.stdout);
    let relocated = out_of_package_asset_paths(crate_dir);
    let mut map: BTreeMap<String, Vec<u8>> = BTreeMap::new();
    for line in listing.lines().map(str::trim).filter(|l| !l.is_empty()) {
        let rel = line.replace('\\', "/");
        if rel == "Cargo.toml" {
            // The raw manifest, compared (canonicalised) against the tarball's
            // `Cargo.toml.orig` — NOT excluded, unlike the tarball's normalized copy.
            let disk = crate_dir.join(&rel);
            let bytes = std::fs::read(&disk).map_err(|e| format!("read {}: {e}", disk.display()))?;
            map.insert("Cargo.toml".to_string(), canonical_manifest(&bytes));
            continue;
        }
        // `Cargo.toml.orig` is synthesized at package time (absent on disk); the
        // rest of the denylist matches the published side.
        if rel == "Cargo.toml.orig" || is_excluded_entry(&rel) {
            continue;
        }
        let disk = match relocated.get(&rel) {
            Some(p) => p.clone(),
            None => crate_dir.join(&rel),
        };
        let bytes = std::fs::read(&disk).map_err(|e| format!("read {}: {e}", disk.display()))?;
        map.insert(rel, bytes);
    }
    if map.is_empty() {
        return Err("`cargo package --list` produced no files".to_string());
    }
    Ok(map)
}

/// Assess ONE crate: fetch the published sources at `version`, collect the local
/// shipped sources from `crate_dir`, and diff. Any IO failure ⇒
/// [`DriftVerdict::Unknown`] (never a false block).
pub fn assess(
    oracle: &dyn PublishedSourceOracle,
    crate_dir: &Path,
    krate: &str,
    version: &str,
) -> DriftVerdict {
    let published = match oracle.published_sources(krate, version) {
        Ok(m) => m,
        Err(e) => return DriftVerdict::Unknown(e),
    };
    let local = match local_shipped_sources(crate_dir) {
        Ok(m) => m,
        Err(e) => return DriftVerdict::Unknown(e),
    };
    diff_source_maps(&local, &published)
}

/// How a crate was chosen for (or dropped from) a smart release selection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SelectReason {
    /// Its own source drifted from the published copy (or it is a first publish /
    /// its drift verdict was inconclusive) — must (re)publish.
    Changed,
    /// Unchanged itself, but a crate it depends on is (re)publishing, so its
    /// resolved dep versions move — the reverse-dep cascade pulls it in.
    CascadedFrom(String),
    /// Forced in by `--all` / `--include-unchanged` (skip-logic disabled).
    Forced,
    /// Named explicitly by `--only`.
    OnlyNamed,
    /// Proven byte-identical to its published version and no changed dependency —
    /// SKIPPED (the smart default).
    SkippedUnchanged,
    /// Dropped by `--exclude`.
    Excluded,
}

/// The outcome of [`select_republish`]: the crates to publish (topo order
/// preserved) and the ones skipped, each with a human reason.
#[derive(Debug, Clone, Default)]
pub struct ReleaseSelection {
    /// Crates to (re)publish, in the input (topo) order.
    pub publish: Vec<String>,
    /// `(crate, reason)` for each crate NOT published.
    pub skipped: Vec<(String, SelectReason)>,
}

impl ReleaseSelection {
    /// True when a crate is in the publish set.
    pub fn will_publish(&self, krate: &str) -> bool {
        self.publish.iter().any(|c| c == krate)
    }
}

/// **Smart release selection** — the pure decision core behind "don't re-release
/// unchanged crates" (design §4). Given the full candidate set in topo order
/// (deps-first), the set of crates PROVEN unchanged (skippable — from
/// [`can_skip_republish`]), and the crate→its-dependencies edges (within the
/// candidate set), decide which crates actually (re)publish:
///
/// * **default (smart):** a crate publishes iff its own source CHANGED (not in
///   `unchanged`) OR any crate it depends on is publishing — the changed set plus
///   its reverse-dependency cascade. Everything else is skipped as unchanged.
/// * `--only` (`only` non-empty): restrict the candidate base to exactly those
///   crates (still minus `exclude`); skip-logic does not apply (an explicit ask).
/// * `--exclude`: drop these crates from the base entirely.
/// * `force_all` (`--all` / `--include-unchanged`): disable the skip — every base
///   crate publishes.
///
/// Deterministic + IO-free (the network drift assessment happens upstream and is
/// distilled into `unchanged`), so it is exhaustively unit-testable. The cascade
/// is a fixpoint, robust to any input ordering.
pub fn select_republish(
    candidates_topo: &[String],
    unchanged: &BTreeSet<String>,
    deps: &BTreeMap<String, BTreeSet<String>>,
    only: &[String],
    exclude: &[String],
    force_all: bool,
) -> ReleaseSelection {
    let exclude_set: BTreeSet<&str> = exclude.iter().map(String::as_str).collect();
    let only_set: BTreeSet<&str> = only.iter().map(String::as_str).collect();

    // Base candidate set (topo order preserved), after --only / --exclude.
    let base: Vec<String> = candidates_topo
        .iter()
        .filter(|c| only_set.is_empty() || only_set.contains(c.as_str()))
        .filter(|c| !exclude_set.contains(c.as_str()))
        .cloned()
        .collect();
    let base_set: BTreeSet<&str> = base.iter().map(String::as_str).collect();

    let mut sel = ReleaseSelection::default();

    // Excluded crates that were candidates → record the reason (visibility).
    for c in candidates_topo {
        if exclude_set.contains(c.as_str()) {
            sel.skipped.push((c.clone(), SelectReason::Excluded));
        }
    }

    if force_all || !only.is_empty() {
        // Skip-logic disabled: every base crate publishes.
        let reason = if !only.is_empty() { SelectReason::OnlyNamed } else { SelectReason::Forced };
        for c in &base {
            sel.publish.push(c.clone());
            let _ = &reason; // reason is per-crate identical; kept for symmetry
        }
        // (No per-crate publish reasons stored — publish order is the contract.)
        return sel;
    }

    // Smart default: republish = changed ∪ (reverse-dep cascade of changed).
    // `cause[c]` = why c is in the republish set (Changed / CascadedFrom).
    let mut cause: BTreeMap<&str, SelectReason> = BTreeMap::new();
    for c in &base {
        if !unchanged.contains(c.as_str()) {
            cause.insert(c.as_str(), SelectReason::Changed);
        }
    }
    // Fixpoint: pull in any base crate that depends on an already-included crate.
    loop {
        let mut added = false;
        for c in &base {
            if cause.contains_key(c.as_str()) {
                continue;
            }
            if let Some(cdeps) = deps.get(c) {
                if let Some(dep) = cdeps
                    .iter()
                    .find(|d| base_set.contains(d.as_str()) && cause.contains_key(d.as_str()))
                {
                    cause.insert(c.as_str(), SelectReason::CascadedFrom(dep.clone()));
                    added = true;
                }
            }
        }
        if !added {
            break;
        }
    }

    for c in &base {
        if cause.contains_key(c.as_str()) {
            sel.publish.push(c.clone());
        } else {
            sel.skipped.push((c.clone(), SelectReason::SkippedUnchanged));
        }
    }
    sel
}

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

    fn m(pairs: &[(&str, &[u8])]) -> BTreeMap<String, Vec<u8>> {
        pairs.iter().map(|(k, v)| (k.to_string(), v.to_vec())).collect()
    }

    fn names(v: &[&str]) -> Vec<String> {
        v.iter().map(|s| s.to_string()).collect()
    }
    fn set(v: &[&str]) -> BTreeSet<String> {
        v.iter().map(|s| s.to_string()).collect()
    }
    fn deps(pairs: &[(&str, &[&str])]) -> BTreeMap<String, BTreeSet<String>> {
        pairs.iter().map(|(k, ds)| (k.to_string(), set(ds))).collect()
    }

    #[test]
    fn smart_default_skips_unchanged_and_cascades_reverse_deps() {
        // Topo (deps-first): a → b → c ; d is standalone.
        let cands = names(&["a", "b", "c", "d"]);
        let dep_edges = deps(&[("b", &["a"]), ("c", &["b"]), ("d", &[])]);

        // Only `a` changed; b/c/d unchanged. b depends on a → cascades; c depends
        // on b → cascades; d is unrelated → stays skipped.
        let unchanged = set(&["b", "c", "d"]);
        let s = select_republish(&cands, &unchanged, &dep_edges, &[], &[], false);
        assert_eq!(s.publish, names(&["a", "b", "c"]), "changed a + its rev-dep cascade");
        assert_eq!(s.skipped.len(), 1);
        assert_eq!(s.skipped[0].0, "d");
        assert_eq!(s.skipped[0].1, SelectReason::SkippedUnchanged);
    }

    #[test]
    fn all_unchanged_publishes_nothing() {
        let cands = names(&["a", "b"]);
        let s = select_republish(&cands, &set(&["a", "b"]), &deps(&[]), &[], &[], false);
        assert!(s.publish.is_empty(), "nothing drifted ⇒ nothing to release");
        assert_eq!(s.skipped.len(), 2);
    }

    #[test]
    fn force_all_ignores_the_skip() {
        let cands = names(&["a", "b"]);
        let s = select_republish(&cands, &set(&["a", "b"]), &deps(&[]), &[], &[], true);
        assert_eq!(s.publish, names(&["a", "b"]), "--all forces every crate");
    }

    #[test]
    fn only_and_exclude_filter_the_base() {
        let cands = names(&["a", "b", "c"]);
        // --only b,c (a dropped even though it changed); skip-logic bypassed.
        let s = select_republish(&cands, &set(&[]), &deps(&[]), &names(&["b", "c"]), &[], false);
        assert_eq!(s.publish, names(&["b", "c"]));
        // --exclude b: b dropped, a+c (changed) publish.
        let s2 = select_republish(&cands, &set(&[]), &deps(&[]), &[], &names(&["b"]), false);
        assert_eq!(s2.publish, names(&["a", "c"]));
        assert!(s2.skipped.iter().any(|(c, r)| c == "b" && *r == SelectReason::Excluded));
    }

    #[test]
    fn only_one_crate_yields_a_single_crate_publish_set() {
        // Regression for the `--only nornir` bug: given a FULL-workspace candidate
        // set (the ~95-crate cascade), `--only nornir` must collapse the publish set
        // to EXACTLY `nornir` — no facett-*, no znippy-zoomies, no draupnir — and the
        // drift-based selection is bypassed entirely (skip-logic does not apply).
        let cands = names(&[
            "funnel-core",
            "draupnir",
            "znippy-zoomies",
            "facett-core",
            "nornir",
            "edda",
        ]);
        // Pretend everything (incl. deps of nornir) drifted — still only nornir ships.
        let s = select_republish(&cands, &set(&[]), &deps(&[]), &names(&["nornir"]), &[], false);
        assert_eq!(s.publish, names(&["nornir"]), "--only nornir ⇒ exactly nornir");
        assert_eq!(s.publish.len(), 1, "a 1-crate publish set");
        assert!(s.will_publish("nornir"));
        assert!(!s.will_publish("facett-core"));
        assert!(!s.will_publish("znippy-zoomies"));
        assert!(!s.will_publish("draupnir"));
    }

    #[test]
    fn cascade_is_order_independent() {
        // Dependents listed BEFORE their deps in the input — the fixpoint still
        // pulls the whole chain in.
        let cands = names(&["c", "b", "a"]);
        let dep_edges = deps(&[("b", &["a"]), ("c", &["b"])]);
        let s = select_republish(&cands, &set(&["b", "c"]), &dep_edges, &[], &[], false);
        // a changed → b → c all publish, in the given order.
        assert_eq!(s.publish, names(&["c", "b", "a"]));
        assert!(s.skipped.is_empty());
    }

    #[test]
    fn identical_maps_are_unchanged() {
        let a = m(&[("src/lib.rs", b"fn x(){}"), ("README.md", b"hi")]);
        let b = m(&[("src/lib.rs", b"fn x(){}"), ("README.md", b"hi")]);
        assert_eq!(diff_source_maps(&a, &b), DriftVerdict::Unchanged);
    }

    #[test]
    fn changed_file_is_drift() {
        let local = m(&[("src/native.rs", b"lzip_parallel::decompress_zip_filter()")]);
        let published = m(&[("src/native.rs", b"lzip_parallel::thread_pool()")]);
        match diff_source_maps(&local, &published) {
            DriftVerdict::Drifted { differing } => {
                assert_eq!(differing, vec!["src/native.rs (changed)".to_string()]);
            }
            other => panic!("expected drift, got {other:?}"),
        }
    }

    #[test]
    fn added_and_removed_files_are_drift() {
        let local = m(&[("src/lib.rs", b"x"), ("src/new.rs", b"y")]);
        let published = m(&[("src/lib.rs", b"x"), ("src/gone.rs", b"z")]);
        match diff_source_maps(&local, &published) {
            DriftVerdict::Drifted { differing } => {
                assert!(differing.contains(&"src/new.rs (added locally)".to_string()));
                assert!(differing.contains(&"src/gone.rs (removed locally)".to_string()));
            }
            other => panic!("expected drift, got {other:?}"),
        }
    }

    #[test]
    fn manifest_reformatting_is_not_drift() {
        // Same dependencies, different whitespace / key order → canonicalised equal.
        let raw_a = b"[package]\nname = \"c\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1\"\n";
        let raw_b = b"[dependencies]\nserde   =   \"1\"\n[package]\nversion=\"0.1.0\"\nname=\"c\"\n";
        let local = m(&[("Cargo.toml", &canonical_manifest(raw_a))]);
        let published = m(&[("Cargo.toml", &canonical_manifest(raw_b))]);
        assert_eq!(diff_source_maps(&local, &published), DriftVerdict::Unchanged);
    }

    #[test]
    fn skip_only_when_published_and_unchanged() {
        // The one skippable case: has a published version AND unchanged.
        assert!(can_skip_republish(Some("1.2.3"), &DriftVerdict::Unchanged));
        // First publish (no published max) → never skip, even if "unchanged".
        assert!(!can_skip_republish(None, &DriftVerdict::Unchanged));
        // Drift → never skip (bump required).
        assert!(!can_skip_republish(
            Some("1.2.3"),
            &DriftVerdict::Drifted { differing: vec!["src/lib.rs (changed)".into()] }
        ));
        // Inconclusive → never skip (bump, to be safe).
        assert!(!can_skip_republish(Some("1.2.3"), &DriftVerdict::Unknown("network".into())));
    }

    #[test]
    fn manifest_dependency_change_is_drift() {
        let raw_a = b"[package]\nname=\"c\"\nversion=\"0.1.0\"\n[dependencies]\nserde=\"1\"\n";
        let raw_b = b"[package]\nname=\"c\"\nversion=\"0.1.0\"\n[dependencies]\nserde=\"2\"\n";
        let local = m(&[("Cargo.toml", &canonical_manifest(raw_a))]);
        let published = m(&[("Cargo.toml", &canonical_manifest(raw_b))]);
        assert!(matches!(diff_source_maps(&local, &published), DriftVerdict::Drifted { .. }));
    }

    /// Real-world E2E (network + a local checkout): assess a crate whose local
    /// tree is known-consistent with its published version. Opt-in — set
    /// `NORNIR_DRIFT_SMOKE_DIR` / `_KRATE` / `_VER` to a matching checkout; skips
    /// cleanly if unset so CI never depends on network or a specific path.
    #[test]
    #[ignore = "network + local checkout; run explicitly with the env vars set"]
    fn e2e_assess_against_published_crate() {
        let (Ok(dir), Ok(krate), Ok(ver)) = (
            std::env::var("NORNIR_DRIFT_SMOKE_DIR"),
            std::env::var("NORNIR_DRIFT_SMOKE_KRATE"),
            std::env::var("NORNIR_DRIFT_SMOKE_VER"),
        ) else {
            eprintln!("skipped: set NORNIR_DRIFT_SMOKE_{{DIR,KRATE,VER}}");
            return;
        };
        let v = assess(&CratesIoSources, Path::new(&dir), &krate, &ver);
        assert_eq!(v, DriftVerdict::Unchanged, "expected consistent tree to be Unchanged, got {v:?}");
    }

    #[test]
    fn extract_strips_prefix_and_drops_synthesized() {
        // Build a tiny .crate (gz+tar) in-memory and round-trip it.
        let mut tar_buf = Vec::new();
        {
            let mut builder = tar::Builder::new(&mut tar_buf);
            let mut add = |name: &str, data: &[u8]| {
                let mut h = tar::Header::new_gnu();
                h.set_size(data.len() as u64);
                h.set_mode(0o644);
                h.set_cksum();
                builder.append_data(&mut h, name, data).unwrap();
            };
            add("foo-1.0.0/src/lib.rs", b"pub fn f() {}");
            add("foo-1.0.0/Cargo.toml", b"normalized junk = true"); // dropped
            add("foo-1.0.0/Cargo.toml.orig", b"[package]\nname=\"foo\"\nversion=\"1.0.0\"\n");
            add("foo-1.0.0/.cargo_vcs_info.json", b"{\"git\":{\"sha1\":\"abc\"}}"); // dropped
            builder.finish().unwrap();
        }
        let mut gz = Vec::new();
        {
            use std::io::Write;
            let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default());
            enc.write_all(&tar_buf).unwrap();
            enc.finish().unwrap();
        }
        let map = extract_published_map(&gz).unwrap();
        assert!(map.contains_key("src/lib.rs"));
        assert!(map.contains_key("Cargo.toml")); // from .orig
        assert!(!map.contains_key("Cargo.toml.orig"));
        assert!(!map.contains_key(".cargo_vcs_info.json"));
        assert_eq!(map.get("src/lib.rs").unwrap(), b"pub fn f() {}");
    }

    /// Lay down a one-crate workspace whose member declares `readme` at `readme_rel`.
    /// The repo-root `README.md` carries `root_readme`; the crate dir gets whatever
    /// `extra` says. Returns (tempdir, crate_dir).
    fn scratch_pkg(
        readme_rel: &str,
        root_readme: &[u8],
        extra: &[(&str, &[u8])],
    ) -> (tempfile::TempDir, std::path::PathBuf) {
        let td = tempfile::tempdir().unwrap();
        let root = td.path();
        std::fs::write(root.join("README.md"), root_readme).unwrap();
        std::fs::write(root.join("Cargo.toml"), b"[workspace]\nmembers = [\"pkg\"]\nresolver = \"2\"\n")
            .unwrap();
        let dir = root.join("pkg");
        std::fs::create_dir_all(dir.join("src")).unwrap();
        std::fs::write(dir.join("src/lib.rs"), b"pub fn f() {}").unwrap();
        std::fs::write(
            dir.join("Cargo.toml"),
            format!(
                "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\
                 description = \"d\"\nlicense = \"MIT\"\nreadme = \"{readme_rel}\"\n\n[dependencies]\n"
            )
            .as_bytes(),
        )
        .unwrap();
        for (rel, bytes) in extra {
            let p = dir.join(rel);
            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
            std::fs::write(p, bytes).unwrap();
        }
        (td, dir)
    }

    /// RED before the fix: `readme = "../README.md"` makes `cargo package --list`
    /// print `README.md`, which does not exist at `<crate_dir>/README.md`, and the
    /// naive join returned `Err(read …/pkg/README.md: No such file or directory)` —
    /// the exact string that put six znippy crates in `Unknown` and out of the SKIP
    /// set. Asserts the APPLIED bytes, not merely that the call succeeded: a
    /// resolution that found some other README would still be wrong.
    #[test]
    fn out_of_package_readme_is_read_from_where_it_actually_lives() {
        let (_td, dir) = scratch_pkg("../README.md", b"# repo root readme\n", &[]);
        assert!(!dir.join("README.md").exists(), "precondition: not in the crate dir");

        let map = local_shipped_sources(&dir).expect("out-of-package readme must resolve");
        assert_eq!(
            map.get("README.md").map(|v| v.as_slice()),
            Some(b"# repo root readme\n".as_slice()),
            "the SHIPPED bytes are the repo-root README cargo copies in"
        );
        assert!(map.contains_key("src/lib.rs"));
    }

    /// The override must not hijack an in-package readme. Here `docs/README.md`
    /// exists inside the crate and is listed under that relative path; a bare
    /// `README.md` key must NOT appear, and the file-name collision with the
    /// repo-root README must not be resolved to the root's bytes.
    #[test]
    fn in_package_readme_is_untouched_by_the_relocation_map() {
        let (_td, dir) =
            scratch_pkg("docs/README.md", b"# WRONG root\n", &[("docs/README.md", b"# in-crate\n")]);

        assert!(
            out_of_package_asset_paths(&dir).is_empty(),
            "an in-package readme is not a relocation"
        );
        let map = local_shipped_sources(&dir).unwrap();
        assert_eq!(
            map.get("docs/README.md").map(|v| v.as_slice()),
            Some(b"# in-crate\n".as_slice())
        );
        assert!(!map.contains_key("README.md"), "no bare README.md key invented");
    }
}

/// Resolve the cargo binary — lifted verbatim from nornir's `release::cargo` so
/// this crate stays a self-contained leaf. `$CARGO` → `~/.cargo/bin/cargo` →
/// `rustup which cargo` → `"cargo"`.
fn cargo_bin() -> std::ffi::OsString {
    if let Some(c) = std::env::var_os("CARGO").filter(|c| !c.is_empty()) {
        return c;
    }
    if let Some(home) = std::env::var_os("HOME") {
        let p = Path::new(&home).join(".cargo/bin/cargo");
        if p.exists() {
            return p.into_os_string();
        }
    }
    if let Ok(out) = Command::new("rustup").args(["which", "cargo"]).output() {
        if out.status.success() {
            let p = String::from_utf8_lossy(&out.stdout).trim().to_string();
            if !p.is_empty() && Path::new(&p).exists() {
                return std::ffi::OsString::from(p);
            }
        }
    }
    std::ffi::OsString::from("cargo")
}