arcature-cli 2026.1.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! Deserialization types for the application metadata artifact.
//!
//! The CLI does NOT depend on `arcature` (AGENTS.md §16 standalone-first). It
//! reaches application metadata by shelling out to the app's
//! `arcature-metadata` binary, which emits a JSON artifact. These types
//! mirror that JSON so the CLI can deserialize it with `serde_json` alone.
//!
//! The JSON contract is defined by the app-side serialization of
//! `arcature::ModuleDescriptor`, `arcature::RouteDescriptor`, and the
//! app-provided service metadata. Field names match the Rust struct field
//! names (serde's default), and `RouteMethod` serializes as lowercase
//! (`get`, `post`, …) while `ScheduleCadence` uses a `kind` tag
//! (`{"kind":"every","seconds":300}` / `{"kind":"daily","hour":3,"minute":0}`).

use serde::{Deserialize, Serialize};

/// The top-level metadata artifact emitted by `arcature-metadata`.
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct MetadataArtifact {
    pub(crate) modules: Vec<ModuleEntry>,
    pub(crate) routes: Vec<RouteEntry>,
    pub(crate) services: Vec<ServiceEntry>,
}

/// A single feature module's metadata (mirrors `ModuleDescriptor`).
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct ModuleEntry {
    pub(crate) name: String,
    #[serde(default)]
    pub(crate) imports: Vec<String>,
    #[serde(default)]
    pub(crate) exports: Vec<String>,
    #[serde(default)]
    pub(crate) controllers: Vec<String>,
    #[serde(default)]
    pub(crate) services: Vec<String>,
    #[serde(default)]
    pub(crate) policies: Vec<String>,
    #[serde(default)]
    pub(crate) routes: Vec<RouteEntry>,
    #[serde(default)]
    pub(crate) listeners: Vec<ListenerEntry>,
    #[serde(default)]
    pub(crate) jobs: Vec<JobEntry>,
    #[serde(default)]
    pub(crate) commands: Vec<CommandEntry>,
    #[serde(default)]
    pub(crate) schedules: Vec<ScheduleEntry>,
}

/// A route descriptor (mirrors `RouteDescriptor`).
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct RouteEntry {
    pub(crate) method: String,
    pub(crate) path: String,
    #[serde(default)]
    pub(crate) name: String,
    pub(crate) handler: String,
}

/// Service metadata: name + typed dependency names (app-provided).
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct ServiceEntry {
    pub(crate) name: String,
    #[serde(default)]
    pub(crate) deps: Vec<String>,
}

/// An event → listener binding (mirrors `ListenerBinding`).
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct ListenerEntry {
    pub(crate) event: String,
    pub(crate) listener: String,
}

/// A job handler binding (mirrors `JobBinding`).
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct JobEntry {
    pub(crate) kind: String,
    pub(crate) version: i16,
    pub(crate) handler: String,
}

/// A command binding (mirrors `CommandBinding`).
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct CommandEntry {
    pub(crate) name: String,
    pub(crate) function: String,
}

/// A schedule binding (mirrors `ScheduleBinding`).
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct ScheduleEntry {
    pub(crate) job: String,
    pub(crate) version: i16,
    pub(crate) cadence: CadenceEntry,
}

/// The cadence of a scheduled job (mirrors `ScheduleCadence`).
#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub(crate) enum CadenceEntry {
    Every { seconds: u64 },
    Daily { hour: u8, minute: u8 },
}

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

    #[test]
    fn deserializes_empty_artifact() {
        let json = r#"{"modules":[],"routes":[],"services":[]}"#;
        let artifact: MetadataArtifact =
            serde_json::from_str(json).expect("empty artifact should parse");
        assert!(artifact.modules.is_empty());
        assert!(artifact.routes.is_empty());
        assert!(artifact.services.is_empty());
    }

    #[test]
    fn deserializes_full_artifact() {
        let json = r#"{
            "modules": [{
                "name": "Links",
                "imports": ["Accounts"],
                "exports": ["LinkService"],
                "controllers": ["LinksController"],
                "services": ["LinkService"],
                "policies": ["LinkPolicy"],
                "routes": [{
                    "method": "get",
                    "path": "/links",
                    "name": "links.index",
                    "handler": "LinksController::index"
                }],
                "listeners": [{"event": "LinkCreated", "listener": "log_creation"}],
                "jobs": [{"kind": "check_links", "version": 1, "handler": "handle_check_links"}],
                "commands": [{"name": "links:prune", "function": "prune_links"}],
                "schedules": [{
                    "job": "cleanup_sessions",
                    "version": 1,
                    "cadence": {"kind": "every", "seconds": 300}
                }]
            }],
            "routes": [{
                "method": "get",
                "path": "/links/{link}",
                "name": "links.show",
                "handler": "LinksController::show"
            }],
            "services": [{
                "name": "LinkService",
                "deps": ["Db", "Cache"]
            }]
        }"#;
        let artifact: MetadataArtifact =
            serde_json::from_str(json).expect("full artifact should parse");
        assert_eq!(artifact.modules.len(), 1);
        assert_eq!(artifact.modules[0].name, "Links");
        assert_eq!(artifact.modules[0].imports, ["Accounts"]);
        assert_eq!(artifact.modules[0].controllers, ["LinksController"]);
        assert_eq!(artifact.modules[0].routes.len(), 1);
        assert_eq!(artifact.modules[0].routes[0].method, "get");
        assert_eq!(artifact.modules[0].routes[0].path, "/links");
        assert_eq!(artifact.modules[0].listeners.len(), 1);
        assert_eq!(artifact.modules[0].jobs[0].kind, "check_links");
        assert_eq!(artifact.modules[0].commands[0].name, "links:prune");
        assert_eq!(artifact.modules[0].schedules.len(), 1);
        assert_eq!(
            artifact.modules[0].schedules[0].cadence,
            CadenceEntry::Every { seconds: 300 }
        );
        assert_eq!(artifact.routes.len(), 1);
        assert_eq!(artifact.routes[0].handler, "LinksController::show");
        assert_eq!(artifact.services.len(), 1);
        assert_eq!(artifact.services[0].deps, ["Db", "Cache"]);
    }

    #[test]
    fn deserializes_daily_cadence() {
        let json = r#"{
            "job": "nightly_report",
            "version": 2,
            "cadence": {"kind": "daily", "hour": 3, "minute": 0}
        }"#;
        let entry: ScheduleEntry = serde_json::from_str(json).expect("daily cadence should parse");
        assert_eq!(entry.job, "nightly_report");
        assert_eq!(entry.version, 2);
        assert_eq!(entry.cadence, CadenceEntry::Daily { hour: 3, minute: 0 });
    }

    #[test]
    fn deserializes_with_defaults_for_empty_sections() {
        let json = r#"{
            "modules": [{"name": "Empty"}],
            "routes": [],
            "services": []
        }"#;
        let artifact: MetadataArtifact = serde_json::from_str(json).expect("defaults should parse");
        assert_eq!(artifact.modules.len(), 1);
        assert!(artifact.modules[0].imports.is_empty());
        assert!(artifact.modules[0].controllers.is_empty());
        assert!(artifact.modules[0].routes.is_empty());
        assert!(artifact.modules[0].schedules.is_empty());
    }

    #[test]
    fn rejects_invalid_cadence_kind() {
        let json = r#"{
            "job": "bad",
            "version": 1,
            "cadence": {"kind": "weekly", "day": 1}
        }"#;
        let err = serde_json::from_str::<ScheduleEntry>(json);
        assert!(err.is_err(), "unknown cadence kind should fail");
    }

    #[test]
    fn round_trips_through_serialize_deserialize() {
        let original = MetadataArtifact {
            modules: vec![ModuleEntry {
                name: "Test".to_owned(),
                imports: vec!["A".to_owned()],
                exports: vec![],
                controllers: vec!["C".to_owned()],
                services: vec![],
                policies: vec![],
                routes: vec![],
                listeners: vec![],
                jobs: vec![],
                commands: vec![],
                schedules: vec![ScheduleEntry {
                    job: "j".to_owned(),
                    version: 1,
                    cadence: CadenceEntry::Every { seconds: 60 },
                }],
            }],
            routes: vec![RouteEntry {
                method: "post".to_owned(),
                path: "/x".to_owned(),
                name: "x".to_owned(),
                handler: "X::create".to_owned(),
            }],
            services: vec![ServiceEntry {
                name: "S".to_owned(),
                deps: vec!["Db".to_owned()],
            }],
        };
        let json = serde_json::to_string(&original).expect("serialize should succeed");
        let round_tripped: MetadataArtifact =
            serde_json::from_str(&json).expect("deserialize should succeed");
        assert_eq!(round_tripped.modules.len(), 1);
        assert_eq!(round_tripped.routes.len(), 1);
        assert_eq!(round_tripped.services.len(), 1);
        assert_eq!(
            round_tripped.modules[0].schedules[0].cadence,
            CadenceEntry::Every { seconds: 60 }
        );
    }
}