arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! The deterministic package manifest (`manifest.json`) (AP2.1-10).
//!
//! The manifest is a small, stable JSON document describing the production
//! artifact bundle: the application name, the Arcature framework version,
//! the build target, the bundled files (relative paths + sizes), the
//! creation timestamp, and a digest of the manifest itself. It is
//! **deterministic**: the same inputs produce byte-identical manifest JSON
//! (sorted keys, no trailing whitespace, fixed float formatting — serde's
//! default with `serde_json::to_string_pretty` is stable for our schema).
//! The file list is sorted by relative path so two builds of the same
//! bundle produce the same manifest.
//!
//! The manifest does NOT include secrets, env vars, or absolute paths —
//! only relative paths within the bundle and public metadata. An operator
//! verifies a deployed bundle by recomputing the checksums
//! (`checksums.sha256`) and comparing the manifest.

use std::path::Path;

use serde::Serialize;

use super::error::PackageError;

/// A single bundled file entry in the manifest.
#[derive(Debug, Serialize)]
pub(crate) struct ManifestFile {
    /// The relative path within the bundle (forward slashes, POSIX-style).
    pub path: String,
    /// The file size in bytes.
    pub size: u64,
    /// The SHA-256 digest of the file contents (hex lowercase).
    pub sha256: String,
}

/// The package manifest. Serialized to `manifest.json` at the bundle root.
///
/// Field order is fixed (serde serializes struct fields in declaration
/// order) so the output is stable. The `files` vector is sorted by `path`
/// before serialization (see [`Manifest::new`]) so two builds of the same
/// bundle produce byte-identical JSON.
#[derive(Debug, Serialize)]
pub(crate) struct Manifest {
    /// The manifest schema version (stable; bumped only on breaking change).
    pub schema_version: u32,
    /// The application name (from `arcature.toml`'s `backend_package`).
    pub application: String,
    /// The Arcature framework version the app was compiled against
    /// (`arcature::FRAMEWORK_VERSION`).
    pub framework_version: String,
    /// The build target triple (e.g. `x86_64-unknown-linux-gnu`).
    pub target: String,
    /// The bundle creation timestamp (RFC 3339 UTC). Present for human
    /// inspection; NOT part of the deterministic digest (two builds at
    /// different times with identical inputs differ only here).
    pub created_at: String,
    /// The bundled files, sorted by relative path.
    pub files: Vec<ManifestFile>,
}

impl Manifest {
    /// Build a manifest from the application name, framework version,
    /// target, timestamp, and the bundled file entries. The `files` are
    /// sorted by `path` for deterministic output.
    pub(crate) fn new(
        application: String,
        framework_version: String,
        target: String,
        created_at: String,
        mut files: Vec<ManifestFile>,
    ) -> Self {
        files.sort_by(|a, b| a.path.cmp(&b.path));
        Manifest {
            schema_version: 1,
            application,
            framework_version,
            target,
            created_at,
            files,
        }
    }

    /// Serialize the manifest to a stable, pretty-printed JSON string.
    /// `serde_json::to_string_pretty` emits with 2-space indentation and a
    /// trailing newline; the output is deterministic for our schema (no
    /// floats, no maps with nondeterministic key order — `files` is a
    /// sorted `Vec`, `BTreeMap` would also be sorted).
    pub(crate) fn to_json(&self) -> Result<String, PackageError> {
        serde_json::to_string_pretty(self)
            .map_err(|source| PackageError::Serialize {
                what: "manifest",
                source,
            })
            // `to_string_pretty` does not add a trailing newline; add one so
            // the file ends cleanly and `git diff` / `cmp` is stable.
            .map(|json| format!("{json}\n"))
    }

    /// Write the manifest to `path` (the bundle root's `manifest.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,
        })
    }
}

/// A tiny helper: normalize a bundle-relative path to forward slashes
/// (POSIX-style) so the manifest is platform-independent. On Windows a
/// backslash in a manifest path would break Linux deployments.
pub(crate) fn relative_path(base: &Path, file: &Path) -> String {
    let rel = file
        .strip_prefix(base)
        .map(|p| p.to_path_buf())
        .unwrap_or_else(|_| file.to_path_buf());
    rel.components()
        .map(|c| c.as_os_str().to_string_lossy().into_owned())
        .collect::<Vec<_>>()
        .join("/")
}

/// Sort and deduplicate file paths for deterministic enumeration. Returns
/// the canonical (forward-slash) relative paths in sorted order.
#[cfg(test)]
pub(crate) fn sorted_relative_paths(base: &Path, files: &[std::path::PathBuf]) -> Vec<String> {
    let mut rels: Vec<String> = files.iter().map(|f| relative_path(base, f)).collect();
    rels.sort();
    rels.dedup();
    rels
}

#[cfg(test)]
mod tests {
    use super::{Manifest, ManifestFile, relative_path, sorted_relative_paths};
    use std::path::{Path, PathBuf};

    #[test]
    fn manifest_serializes_deterministically() {
        let files = vec![
            ManifestFile {
                path: "public/build/assets/style.css".to_owned(),
                size: 100,
                sha256: "a".repeat(64),
            },
            ManifestFile {
                path: "app-binary".to_owned(),
                size: 200,
                sha256: "b".repeat(64),
            },
            ManifestFile {
                path: "public/build/assets/app.js".to_owned(),
                size: 300,
                sha256: "c".repeat(64),
            },
        ];
        let manifest = Manifest::new(
            "demo".to_owned(),
            "2026.1.0".to_owned(),
            "x86_64-unknown-linux-gnu".to_owned(),
            "2026-08-17T00:00:00Z".to_owned(),
            files,
        );
        let json = manifest.to_json().expect("serialize");
        // Files are sorted by path: app-binary < public/build/assets/app.js
        // < public/build/assets/style.css.
        let app_idx = json.find("\"app-binary\"").expect("app-binary present");
        let js_idx = json
            .find("\"public/build/assets/app.js\"")
            .expect("app.js present");
        let css_idx = json
            .find("\"public/build/assets/style.css\"")
            .expect("style.css present");
        assert!(app_idx < js_idx);
        assert!(js_idx < css_idx);
        // Schema version is present.
        assert!(json.contains("\"schema_version\": 1"));
        assert!(json.contains("\"application\": \"demo\""));
        assert!(json.contains("\"framework_version\": \"2026.1.0\""));
        // Trailing newline for stable files.
        assert!(json.ends_with("}\n"));
    }

    #[test]
    fn same_inputs_produce_same_manifest() {
        let make = || {
            Manifest::new(
                "demo".to_owned(),
                "2026.1.0".to_owned(),
                "x86_64-unknown-linux-gnu".to_owned(),
                "2026-08-17T00:00:00Z".to_owned(),
                vec![
                    ManifestFile {
                        path: "z".to_owned(),
                        size: 1,
                        sha256: "z".repeat(64),
                    },
                    ManifestFile {
                        path: "a".to_owned(),
                        size: 2,
                        sha256: "a".repeat(64),
                    },
                ],
            )
            .to_json()
            .expect("serialize")
        };
        assert_eq!(make(), make(), "deterministic manifest output");
    }

    #[test]
    fn relative_path_uses_forward_slashes() {
        let base = Path::new("/bundle");
        let file = Path::new("/bundle/public/build/assets/app.js");
        assert_eq!(relative_path(base, file), "public/build/assets/app.js");
        // On Windows the path components use backslash; the join normalizes
        // to forward slash so the manifest is platform-independent. On Unix
        // the backslash is a literal character (not a separator), so the
        // Windows path test is `cfg(windows)` only.
        #[cfg(windows)]
        {
            let win_base = PathBuf::from(r"C:\bundle");
            let win_file = PathBuf::from(r"C:\bundle\public\build");
            assert_eq!(relative_path(&win_base, &win_file), "public/build");
        }
    }

    #[test]
    fn sorted_relative_paths_is_sorted_and_deduped() {
        let base = Path::new("/bundle");
        let files = vec![
            PathBuf::from("/bundle/b"),
            PathBuf::from("/bundle/a"),
            PathBuf::from("/bundle/a"),
        ];
        assert_eq!(
            sorted_relative_paths(base, &files),
            vec!["a".to_owned(), "b".to_owned()]
        );
    }
}