crates/nornir-introspect/src/pathdeps.rs
//! Sibling **path-dep** resolution for a headless `cargo metadata` — the pure
//! (filesystem + `toml`, no network) half of nornir's security path-dep prep,
//! copied here so `depgraph::extract_with_path_deps` is self-contained.
//!
//! The network half (cloning a missing sibling repo) is NOT here — it needs git
//! and is injected by the caller as a closure, so this crate stays free of any
//! git / warehouse dependency. nornir's `security::clone_missing_path_dep_siblings`
//! is the real clone hook it passes in.
use std::path::Path;
use anyhow::{Context, Result};
/// Materialize sibling **path-dep** checkouts next to `repo` under `scan_root`,
/// so a headless `cargo metadata` (which needs the path-dep sources on disk to
/// resolve) succeeds for repos that depend on sibling crates by relative path
/// (e.g. `znippy → ../zoomies`, `nornir → ../skade`).
///
/// For every `dep = { path = "..." }` in the repo's `Cargo.toml`, resolve the
/// target's crate/repo directory name; if it isn't already reachable at the path
/// the manifest expects but *is* present under `scan_root`, drop a symlink so the
/// relative path resolves. Pure-filesystem, no cargo, no network. Best-effort and
/// idempotent. Returns the number of links created.
pub fn materialize_path_dep_siblings(repo: &Path, scan_root: &Path) -> Result<usize> {
let manifest = repo.join("Cargo.toml");
let text = match std::fs::read_to_string(&manifest) {
Ok(t) => t,
Err(_) => return Ok(0), // no manifest → nothing to do
};
let doc: toml::Value = toml::from_str(&text).context("parse Cargo.toml")?;
let mut linked = 0usize;
for table_key in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(deps) = doc.get(table_key).and_then(|d| d.as_table()) else {
continue;
};
for (dep_name, spec) in deps {
let Some(path) = spec
.as_table()
.and_then(|t| t.get("path"))
.and_then(|p| p.as_str())
else {
continue;
};
// The location the manifest's relative `path` points at.
let target = repo.join(path);
if target.join("Cargo.toml").exists() {
continue; // already resolvable in place
}
// The directory name the dep expects (last segment of its path).
let leaf = Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| dep_name.clone());
// Look for a sibling checkout under scan_root by that leaf name, then
// by the dep's own name (repo dir often == crate name).
let candidate = [leaf.as_str(), dep_name.as_str()]
.into_iter()
.map(|n| scan_root.join(n))
.find(|c| c.join("Cargo.toml").exists());
let Some(src) = candidate else { continue };
if let Some(parent) = target.parent() {
let _ = std::fs::create_dir_all(parent);
}
if symlink_dir(&src, &target).is_ok() {
linked += 1;
}
}
}
Ok(linked)
}
/// List the **path-dep siblings of `repo` that are still unresolved** — i.e. the
/// manifest declares `dep = { path = "..." }` but neither the path itself nor a
/// `scan_root` sibling carries a `Cargo.toml`, so a headless `cargo metadata`
/// would fail to load them. Call this *after* materializing to turn the opaque
/// `MetadataCommand::exec` failure into a clear, named diagnostic (DR2).
///
/// Returns `(dep_name, declared_path)` pairs. Empty ⇒ every path-dep resolves.
/// Pure filesystem, never errors on a missing/garbled manifest.
pub fn unresolved_path_dep_siblings(repo: &Path, scan_root: &Path) -> Vec<(String, String)> {
let Ok(text) = std::fs::read_to_string(repo.join("Cargo.toml")) else {
return Vec::new();
};
let Ok(doc) = toml::from_str::<toml::Value>(&text) else {
return Vec::new();
};
let mut unresolved = Vec::new();
for table_key in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(deps) = doc.get(table_key).and_then(|d| d.as_table()) else {
continue;
};
for (dep_name, spec) in deps {
let Some(path) = spec
.as_table()
.and_then(|t| t.get("path"))
.and_then(|p| p.as_str())
else {
continue;
};
// Resolvable in place (path or, post-prep, via the symlink we dropped)?
if repo.join(path).join("Cargo.toml").exists() {
continue;
}
// Still resolvable from a scan_root sibling we could have linked?
let leaf = Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| dep_name.clone());
let present = [leaf.as_str(), dep_name.as_str()]
.into_iter()
.any(|n| scan_root.join(n).join("Cargo.toml").exists());
if !present {
unresolved.push((dep_name.clone(), path.to_string()));
}
}
}
unresolved
}
/// Create a directory symlink `link → src`. No-op if `link` already exists.
#[cfg(unix)]
fn symlink_dir(src: &Path, link: &Path) -> std::io::Result<()> {
if link.exists() {
return Ok(());
}
std::os::unix::fs::symlink(src, link)
}
#[cfg(not(unix))]
fn symlink_dir(src: &Path, link: &Path) -> std::io::Result<()> {
if link.exists() {
return Ok(());
}
std::os::windows::fs::symlink_dir(src, link)
}