nornir 0.4.30

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! Workspace + external deps from `cargo metadata`, via the canonical
//! `cargo_metadata` crate (pure-Rust API; no manual subprocess).

use std::path::Path;

use anyhow::{Context, Result};
use cargo_metadata::MetadataCommand;

use super::{Edge, EdgeKind, Graph};

pub fn extract(repo_root: &Path) -> Result<Graph> {
    let meta = MetadataCommand::new()
        .current_dir(repo_root)
        .no_deps()
        .exec()
        .context("cargo_metadata::MetadataCommand::exec")?;
    let mut g = Graph::default();
    for p in &meta.packages {
        g.nodes.push(p.name.to_string());
        for d in &p.dependencies {
            g.edges.push(Edge {
                from: p.name.to_string(),
                to: d.name.clone(),
                kind: EdgeKind::DependsOn,
            });
        }
    }
    Ok(g)
}

/// Like [`extract`], but **materialize sibling path-deps first** (DR1): repos
/// like `nornir → skade` declare `dep = { path = "../skade" }`, and a headless
/// `cargo metadata` over a single-repo server checkout can't read those sibling
/// manifests unless they're present beside `repo_root`. We clone (feature
/// `net-scan`) + symlink the siblings from `scan_root` — the same prep the live
/// `Mimir.SecurityScan` SBOM path already does — so docs-gen resolves exactly
/// the deps the security scan does.
///
/// `scan_root` is the `git/` dir holding every monitored member checkout. When a
/// sibling is *still* unresolved after prep (e.g. `net-scan` off, no checkout on
/// disk), we surface a clear, named error (DR2) instead of the opaque
/// `MetadataCommand::exec` / `Internal`.
pub fn extract_with_path_deps(repo_root: &Path, scan_root: &Path) -> Result<Graph> {
    let (cloned, linked) =
        crate::security::prepare_path_deps(repo_root, scan_root, &|_| None);
    if cloned + linked > 0 {
        eprintln!(
            "nornir-docs: prepared path-deps for {} ({cloned} cloned, {linked} linked)",
            repo_root.display()
        );
    }
    extract(repo_root).map_err(|e| {
        // Turn the opaque cargo-metadata failure into a clear DR2 diagnostic that
        // names the unresolved sibling manifest(s) and how to fix it.
        let unresolved = crate::security::unresolved_path_dep_siblings(repo_root, scan_root);
        if unresolved.is_empty() {
            return e;
        }
        let names: Vec<String> = unresolved
            .iter()
            .map(|(dep, path)| format!("{dep} ({path})"))
            .collect();
        anyhow::anyhow!(
            "docs render for {repo}: unresolved path-dep sibling(s) {sibs} — \
             their Cargo.toml is not on disk beside the repo. Check the sibling \
             repo(s) out under {root}, or build nornir-server with the `net-scan` \
             feature so it clones them automatically (set NORNIR_DEP_CLONE_BASE \
             for a non-default git host).",
            repo = repo_root.display(),
            sibs = names.join(", "),
            root = scan_root.display(),
        )
    })
}

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

    /// DR1: docs-gen `cargo metadata` for a repo with a sibling **path-dep**
    /// (e.g. `nornir → ../skade`) must resolve when the sibling is checked out
    /// flat under the `git/` scan root — `extract_with_path_deps` symlinks it in
    /// first, then `cargo metadata` succeeds and the graph names both crates.
    #[test]
    fn extract_with_path_deps_resolves_sibling_under_scan_root() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path(); // the `git/` scan root

        // sibling crate `skade` checked out flat under scan_root.
        let skade = root.join("skade");
        std::fs::create_dir_all(&skade).unwrap();
        std::fs::write(
            skade.join("Cargo.toml"),
            "[package]\nname = \"skade\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
        )
        .unwrap();
        std::fs::create_dir_all(skade.join("src")).unwrap();
        std::fs::write(skade.join("src/lib.rs"), "").unwrap();

        // the depending repo `nornir`, declaring `skade = { path = "../skade" }`.
        // `../skade` from nornir resolves to scan_root/skade here — so to force the
        // symlink path, point one level deeper where it is NOT present in place.
        let nornir = root.join("nornir");
        std::fs::create_dir_all(nornir.join("src")).unwrap();
        std::fs::write(
            nornir.join("Cargo.toml"),
            "[package]\nname = \"nornir\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
             [dependencies]\nskade = { path = \"vendor/skade\" }\n",
        )
        .unwrap();
        std::fs::write(nornir.join("src/lib.rs"), "").unwrap();

        // Before: the path-dep does NOT resolve in place.
        assert!(!nornir.join("vendor/skade/Cargo.toml").exists());

        let g = extract_with_path_deps(&nornir, root).expect("metadata resolves after prep");
        // After: the sibling link exists and the graph names both crates (real
        // injected input → real asserted output, not just "didn't panic").
        assert!(nornir.join("vendor/skade/Cargo.toml").exists(), "sibling linked in");
        assert!(g.nodes.iter().any(|n| n == "nornir"), "graph names the repo: {:?}", g.nodes);
        assert!(
            g.edges.iter().any(|e| e.from == "nornir" && e.to == "skade"),
            "graph has the nornir→skade path-dep edge: {:?}",
            g.edges
        );
    }

    /// DR2: when a sibling path-dep is **still unresolved** (no checkout under
    /// scan_root, and `net-scan` is OFF so nothing can be fetched), the error
    /// must clearly NAME the unresolved manifest — never the opaque
    /// `cargo_metadata::MetadataCommand::exec`. Gated to the airgap build: with
    /// `net-scan` on, a real sibling repo would simply be cloned and resolve.
    #[cfg(not(feature = "net-scan"))]
    #[test]
    fn extract_with_path_deps_surfaces_clear_error_for_unresolved_sibling() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();

        let nornir = root.join("nornir");
        std::fs::create_dir_all(nornir.join("src")).unwrap();
        // A `[workspace]` repo: cargo metadata must load member manifests' deps
        // (even with `no_deps()`), so a missing path-dep manifest hard-fails —
        // exactly the real nornir → skade case on the live server.
        std::fs::write(
            nornir.join("Cargo.toml"),
            "[workspace]\nmembers = [\".\"]\n\n\
             [package]\nname = \"nornir\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
             [dependencies]\nskade = { version = \"0.4.9\", path = \"../skade/skade\" }\n",
        )
        .unwrap();
        std::fs::write(nornir.join("src/lib.rs"), "").unwrap();
        // No `skade` anywhere — unresolvable. (With `net-scan` on, the default
        // clone base is unreachable for a bogus name, so it stays unresolved too.)

        let err = extract_with_path_deps(&nornir, root)
            .expect_err("must fail when the sibling is unresolvable");
        let msg = format!("{err:#}");
        assert!(msg.contains("unresolved path-dep sibling"), "DR2 wording: {msg}");
        assert!(msg.contains("skade"), "DR2 must name the unresolved dep: {msg}");
        assert!(
            !msg.contains("MetadataCommand::exec"),
            "DR2 must NOT surface the opaque cargo-metadata error: {msg}"
        );
    }
}