arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! Discover the direct Rust crate dependencies from `Cargo.lock` for the
//! SBOM (AP2.1-10).
//!
//! Parses the workspace `Cargo.lock` (a TOML file) for package `name` +
//! `version` pairs. This is the honest, modest scope: the SBOM enumerates
//! the components the bundle depends on. We parse `Cargo.lock` directly
//! (via the already-admitted [`toml`] crate) rather than shelling out to
//! `cargo metadata` — no subprocess, no JSON, no extra dependency, and
//! `Cargo.lock` is the lockfile the bundle was actually built from.
//!
//! Only the application's direct dependencies are enumerated as
//! `DEPENDS_ON` in the SBOM (the transitive closure is in `Cargo.lock` and
//! can be re-derived; the wave-1 SBOM is a component list, not a full
//! graph). The full transitive set IS in `Cargo.lock`, and `cargo deny` /
//! `cargo audit` are the governance tools that audit it; the SBOM points
//! at the components, the lockfile is the source of truth.

use std::path::Path;

use serde::Deserialize;

use super::error::PackageError;

/// The `Cargo.lock` top-level structure — only the `[[package]]` array is
/// needed for the SBOM.
#[derive(Debug, Deserialize)]
struct CargoLock {
    package: Vec<LockPackage>,
}

#[derive(Debug, Deserialize)]
struct LockPackage {
    name: String,
    version: String,
}

/// Read `Cargo.lock` and return the `(name, version)` pairs of every
/// package. Sorted by name for deterministic SBOM output (the SBOM builder
/// re-sorts, but sorting here keeps the input stable too). Returns an
/// empty list if the lockfile is missing (the SBOM still declares the
/// application + Arcature; the dependency list is best-effort).
pub(crate) fn read_dependencies(lockfile: &Path) -> Result<Vec<(String, String)>, PackageError> {
    let text = std::fs::read_to_string(lockfile).map_err(|source| PackageError::Read {
        path: lockfile.to_path_buf(),
        source,
    })?;
    let parsed: CargoLock = toml::from_str(&text).map_err(|source| PackageError::Toml {
        what: "Cargo.lock",
        source,
    })?;
    let mut deps: Vec<(String, String)> = parsed
        .package
        .into_iter()
        .map(|p| (p.name, p.version))
        .collect();
    deps.sort_by(|a, b| a.0.cmp(&b.0));
    Ok(deps)
}

#[cfg(test)]
mod tests {
    use super::read_dependencies;
    use std::fs;
    use std::path::Path;

    #[test]
    fn reads_packages_from_cargo_lock() {
        let dir = tempfile::tempdir().expect("tempdir");
        let lockfile = dir.path().join("Cargo.lock");
        fs::write(
            &lockfile,
            r#"# This file is automatically @generated by Cargo.
version = 3

[[package]]
name = "demo"
version = "0.1.0"

[[package]]
name = "arcature"
version = "2026.1.0"

[[package]]
name = "serde"
version = "1.0.229"
"#,
        )
        .expect("write lockfile");
        let deps = read_dependencies(&lockfile).expect("read");
        assert_eq!(deps.len(), 3);
        // Sorted by name.
        assert_eq!(deps[0].0, "arcature");
        assert_eq!(deps[1].0, "demo");
        assert_eq!(deps[2].0, "serde");
        assert_eq!(deps[2].1, "1.0.229");
        let _ = Path::new(&lockfile);
    }

    #[test]
    fn missing_lockfile_is_typed_error() {
        let dir = tempfile::tempdir().expect("tempdir");
        let lockfile = dir.path().join("Cargo.lock");
        let result = read_dependencies(&lockfile);
        assert!(matches!(result, Err(super::PackageError::Read { .. })));
    }

    #[test]
    fn malformed_lockfile_is_typed_error() {
        let dir = tempfile::tempdir().expect("tempdir");
        let lockfile = dir.path().join("Cargo.lock");
        fs::write(&lockfile, "this is not toml {{[").expect("write");
        let result = read_dependencies(&lockfile);
        assert!(matches!(result, Err(super::PackageError::Toml { .. })));
    }
}