phoxal-runtime-contract 0.56.1

Phoxal process-boundary identities, participant launch ABI, and embedded metadata contract.
Documentation
//! The one sanctioned writer of the embedded participant-metadata document.
//!
//! [`ParticipantMetadata`](crate::metadata::ParticipantMetadata) is
//! deserialize-only, so the document's serialized shape is defined exactly
//! once, here, by [`ParticipantMetadataRecord`]. Every field is a typed version
//! identity; no writer anywhere restates a version as a string literal.
//!
//! A role macro cannot call `serde_json` though: the record it emits lands in a
//! `#[link_section]` static, whose length must be a constant, and its
//! `config_schema` is only known after `rustc` const-evaluates the recursive
//! `ParticipantConfig::SCHEMA_JSON` tree in the participant's own crate. So the
//! const-eval path is
//! [`participant_metadata_json!`](crate::participant_metadata_json), which
//! composes the same document from the same typed values through
//! `const_format`. The two are one writer in two evaluation modes, and
//! `the_const_writer_emits_exactly_what_the_typed_record_serializes` fails if
//! they ever disagree.

use serde::Serialize;

use crate::metadata::{ParticipantKind, ParticipantRequirement, ParticipantSchemas};
use crate::version::RobotApiVersion;

/// `const_format::concatcp!`, made reachable as `$crate::emit::concatcp!`.
///
/// [`participant_metadata_json!`](crate::participant_metadata_json) expands
/// inside a participant's own crate, which does not depend on `const_format`,
/// so the macro cannot name that crate directly. Routing the call through this
/// crate is what makes the expansion hygienic: it resolves in the participant
/// crate no matter what is in scope there. That obligation is the only reason
/// this is public, and it is why the item cannot be made private or removed
/// while the macro exists.
#[doc(hidden)]
pub use const_format::concatcp;

/// The serialize side of the embedded metadata document.
///
/// The serialized form of one [`ParticipantContract`](crate::metadata::ParticipantContract)
/// while its artifact id is still a const string in a role-macro expansion.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ParticipantContractRecord<'a> {
    pub api: RobotApiVersion,
    pub schemas: ParticipantSchemas,
    pub id: &'a str,
    pub kind: ParticipantKind,
    pub requirement: Option<ParticipantRequirement>,
    pub config_schema: serde_json::Value,
}

/// Its variants and renames mirror
/// [`ParticipantMetadata`](crate::metadata::ParticipantMetadata) exactly - that
/// is the point: a record written through this type is, by construction, a
/// document the parser accepts.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "schema")]
pub enum ParticipantMetadataRecord<'a> {
    #[serde(rename = "phoxal/participant-metadata/v0")]
    V0 {
        #[serde(flatten)]
        contract: ParticipantContractRecord<'a>,
    },
}

/// Const-evaluates the embedded metadata document from typed version
/// identities.
///
/// `api` is the canonical Robot API token generated by the selected API
/// catalogue. The remaining version identities are closed enums and the macro
/// reads their canonical spelling through `as_str`. `id` is the participant
/// identity literal and `config_schema` is a `&'static str` holding
/// already-composed JSON.
///
/// Hidden from the docs: this is the ABI writer the role macros expand into,
/// not a surface a participant author calls. It is `#[macro_export]` only
/// because macro expansion in another crate needs it to be nameable.
#[doc(hidden)]
#[macro_export]
macro_rules! participant_metadata_json {
    (
        api = $api:expr,
        bus = $bus:expr,
        launch = $launch:expr,
        runtime = $runtime:expr,
        id = $id:expr,
        kind = $kind:expr,
        requirement = $requirement:expr,
        config_schema = $config_schema:expr $(,)?
    ) => {{
        // `concatcp!` takes constants, not method calls, so each identity
        // resolves to its canonical spelling one step earlier.
        const __PHOXAL_API: &str = $api;
        const __PHOXAL_BUS: &str = $bus.as_str();
        const __LAUNCH_ABI: &str = $launch.as_str();
        const __PHOXAL_RUNTIME: &str = $runtime.as_str();
        const __PHOXAL_KIND: &str = $kind.as_str();
        const __PHOXAL_REQUIREMENT: &str = match $requirement {
            Some(requirement) => match requirement {
                $crate::metadata::ParticipantRequirement::DifferentialDriveVelocity => {
                    "\"differential_drive_velocity\""
                }
            },
            None => "null",
        };

        $crate::emit::concatcp!(
            "{\"schema\":\"phoxal/participant-metadata/v0\",\"api\":\"",
            __PHOXAL_API,
            "\",\"schemas\":{\"bus\":\"",
            __PHOXAL_BUS,
            "\",\"launch\":\"",
            __LAUNCH_ABI,
            "\",\"runtime\":\"",
            __PHOXAL_RUNTIME,
            "\"},\"id\":\"",
            $id,
            "\",\"kind\":\"",
            __PHOXAL_KIND,
            "\",\"requirement\":",
            __PHOXAL_REQUIREMENT,
            ",\"config_schema\":",
            $config_schema,
            "}"
        )
    }};
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::metadata::ParticipantMetadata;
    use crate::version::{BusAbi, LaunchAbi, RuntimeSchema};

    const CONFIG_SCHEMA: &str = r#"{"type":"null"}"#;

    const EMBEDDED: &str = participant_metadata_json!(
        api = "phoxal/robot-api/v0.1",
        bus = BusAbi::V0,
        launch = LaunchAbi::V0,
        runtime = RuntimeSchema::V0,
        id = "drive",
        kind = ParticipantKind::Service,
        requirement = None,
        config_schema = CONFIG_SCHEMA,
    );

    fn typed_record() -> ParticipantMetadataRecord<'static> {
        ParticipantMetadataRecord::V0 {
            contract: ParticipantContractRecord {
                api: RobotApiVersion::new(0, 1),
                schemas: ParticipantSchemas {
                    bus: BusAbi::V0,
                    launch: LaunchAbi::V0,
                    runtime: RuntimeSchema::V0,
                },
                id: "drive",
                kind: ParticipantKind::Service,
                requirement: None,
                config_schema: serde_json::json!({"type": "null"}),
            },
        }
    }

    #[test]
    fn the_const_writer_emits_exactly_what_the_typed_record_serializes() {
        let const_written: serde_json::Value =
            serde_json::from_str(EMBEDDED).expect("the const writer emits a JSON document");
        let typed = serde_json::to_value(typed_record()).expect("the typed record serializes");
        assert_eq!(const_written, typed);
    }

    #[test]
    fn an_emitted_record_parses_back_into_every_typed_identity() {
        let metadata = ParticipantMetadata::from_bytes(EMBEDDED.as_bytes())
            .expect("the writer's own output must satisfy the parser");
        let contract = metadata.contract();

        assert_eq!(contract.api, RobotApiVersion::new(0, 1));
        assert_eq!(contract.schemas.bus, BusAbi::V0);
        assert_eq!(contract.schemas.launch, LaunchAbi::V0);
        assert_eq!(contract.schemas.runtime, RuntimeSchema::V0);
        assert_eq!(contract.id.as_str(), "drive");
        assert_eq!(contract.kind, ParticipantKind::Service);
        assert_eq!(contract.requirement, None);
        assert_eq!(contract.config_schema, serde_json::json!({"type": "null"}));
    }

    /// The embedded section is read as a whole document, so the const writer
    /// has to emit one - not a fragment a reader would have to repair.
    #[test]
    fn the_const_written_document_is_self_contained() {
        assert!(
            EMBEDDED.starts_with('{') && EMBEDDED.ends_with('}'),
            "{EMBEDDED}"
        );
        assert_eq!(EMBEDDED.len(), EMBEDDED.trim().len());
    }
}