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

nordisk/edda

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