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}"
);
}
}