arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! The SPDX 2.3 SBOM (`sbom.spdx.json`) for the production artifact bundle
//! (AP2.1-10).
//!
//! Hand-rolled JSON via the already-admitted [`serde_json`] crate — no new
//! SPDX dependency (AGENTS.md §3, §8). The SBOM is a minimal, valid SPDX 2.3
//! document: it names the application package, the Arcature framework
//! dependency, and (when discovered) the direct Rust crate dependencies from
//! `Cargo.lock`. It is **deterministic**: sorted package list, stable
//! creation timestamp passed in (not `now`), stable field order.
//!
//! SPDX 2.3 is the current ISO standard for SBOMs (ISO/IEC 5962:2021). The
//! JSON schema is published at <https://spdx.github.io/spdx-spec/v2.3/>.
//! This module emits the required top-level fields (`spdxVersion`,
//! `dataLicense`, `SPDXID`, `name`, `documentNamespace`, `creationInfo`,
//! `packages`) and a `DESCRIBES` relationship. It does not emit file-level
//! checksums (the `checksums.sha256` file covers the bundle; SPDX file
//! entries are out of scope for the wave-1 slice and would duplicate the
//! checksums file).
//!
//! # Honesty (AGENTS.md §24)
//!
//! The SBOM declares only what `arc package` actually knows: the
//! application package, the Arcature framework, and the direct dependencies
//! discoverable from `Cargo.lock` (name + version). It does NOT claim a
//! full transitive license audit (that requires `cargo deny` / `cargo
//! about` which are separate governance tools). The `licenseDeclared` for
//! discovered crates is `"NOASSERTION"` (SPDX's explicit "not asserted"
//! value) unless a LICENSE file is found in the crate's source directory
//! (rare for registry crates); the dedicated `licenses/` inventory (see
//! [`super::licenses`]) carries the LICENSE text for the application and
//! Arcature. This is the honest representation: the SBOM enumerates the
//! components; the license inventory carries the texts.

use std::path::Path;

use serde::Serialize;

use super::error::PackageError;

/// The SPDX 2.3 document. Serialized to `sbom.spdx.json`.
///
/// SPDX 2.3 JSON uses a mix of camelCase and all-caps field names
/// (`spdxVersion`, `dataLicense`, `SPDXID`, `documentNamespace`,
/// `creationInfo`, `licenseListVersion`, `filesAnalyzed`, …). serde emits
/// Rust field names verbatim, so each field carries an explicit
/// `#[serde(rename = …)]` mapping to the SPDX JSON name. `SPDXID` is not a
/// camelCase of `spdx_id`, so it is renamed explicitly rather than via
/// `rename_all` (which would not produce the all-caps form).
#[derive(Debug, Serialize)]
pub(crate) struct SpdxDocument {
    #[serde(rename = "spdxVersion")]
    pub spdx_version: String,
    #[serde(rename = "dataLicense")]
    pub data_license: String,
    #[serde(rename = "SPDXID")]
    pub spdx_id: String,
    pub name: String,
    #[serde(rename = "documentNamespace")]
    pub document_namespace: String,
    #[serde(rename = "creationInfo")]
    pub creation_info: SpdxCreationInfo,
    pub packages: Vec<SpdxPackage>,
    pub relationships: Vec<SpdxRelationship>,
}

#[derive(Debug, Serialize)]
pub(crate) struct SpdxCreationInfo {
    pub created: String,
    pub creators: Vec<String>,
    #[serde(rename = "licenseListVersion")]
    pub license_list_version: String,
}

#[derive(Debug, Serialize)]
pub(crate) struct SpdxPackage {
    pub name: String,
    #[serde(rename = "SPDXID")]
    pub spdx_id: String,
    #[serde(rename = "versionInfo")]
    pub version_info: String,
    #[serde(rename = "downloadLocation")]
    pub download_location: String,
    #[serde(rename = "filesAnalyzed")]
    pub files_analyzed: bool,
    #[serde(rename = "licenseConcluded")]
    pub license_concluded: String,
    #[serde(rename = "licenseDeclared")]
    pub license_declared: String,
    #[serde(rename = "copyrightText")]
    pub copyright_text: String,
    pub supplier: String,
}

#[derive(Debug, Serialize)]
pub(crate) struct SpdxRelationship {
    #[serde(rename = "spdxElementId")]
    pub spdx_element_id: String,
    #[serde(rename = "relationshipType")]
    pub relationship_type: String,
    #[serde(rename = "relatedSpdxElement")]
    pub related_spdx_element: String,
}

impl SpdxDocument {
    /// Build a minimal, valid SPDX 2.3 document for the production bundle.
    ///
    /// `application` is the app name; `framework_version` is the Arcature
    /// version; `created` is the RFC 3339 timestamp (passed in for
    /// determinism, not `now`); `dependencies` is the list of direct Rust
    /// crate dependencies discovered from `Cargo.lock` (name, version) —
    /// the SBOM enumerates them as packages with `licenseDeclared:
    /// NOASSERTION` (honest; see the module docs).
    pub(crate) fn new(
        application: &str,
        framework_version: &str,
        created: &str,
        dependencies: &[(String, String)],
    ) -> Self {
        let doc_id = "SPDXRef-DOCUMENT".to_owned();
        let app_id = "SPDXRef-Package-Application".to_owned();
        let mut packages = Vec::new();
        // The application package itself.
        packages.push(SpdxPackage {
            name: application.to_owned(),
            spdx_id: app_id.clone(),
            version_info: "UNKNOWN".to_owned(),
            download_location: "NOASSERTION".to_owned(),
            files_analyzed: false,
            license_concluded: "NOASSERTION".to_owned(),
            license_declared: "NOASSERTION".to_owned(),
            copyright_text: "NOASSERTION".to_owned(),
            supplier: "Organization: Application".to_owned(),
        });
        // The Arcature framework dependency.
        let fw_id = "SPDXRef-Package-Arcature".to_owned();
        packages.push(SpdxPackage {
            name: "arcature".to_owned(),
            spdx_id: fw_id.clone(),
            version_info: framework_version.to_owned(),
            download_location: "NOASSERTION".to_owned(),
            files_analyzed: false,
            license_concluded: "Apache-2.0".to_owned(),
            license_declared: "Apache-2.0".to_owned(),
            copyright_text: "NOASSERTION".to_owned(),
            supplier: "Organization: ArcatureLabs".to_owned(),
        });
        // Direct Rust dependencies from Cargo.lock. Sorted by name for
        // deterministic output. SPDX IDs are stable: SPDXRef-Package-<idx>.
        let mut sorted_deps: Vec<&(String, String)> = dependencies.iter().collect();
        sorted_deps.sort_by(|a, b| a.0.cmp(&b.0));
        sorted_deps.dedup_by(|a, b| a.0 == b.0);
        for (idx, (name, version)) in sorted_deps.iter().enumerate() {
            let dep_id = format!("SPDXRef-Package-Dep-{idx}");
            packages.push(SpdxPackage {
                name: name.clone(),
                spdx_id: dep_id,
                version_info: version.clone(),
                download_location: "NOASSERTION".to_owned(),
                files_analyzed: false,
                license_concluded: "NOASSERTION".to_owned(),
                // Honest: we do not assert a license for a registry crate
                // without reading its LICENSE file (the licenses/ inventory
                // carries texts; the SBOM enumerates components).
                license_declared: "NOASSERTION".to_owned(),
                copyright_text: "NOASSERTION".to_owned(),
                supplier: "NOASSERTION".to_owned(),
            });
        }
        let relationships = vec![
            SpdxRelationship {
                spdx_element_id: doc_id.clone(),
                relationship_type: "DESCRIBES".to_owned(),
                related_spdx_element: app_id.clone(),
            },
            SpdxRelationship {
                spdx_element_id: app_id.clone(),
                relationship_type: "DEPENDS_ON".to_owned(),
                related_spdx_element: fw_id,
            },
        ];
        SpdxDocument {
            spdx_version: "SPDX-2.3".to_owned(),
            data_license: "CC0-1.0".to_owned(),
            spdx_id: doc_id,
            name: format!("{application} production bundle"),
            document_namespace: format!("https://arcature.dev/spdx/{application}/{created}"),
            creation_info: SpdxCreationInfo {
                created: created.to_owned(),
                creators: vec![format!(
                    "Tool: arc package (arcature {})",
                    framework_version
                )],
                license_list_version: "3.23".to_owned(),
            },
            packages,
            relationships,
        }
    }

    /// Serialize to stable, pretty JSON with a trailing newline.
    pub(crate) fn to_json(&self) -> Result<String, PackageError> {
        serde_json::to_string_pretty(self)
            .map_err(|source| PackageError::Serialize {
                what: "spdx sbom",
                source,
            })
            .map(|json| format!("{json}\n"))
    }

    /// Write the SBOM to `path` (the bundle root's `sbom.spdx.json`).
    pub(crate) fn write(&self, path: &Path) -> Result<(), PackageError> {
        let json = self.to_json()?;
        std::fs::write(path, json).map_err(|source| PackageError::Write {
            path: path.to_path_buf(),
            source,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::SpdxDocument;

    #[test]
    fn sbom_has_required_spdx_23_top_level_fields() {
        let doc = SpdxDocument::new(
            "demo",
            "2026.1.0",
            "2026-08-17T00:00:00Z",
            &[("serde".to_owned(), "1.0.229".to_owned())],
        );
        let json = doc.to_json().expect("serialize");
        assert!(json.contains("\"spdxVersion\": \"SPDX-2.3\""));
        assert!(json.contains("\"dataLicense\": \"CC0-1.0\""));
        assert!(json.contains("\"SPDXID\": \"SPDXRef-DOCUMENT\""));
        assert!(json.contains("\"name\": \"demo production bundle\""));
        assert!(json.contains("\"documentNamespace\""));
        assert!(json.contains("\"creationInfo\""));
        assert!(json.contains("\"packages\""));
        assert!(json.contains("\"relationships\""));
        assert!(json.contains("\"DESCRIBES\""));
        assert!(json.contains("\"DEPENDS_ON\""));
        assert!(json.contains("\"licenseListVersion\": \"3.23\""));
        assert!(json.contains("\"Tool: arc package"));
    }

    #[test]
    fn sbom_declares_application_arcature_and_sorted_deps() {
        let doc = SpdxDocument::new(
            "demo",
            "2026.1.0",
            "2026-08-17T00:00:00Z",
            &[
                ("zstd".to_owned(), "0.13".to_owned()),
                ("axum".to_owned(), "0.8.9".to_owned()),
                ("axum".to_owned(), "0.8.9".to_owned()), // duplicate, deduped
            ],
        );
        let json = doc.to_json().expect("serialize");
        // Parse and count the `packages` array directly — the honest package
        // count (the document-level `SPDXID` is a separate field, so a raw
        // `"SPDXID"` string match would over-count by one).
        let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse sbom");
        let packages = parsed["packages"].as_array().expect("packages array");
        // Application + Arcature + 2 unique deps (axum deduped) = 4 packages.
        assert_eq!(packages.len(), 4);
        // Arcature framework package with Apache-2.0.
        assert!(json.contains("\"name\": \"arcature\""));
        assert!(json.contains("\"licenseDeclared\": \"Apache-2.0\""));
        assert!(json.contains("\"versionInfo\": \"2026.1.0\""));
        // Deps sorted: axum before zstd.
        let axum_idx = json.find("\"name\": \"axum\"").expect("axum");
        let zstd_idx = json.find("\"name\": \"zstd\"").expect("zstd");
        assert!(axum_idx < zstd_idx);
        // Deps use NOASSERTION for license (honest).
        assert!(json.contains("\"licenseDeclared\": \"NOASSERTION\""));
    }

    #[test]
    fn sbom_is_deterministic_for_same_inputs() {
        let make = || {
            SpdxDocument::new(
                "demo",
                "2026.1.0",
                "2026-08-17T00:00:00Z",
                &[("serde".to_owned(), "1.0.229".to_owned())],
            )
            .to_json()
            .expect("serialize")
        };
        assert_eq!(make(), make());
    }

    #[test]
    fn sbom_trailing_newline() {
        let doc = SpdxDocument::new("demo", "2026.1.0", "2026-08-17T00:00:00Z", &[]);
        let json = doc.to_json().expect("serialize");
        assert!(json.ends_with("}\n"));
    }
}