crates/nornir-feature-graph/src/parse.rs
//! `Cargo.toml` text → [`PackageFacts`], plus the manifest WALK.
//!
//! The walk lives here rather than in `nornir-release-core` because this is the
//! leaf: `nornir-release-core` pulls gix, ureq, tar and cargo_metadata, and the
//! walk is thirty lines of `read_dir` that both need. It moved down; the release
//! gate calls [`walk_cargo_tomls`] from here (LAW 5 — one writer, not two copies
//! a guard has to watch agree).
use crate::facts::{DepKind, DepSpec, PackageFacts};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Anything that went wrong reading a manifest.
#[derive(Debug)]
pub enum ParseError {
Io { path: PathBuf, source: std::io::Error },
Toml { path: PathBuf, source: toml::de::Error },
/// A manifest with no `[package]` — a virtual workspace root. Not an error
/// to the caller, but it yields no facts, and saying so beats returning an
/// empty `PackageFacts` that reads like a package with no dependencies.
NotAPackage { path: PathBuf },
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::Io { path, source } => write!(f, "read {}: {source}", path.display()),
ParseError::Toml { path, source } => write!(f, "parse {}: {source}", path.display()),
ParseError::NotAPackage { path } => {
write!(f, "{} declares no [package] (virtual workspace root)", path.display())
}
}
}
}
impl std::error::Error for ParseError {}
/// **Every `Cargo.toml` under `root`**, depth-first, symlinks never followed.
///
/// Skips `target`, `node_modules`, and ALL dot-dir CHILDREN. That last rule is
/// not cosmetic: a repo's `.claude/worktrees/` symlinks out to sibling repos, so
/// descending would sweep the whole constellation — facett went 26 → 95
/// manifests before the guard existed. The check is on the CHILD, not the popped
/// directory, so a root that is itself dot-named is still scanned.
pub fn walk_cargo_tomls(root: &Path) -> Result<Vec<PathBuf>, ParseError> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(rd) => rd,
Err(source) => return Err(ParseError::Io { path: dir, source }),
};
for entry in rd {
let entry = match entry {
Ok(e) => e,
Err(source) => return Err(ParseError::Io { path: dir.clone(), source }),
};
let p = entry.path();
let ft = match entry.file_type() {
Ok(ft) => ft,
Err(source) => return Err(ParseError::Io { path: p, source }),
};
if ft.is_symlink() {
continue;
}
if ft.is_dir() {
let cname = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if matches!(cname, "target" | "node_modules") || cname.starts_with('.') {
continue;
}
stack.push(p);
} else if ft.is_file() && p.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") {
out.push(p);
}
}
}
out.sort();
Ok(out)
}
/// Parse one manifest from TEXT. `repo` is stamped onto every row.
pub fn parse_manifest_str(
repo: &str,
path: &Path,
text: &str,
) -> Result<PackageFacts, ParseError> {
let doc: toml::Value = toml::from_str(text)
.map_err(|source| ParseError::Toml { path: path.to_path_buf(), source })?;
let pkg = doc
.get("package")
.and_then(|v| v.as_table())
.ok_or_else(|| ParseError::NotAPackage { path: path.to_path_buf() })?;
// `name` may be `{ workspace = true }` in theory; in practice it never is
// (cargo forbids inheriting the name). A missing name is a broken manifest,
// and the file stem is a worse lie than an explicit marker.
let name = pkg
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("<unnamed>")
.to_string();
let version = pkg
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("<inherited>")
.to_string();
let mut features: BTreeMap<String, Vec<String>> = BTreeMap::new();
if let Some(t) = doc.get("features").and_then(|v| v.as_table()) {
for (k, v) in t {
let list = v
.as_array()
.map(|a| a.iter().filter_map(|e| e.as_str().map(str::to_string)).collect())
.unwrap_or_default();
features.insert(k.clone(), list);
}
}
let mut deps: BTreeMap<(String, DepKind), DepSpec> = BTreeMap::new();
collect_deps(&doc, None, &mut deps);
// `[target.'cfg(...)'.dependencies]` — kept, with the predicate recorded.
// Dropping them would under-report; silently merging them would claim a
// crate is present on every platform. The cfg travels on the row so the
// consumer decides.
if let Some(targets) = doc.get("target").and_then(|v| v.as_table()) {
for (cfg, sub) in targets {
collect_deps(sub, Some(cfg.as_str()), &mut deps);
}
}
Ok(PackageFacts { id: name.clone(), name, version, repo: repo.to_string(), features, deps })
}
/// Read and parse one manifest from disk.
pub fn parse_manifest(repo: &str, path: &Path) -> Result<PackageFacts, ParseError> {
let text = std::fs::read_to_string(path)
.map_err(|source| ParseError::Io { path: path.to_path_buf(), source })?;
parse_manifest_str(repo, path, &text)
}
fn collect_deps(
doc: &toml::Value,
target_cfg: Option<&str>,
out: &mut BTreeMap<(String, DepKind), DepSpec>,
) {
for (section, kind) in [
("dependencies", DepKind::Normal),
("build-dependencies", DepKind::Build),
("dev-dependencies", DepKind::Dev),
] {
let Some(t) = doc.get(section).and_then(|v| v.as_table()) else { continue };
for (key, spec) in t {
let (package, optional, default_features, features) = match spec {
// `foo = "1"` — the short form: never optional, defaults on.
toml::Value::String(_) => (key.clone(), false, true, Vec::new()),
toml::Value::Table(tbl) => {
let package =
tbl.get("package").and_then(|v| v.as_str()).unwrap_or(key).to_string();
let optional = tbl.get("optional").and_then(|v| v.as_bool()).unwrap_or(false);
let default_features = tbl
.get("default-features")
.or_else(|| tbl.get("default_features"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
let features = tbl
.get("features")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|e| e.as_str().map(str::to_string)).collect())
.unwrap_or_default();
(package, optional, default_features, features)
}
_ => continue,
};
// **MERGE, do not overwrite.** The same dependency is routinely
// declared under SEVERAL `[target.'cfg(...)']` sections with
// DIFFERENT feature lists, and `--target all` unions them. Keying on
// `(key, kind)` and inserting made the LAST section win, silently
// dropping the others.
//
// Measured 2026-08-01: `arboard 3.6.1` declares `image` three times
// (unix -> png, macos -> tiff, windows -> png+bmp); only windows
// survived, losing tiff, fax, weezl, quick-error, zune-core,
// zune-jpeg. `arrow-array 58.3.0` declares `ahash` twice
// (not(wasm32) -> runtime-rng, wasm32 -> compile-time-rng); only
// wasm32 survived, losing getrandom 0.3.4 -> wasip2 -> wit-bindgen.
// Fixing this took a 20-case cross-repo sweep from 10/20 exact
// (recall 0.9951) to 20/20 exact (recall 1.0000).
//
// `optional` is ANDed: a dep that is non-optional on ANY platform is
// reachable without a feature. `target_cfg` keeps the FIRST, which is
// now only a label — the edge is the union of every section.
match out.get_mut(&(key.clone(), kind)) {
Some(prev) => {
for f in features {
if !prev.features.contains(&f) {
prev.features.push(f);
}
}
prev.default_features |= default_features;
prev.optional &= optional;
}
None => {
out.insert(
(key.clone(), kind),
DepSpec {
key: key.clone(),
package,
optional,
default_features,
features,
kind,
target_cfg: target_cfg.map(str::to_string),
},
);
}
}
}
}
}
/// **Every package under a set of repo roots**, keyed by package name.
///
/// Later roots do not overwrite earlier ones: the first manifest to claim a name
/// wins, and a duplicate is reported rather than silently shadowed — two
/// packages with one name is a real condition and hiding it would make the
/// closure wrong in a way nothing downstream could see.
pub fn scan_repos<'a, I>(roots: I) -> ScanOutcome
where
I: IntoIterator<Item = (&'a str, &'a Path)>,
{
let mut packages: BTreeMap<String, PackageFacts> = BTreeMap::new();
let mut same_name: BTreeMap<String, Vec<PackageFacts>> = BTreeMap::new();
let mut duplicates: Vec<String> = Vec::new();
let mut problems: Vec<String> = Vec::new();
for (repo, root) in roots {
let manifests = match walk_cargo_tomls(root) {
Ok(m) => m,
Err(e) => {
problems.push(e.to_string());
continue;
}
};
for m in manifests {
match parse_manifest(repo, &m) {
Ok(facts) => {
if let Some(prev) = packages.get(&facts.name) {
if prev.repo != facts.repo {
duplicates.push(format!(
"{} declared in both {} and {}",
facts.name, prev.repo, facts.repo
));
// Kept, not dropped: a lock can hold both as path
// packages (nornir's recipe crate and edda's evaluator
// are both `nornir-build-thing`), and the universe picks
// the one whose version the lock names.
same_name.entry(facts.name.clone()).or_default().push(facts);
}
continue;
}
packages.insert(facts.name.clone(), facts);
}
// A virtual workspace root is normal and expected, not a problem.
Err(ParseError::NotAPackage { .. }) => {}
Err(e) => problems.push(e.to_string()),
}
}
}
ScanOutcome { packages, same_name, duplicates, problems }
}
/// What [`scan_repos`] found, including what it could not read.
#[derive(Debug, Default)]
pub struct ScanOutcome {
pub packages: BTreeMap<String, PackageFacts>,
/// The other first-party packages that carry a name already in [`Self::packages`]
/// (declared in another repository), by name. [`Self::first_party`] picks among them.
pub same_name: BTreeMap<String, Vec<PackageFacts>>,
pub duplicates: Vec<String>,
pub problems: Vec<String>,
}
impl ScanOutcome {
/// The first-party package `name` at `version` when two repositories declare the
/// name, else the one package of that name (whatever its version, as before).
pub fn first_party(&self, name: &str, version: &str) -> Option<&PackageFacts> {
let first = self.packages.get(name)?;
if first.version == version {
return Some(first);
}
self.same_name
.get(name)
.and_then(|others| others.iter().find(|f| f.version == version))
.or(Some(first))
}
}