arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! `cargo metadata` ingestion — the authoritative workspace/package graph
//! input (ADR-0005 non-negotiable §2). One command, one parse: `cargo
//! metadata` is invoked once per release command and the JSON is parsed into
//! the minimal typed surface the rest of the engine needs. No new
//! dependency: this shells out through [`crate::process`] (typed
//! [`ProcessSpec`], never string-concatenated shell) and parses with the
//! already-vetted `serde_json`.
//!
//! The [`discovered`] function is a pure projection from the parsed
//! [`CargoMetadata`] document onto [`DiscoveredCrate`] — it has no side
//! effects and is unit-testable from a fixture JSON document. The network
//! and process boundary lives only in [`load_metadata`]; pure graph/unit
//! tests do not need it (ADR-0005 invariant 15).

use std::path::PathBuf;

use serde::Deserialize;

use super::error::ReleaseError;
use super::metadata::{CrateMetadata, ReleaseMetadata};
use crate::process::{ProcessSpec, run_capture};

/// The minimal slice of `cargo metadata` output the release engine reads.
///
/// Field shapes follow the official `cargo metadata` JSON schema
/// (`https://doc.rust-lang.org/cargo/commands/cargo-metadata.html`):
/// `publish` is `null` (unrestricted), `[]` (forbidden), or a list of
/// registry names; `metadata` is `null` or an object mirroring
/// `[package.metadata.*]`; `targets[].kind` lists kinds like `"lib"`,
/// `"proc-macro"`, `"bin"`. Only the fields the engine uses are modeled.
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct CargoMetadata {
    pub packages: Vec<Package>,
}

#[derive(Debug, Clone, Deserialize)]
pub(crate) struct Package {
    pub name: String,
    /// The published version string (e.g. `"2026.1.0"`). Read by the version
    /// engine (RV2.5) to resolve each crate's current YBF version.
    #[serde(default)]
    pub version: Option<String>,
    #[serde(rename = "manifest_path")]
    pub manifest_path: String,
    /// `null` ⇒ unrestricted publishing; `[]` ⇒ publishing forbidden; a list
    /// ⇒ publishable only to the named registries.
    pub publish: Option<Vec<String>>,
    /// `null` when no `[package.metadata.*]` is declared.
    pub metadata: Option<serde_json::Value>,
    pub targets: Vec<Target>,
    /// The package's declared dependencies (normal, dev, build). The graph
    /// builder (RV2.4) reads these to construct the publishable DAG.
    #[serde(default)]
    pub dependencies: Vec<Dependency>,
}

/// A dependency entry from cargo metadata (official schema:
/// `https://doc.rust-lang.org/cargo/commands/cargo-metadata.html`).
/// Only the fields the graph builder reads are modeled.
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct Dependency {
    pub name: String,
    /// `"dev"`, `"build"`, or `null` (normal).
    #[serde(default)]
    pub kind: Option<String>,
    #[serde(default)]
    #[allow(dead_code)]
    pub optional: Option<bool>,
    /// The version requirement. `"*"` for a path-only dep with no version.
    #[serde(default)]
    pub req: Option<String>,
    /// Present only for local path dependencies.
    #[serde(default)]
    pub path: Option<String>,
    /// The package name if renamed via `package = "…"`, else `null`.
    #[serde(default)]
    pub rename: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub(crate) struct Target {
    pub kind: Vec<String>,
}

/// Whether a package may be published to crates.io, derived from the
/// `publish` field (official semantics): unrestricted (`null`) or a list
/// that names `crates-io`.
fn is_publishable(publish: &Option<Vec<String>>) -> bool {
    match publish {
        None => true,
        Some(registries) => registries.iter().any(|r| r == "crates-io"),
    }
}

/// Whether a package is a proc-macro crate, derived from its targets.
fn is_proc_macro(package: &Package) -> bool {
    package
        .targets
        .iter()
        .any(|t| t.kind.iter().any(|k| k == "proc-macro"))
}

/// A workspace crate with its release metadata resolved.
///
/// `metadata` is [`CrateMetadata::Absent`] when no
/// `[package.metadata.arcature]` table is present, [`Valid`] when it parses,
/// or [`Malformed`] when it is present but cannot be deserialized — so one
/// bad crate produces one diagnostic instead of aborting discovery.
///
/// `manifest_path` and `proc_macro` are read by later phases (the graph
/// phase resolves crate roots; prepare edits the manifest; the version
/// engine treats proc-macro crates specially within `core`). They are
/// allowed dead until then to avoid a clippy warning on the phase that
/// introduces them.
#[derive(Debug, Clone)]
pub(crate) struct DiscoveredCrate {
    pub name: String,
    #[allow(dead_code)]
    pub manifest_path: PathBuf,
    #[allow(dead_code)]
    pub proc_macro: bool,
    pub publishable: bool,
    pub metadata: CrateMetadata,
}

/// Run `cargo metadata --no-deps --format-version 1` and parse the result.
///
/// The command runs in the current working directory; `cargo metadata`
/// walks up to the workspace root, so this is correct whether invoked from
/// the Arcature repo root or a subdirectory. `--no-deps` keeps the document
/// to workspace members only — the dependency DAG is reconstructed by the
/// graph phase (RV2.4) from the same `dependencies` field, not by resolving
/// the external graph here.
pub(crate) fn load_metadata() -> Result<CargoMetadata, ReleaseError> {
    let spec = ProcessSpec::new("cargo", std::env::current_dir().unwrap_or_default())
        .arg("metadata")
        .arg("--no-deps")
        .arg("--format-version")
        .arg("1");
    let json = run_capture(&spec)?;
    let metadata: CargoMetadata = serde_json::from_slice(&json)?;
    Ok(metadata)
}

/// Project a parsed [`CargoMetadata`] document onto the per-crate release
/// view. Pure: no I/O, deterministic (input order preserved).
pub(crate) fn discover(document: &CargoMetadata) -> Vec<DiscoveredCrate> {
    document
        .packages
        .iter()
        .map(|package| {
            let metadata = match package.metadata.as_ref().and_then(|m| m.get("arcature")) {
                None => CrateMetadata::Absent,
                Some(value) => match serde_json::from_value::<ReleaseMetadata>(value.clone()) {
                    Ok(parsed) => CrateMetadata::Valid(parsed),
                    Err(error) => CrateMetadata::Malformed(error.to_string()),
                },
            };
            DiscoveredCrate {
                name: package.name.clone(),
                manifest_path: PathBuf::from(&package.manifest_path),
                publishable: is_publishable(&package.publish),
                proc_macro: is_proc_macro(package),
                metadata,
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::release::metadata::Role;

    fn package(name: &str, publish: Option<&[&str]>, metadata: Option<&str>) -> Package {
        Package {
            name: name.to_string(),
            version: None,
            manifest_path: format!("/repo/crates/{name}/Cargo.toml"),
            publish: publish.map(|p| p.iter().map(|s| (*s).to_string()).collect()),
            metadata: metadata.map(|m| serde_json::from_str(m).unwrap()),
            targets: vec![Target {
                kind: vec!["lib".to_string()],
            }],
            dependencies: Vec::new(),
        }
    }

    #[test]
    fn unrestricted_publish_is_publishable() {
        assert!(is_publishable(&None));
    }

    #[test]
    fn empty_publish_list_is_not_publishable() {
        assert!(!is_publishable(&Some(vec![])));
    }

    #[test]
    fn crates_io_registry_is_publishable() {
        assert!(is_publishable(&Some(vec!["crates-io".to_string()])));
        assert!(!is_publishable(&Some(vec!["private".to_string()])));
    }

    #[test]
    fn absent_metadata_yields_absent() {
        let doc = CargoMetadata {
            packages: vec![package("arcature-auth", None, None)],
        };
        let crates = discover(&doc);
        assert_eq!(crates.len(), 1);
        assert!(matches!(crates[0].metadata, CrateMetadata::Absent));
        assert!(crates[0].publishable);
    }

    #[test]
    fn valid_metadata_parses() {
        let doc = CargoMetadata {
            packages: vec![package(
                "arcature-auth",
                None,
                Some(
                    r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
                ),
            )],
        };
        let crates = discover(&doc);
        match &crates[0].metadata {
            CrateMetadata::Valid(md) => {
                assert!(md.publish);
                assert_eq!(md.role, Some(Role::Subsystem));
                assert_eq!(md.release_unit.as_deref(), Some("arcature-auth"));
            }
            other => panic!("expected Valid, got {other:?}"),
        }
    }

    #[test]
    fn malformed_metadata_is_captured_not_fatal() {
        // publish=true but role is an unknown variant — deserialization
        // fails, but discovery records it as Malformed rather than aborting.
        let doc = CargoMetadata {
            packages: vec![package(
                "arcature-bad",
                None,
                Some(r#"{"arcature":{"publish":true,"role":"glue","release-unit":"x"}}"#),
            )],
        };
        let crates = discover(&doc);
        assert!(matches!(crates[0].metadata, CrateMetadata::Malformed(_)));
    }

    #[test]
    fn proc_macro_target_detected() {
        let mut pkg = package("arcature-dx", None, None);
        pkg.targets = vec![Target {
            kind: vec!["proc-macro".to_string()],
        }];
        assert!(is_proc_macro(&pkg));
        let lib = package("arcature", None, None);
        assert!(!is_proc_macro(&lib));
    }
}