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

nordisk/nornir

crates/nornir-introspect/src/depgraph.rs

//! 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`.
///
/// `clone_missing` is the **network half** of the prep, injected by the caller so
/// this crate stays free of any git dependency: given `(repo_root, scan_root)` it
/// clones any missing sibling repo into `scan_root` and returns how many it
/// fetched. nornir passes its `security::clone_missing_path_dep_siblings`; a
/// caller with no network (or a unit test) passes `&|_, _| 0`. Cloning happens
/// first, then the pure symlink materialization, mirroring the original
/// `security::prepare_path_deps`.
pub fn extract_with_path_deps(
    repo_root: &Path,
    scan_root: &Path,
    clone_missing: &dyn Fn(&Path, &Path) -> usize,
) -> Result<Graph> {
    let cloned = clone_missing(repo_root, scan_root);
    let linked = crate::pathdeps::materialize_path_dep_siblings(repo_root, scan_root)
        .unwrap_or_else(|e| {
            eprintln!(
                "nornir-docs: link path-dep siblings for {} skipped: {e:#}",
                repo_root.display()
            );
            0
        });
    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::pathdeps::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, &|_, _| 0).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, &|_, _| 0)
            .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}"
        );
    }
}