arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! The license inventory (`licenses/`) for the production artifact bundle
//! (AP2.1-10).
//!
//! `arc package` copies the application's `LICENSE` file (if present) and
//! the Arcature workspace `LICENSE` into `licenses/` so a deployed bundle
//! carries the texts an operator needs for compliance. This is the honest,
//! modest scope of the wave-1 inventory: it carries the LICENSE texts the
//! repository actually contains. It does NOT enumerate every transitive
//! crate's license (that requires `cargo deny` / `cargo about`, which are
//! separate governance tools; the SBOM enumerates components with
//! `licenseDeclared: NOASSERTION` for those — see [`super::sbom`]).
//!
//! # Determinism
//!
//! The copied files are sorted by filename in the bundle for deterministic
//! enumeration; the file contents are copied verbatim.

use std::path::{Path, PathBuf};

use super::error::PackageError;

/// The license inventory: a list of `(bundle-relative-name, source-path)`
/// pairs that `arc package` copies into `licenses/`. Built from the
/// application root and the Arcature workspace root.
pub(crate) struct LicenseInventory {
    pub entries: Vec<(String, PathBuf)>,
}

impl LicenseInventory {
    /// Discover the LICENSE files: the application root's `LICENSE` (if
    /// present) and the Arcature workspace root's `LICENSE`. The
    /// application's copy is named `LICENSE`; the Arcature copy is named
    /// `LICENSE-arcature` so the two do not collide when the application is
    /// Arcature's own dogfood.
    pub(crate) fn discover(app_root: &Path, framework_root: &Path) -> Result<Self, PackageError> {
        let mut entries = Vec::new();
        // The application's LICENSE. Common names: LICENSE, LICENSE.md,
        // LICENSE.txt, COPYING. Copied verbatim as `LICENSE`.
        for candidate in ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"] {
            let path = app_root.join(candidate);
            if path.is_file() {
                entries.push(("LICENSE".to_owned(), path));
                break;
            }
        }
        // The Arcature framework LICENSE. Copied as `LICENSE-arcature`.
        let fw_license = framework_root.join("LICENSE");
        if fw_license.is_file() {
            entries.push(("LICENSE-arcature".to_owned(), fw_license));
        }
        Ok(LicenseInventory { entries })
    }

    /// Copy the discovered LICENSE files into `licenses/` under the bundle
    /// root. Returns the list of copied (bundle-relative-name, source-path)
    /// pairs so the manifest/checksums can enumerate them.
    pub(crate) fn write(
        &self,
        licenses_dir: &Path,
    ) -> Result<Vec<(String, PathBuf)>, PackageError> {
        std::fs::create_dir_all(licenses_dir).map_err(|source| PackageError::Write {
            path: licenses_dir.to_path_buf(),
            source,
        })?;
        let mut copied = Vec::new();
        for (name, source) in &self.entries {
            let dest = licenses_dir.join(name);
            std::fs::copy(source, &dest).map_err(|err| PackageError::Copy {
                what: "license",
                from: source.clone(),
                source: err,
            })?;
            copied.push((name.clone(), dest));
        }
        // Sort for deterministic enumeration.
        copied.sort_by(|a, b| a.0.cmp(&b.0));
        Ok(copied)
    }
}

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

    #[test]
    fn discovers_app_and_framework_licenses() {
        let dir = tempfile::tempdir().expect("tempdir");
        let app_root = dir.path().join("app");
        let fw_root = dir.path().join("fw");
        fs::create_dir_all(&app_root).expect("dir");
        fs::create_dir_all(&fw_root).expect("dir");
        fs::write(app_root.join("LICENSE"), b"app license").expect("write");
        fs::write(fw_root.join("LICENSE"), b"arcature license").expect("write");

        let inv = LicenseInventory::discover(&app_root, &fw_root).expect("discover");
        assert_eq!(inv.entries.len(), 2);
        // The application LICENSE is named "LICENSE".
        assert!(inv.entries.iter().any(|(n, _)| n == "LICENSE"));
        // The framework LICENSE is named "LICENSE-arcature".
        assert!(inv.entries.iter().any(|(n, _)| n == "LICENSE-arcature"));
    }

    #[test]
    fn writes_licenses_into_bundle() {
        let dir = tempfile::tempdir().expect("tempdir");
        let app_root = dir.path().join("app");
        let fw_root = dir.path().join("fw");
        fs::create_dir_all(&app_root).expect("dir");
        fs::create_dir_all(&fw_root).expect("dir");
        fs::write(app_root.join("LICENSE"), b"app license").expect("write");
        fs::write(fw_root.join("LICENSE"), b"arcature license").expect("write");

        let inv = LicenseInventory::discover(&app_root, &fw_root).expect("discover");
        let licenses_dir = dir.path().join("bundle").join("licenses");
        let copied = inv.write(&licenses_dir).expect("write");
        assert_eq!(copied.len(), 2);
        let app_text = fs::read_to_string(licenses_dir.join("LICENSE")).expect("read");
        assert_eq!(app_text, "app license");
        let fw_text = fs::read_to_string(licenses_dir.join("LICENSE-arcature")).expect("read");
        assert_eq!(fw_text, "arcature license");
        let _ = Path::new(&licenses_dir);
    }

    #[test]
    fn missing_app_license_is_skipped() {
        let dir = tempfile::tempdir().expect("tempdir");
        let app_root = dir.path().join("app");
        let fw_root = dir.path().join("fw");
        fs::create_dir_all(&app_root).expect("dir");
        fs::create_dir_all(&fw_root).expect("dir");
        // No LICENSE in app; framework has one.
        fs::write(fw_root.join("LICENSE"), b"arcature license").expect("write");
        let inv = LicenseInventory::discover(&app_root, &fw_root).expect("discover");
        assert_eq!(inv.entries.len(), 1);
        assert_eq!(inv.entries[0].0, "LICENSE-arcature");
    }
}