phoxal 0.67.0

Phoxal - production-oriented autonomous robot framework: the one framework library, holding the runtime engine, the api contract tree, the typed bus, the canonical model, and the bundle.
Documentation
//! The one sanctioned writer of the embedded participant-metadata document.
//!
//! [`ParticipantMetadata`] is deserialize-only, so the document's serialized
//! shape is defined exactly once, here, by [`ParticipantMetadataRecord`]. The
//! framework train version is the document's whole compatibility claim and
//! arrives as a typed [`FrameworkVersion`]; no writer anywhere invents a
//! version of its own.
//!
//! 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::__compat::wire::{DescribeWire, WireSchema};
use crate::model::connection::ConnectionKind;
use crate::participant::metadata::{ParticipantContract, ParticipantKind, ParticipantMetadata};
use crate::version::FrameworkVersion;

/// The serialize side of the embedded metadata document.
///
/// The serialized form of one [`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 framework: FrameworkVersion,
    pub id: &'a str,
    pub kind: ParticipantKind,
    pub connection: Option<ConnectionKind>,
    pub config_schema: serde_json::Value,
}

/// Its variants and renames mirror [`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>,
    },
}

impl DescribeWire for ParticipantContractRecord<'_> {
    // Invariant: this record is the serialize side of one contract, so it
    // declares that contract's shape rather than a second copy of it. A field
    // added to only one of the two stops matching here.
    fn wire_schema() -> WireSchema {
        ParticipantContract::wire_schema()
    }
}

impl DescribeWire for ParticipantMetadataRecord<'_> {
    // Invariant: the writer and the parser are one document, so the shape is
    // stated exactly once, on the parser.
    fn wire_schema() -> WireSchema {
        ParticipantMetadata::wire_schema()
    }
}

/// The JSON fragment a declared connection kind contributes to the embedded
/// document: the kind's own quoted token, or `null` when none was declared.
///
/// `concatcp!` splices `&str` constants, so the record's one nullable field has
/// to arrive already spelled as JSON. The quoting lives here rather than in the
/// macro body because a const-eval `match` is the only way to produce it, and
/// `the_connection_fragment_is_the_quoted_wire_token` checks it against
/// [`ConnectionKind::as_str`] so the two spellings cannot drift.
#[doc(hidden)]
#[must_use]
pub const fn connection_json(connection: Option<ConnectionKind>) -> &'static str {
    match connection {
        None => "null",
        Some(ConnectionKind::Can) => "\"can\"",
        Some(ConnectionKind::I2c) => "\"i2c\"",
        Some(ConnectionKind::Spi) => "\"spi\"",
        Some(ConnectionKind::Serial) => "\"serial\"",
        Some(ConnectionKind::Uart) => "\"uart\"",
        Some(ConnectionKind::Usb) => "\"usb\"",
        Some(ConnectionKind::Gpio) => "\"gpio\"",
    }
}

/// Const-evaluates the embedded metadata document.
///
/// `framework` is the canonical spelling of the framework train version,
/// spliced from the facade constant that owns it. `id` is the participant
/// identity literal, `connection` is the declared connection kind as an
/// `Option<ConnectionKind>`, 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.
///
/// Everything the expansion names goes through `$crate::__private`, the macro
/// ABI - including the `concatcp!` it composes the document with. A participant
/// crate does not depend on `const_format`, and the participant engine is not a
/// public path in a participant's own profile, so the ABI module is the only
/// door either can be reached through.
#[doc(hidden)]
#[macro_export]
macro_rules! participant_metadata_json {
    (
        framework = $framework:expr,
        id = $id:expr,
        kind = $kind:expr,
        connection = $connection:expr,
        config_schema = $config_schema:expr $(,)?
    ) => {{
        // `concatcp!` takes constants, not method calls, so each value resolves
        // to its canonical spelling one step earlier.
        const __PHOXAL_FRAMEWORK: &str = $framework;
        const __PHOXAL_KIND: &str = $kind.as_str();
        const __PHOXAL_CONNECTION: &str = $crate::__private::connection_json($connection);

        $crate::__private::meta::concatcp!(
            "{\"schema\":\"phoxal/participant-metadata/v0\",\"framework\":\"",
            __PHOXAL_FRAMEWORK,
            "\",\"id\":\"",
            $id,
            "\",\"kind\":\"",
            __PHOXAL_KIND,
            "\",\"connection\":",
            __PHOXAL_CONNECTION,
            ",\"config_schema\":",
            $config_schema,
            "}"
        )
    }};
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::participant::metadata::ParticipantMetadata;

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

    const EMBEDDED: &str = participant_metadata_json!(
        framework = FrameworkVersion::CURRENT_SPELLING,
        id = "drive",
        kind = ParticipantKind::Service,
        connection = None,
        config_schema = CONFIG_SCHEMA,
    );

    /// The other half of the one nullable field: a driver that declared a kind.
    const EMBEDDED_DECLARED: &str = participant_metadata_json!(
        framework = FrameworkVersion::CURRENT_SPELLING,
        id = "ddsm115",
        kind = ParticipantKind::Driver,
        connection = Some(ConnectionKind::Serial),
        config_schema = CONFIG_SCHEMA,
    );

    fn typed_record() -> ParticipantMetadataRecord<'static> {
        ParticipantMetadataRecord::V0 {
            contract: ParticipantContractRecord {
                framework: FrameworkVersion::CURRENT,
                id: "drive",
                kind: ParticipantKind::Service,
                connection: None,
                config_schema: serde_json::json!({"type": "null"}),
            },
        }
    }

    fn typed_declared_record() -> ParticipantMetadataRecord<'static> {
        ParticipantMetadataRecord::V0 {
            contract: ParticipantContractRecord {
                framework: FrameworkVersion::CURRENT,
                id: "ddsm115",
                kind: ParticipantKind::Driver,
                connection: Some(ConnectionKind::Serial),
                config_schema: serde_json::json!({"type": "null"}),
            },
        }
    }

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

    /// The const writer has to spell the kind as a JSON string itself, so the
    /// fragment it splices is checked against the kind's one wire token rather
    /// than trusted.
    #[test]
    fn the_connection_fragment_is_the_quoted_wire_token() {
        assert_eq!(connection_json(None), "null");
        for kind in ConnectionKind::ALL {
            assert_eq!(
                connection_json(Some(kind)),
                format!("\"{}\"", kind.as_str())
            );
        }
    }

    /// The bytes that actually land in the linker section are checked against
    /// the declared document shape, so the const evaluation mode is covered by
    /// the same declaration the typed one is.
    #[test]
    fn the_const_written_bytes_have_the_declared_document_shape() {
        for embedded in [EMBEDDED, EMBEDDED_DECLARED] {
            let const_written: serde_json::Value =
                serde_json::from_str(embedded).expect("the const writer emits a JSON document");
            assert_eq!(
                ParticipantMetadataRecord::wire_schema().conforms(&const_written),
                Ok(())
            );
        }
    }

    #[test]
    fn an_emitted_record_parses_back_into_the_typed_contract() {
        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.framework, FrameworkVersion::CURRENT);
        assert_eq!(contract.id.as_str(), "drive");
        assert_eq!(contract.kind, ParticipantKind::Service);
        assert_eq!(contract.connection, None);
        assert_eq!(contract.config_schema, serde_json::json!({"type": "null"}));

        let declared = ParticipantMetadata::from_bytes(EMBEDDED_DECLARED.as_bytes())
            .expect("the writer's own output must satisfy the parser");
        assert_eq!(declared.contract().connection, Some(ConnectionKind::Serial));
    }

    /// 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() {
        for embedded in [EMBEDDED, EMBEDDED_DECLARED] {
            assert!(
                embedded.starts_with('{') && embedded.ends_with('}'),
                "{embedded}"
            );
            assert_eq!(embedded.len(), embedded.trim().len());
        }
    }
}