Skip to main content

phoxal_runtime_contract/
version.rs

1//! Every version identity that crosses a Phoxal process boundary.
2//!
3//! Most cross-binary versions are a closed set. Robot API identity is the
4//! deliberate exception: a process must be able to report an otherwise valid
5//! newer robot API to an external client even when that client does not
6//! implement that API's contract tree. It is therefore an open, validated
7//! value rather than a train-maintained enum.
8//!
9//! These identities live on the process-boundary floor rather than in the crate
10//! that implements each contract, because the record that declares them
11//! ([`crate::metadata::ParticipantMetadata`]) has to name all of them at once
12//! and this crate is below `phoxal-bus`, `phoxal-api`, and `phoxal-manifest`
13//! in the graph. `phoxal-api` pins its generated `RobotApi` to the revision its contract tree
14//! actually speaks, and the runtime bundle pins [`RuntimeSchema`] to the
15//! compiled document grammar it persists. Authored source grammars are not
16//! participant-binary compatibility claims.
17
18use serde::{Deserialize, Serialize};
19
20/// An exact, open robot API identity carried at the process boundary.
21///
22/// Its canonical wire spelling is `phoxal/robot-api/v<major>.<minor>`. This
23/// type deliberately accepts versions unknown to this framework build; the
24/// generated API catalogue decides whether a caller implements a particular
25/// revision. Keeping that catalogue above this process-contract floor avoids a
26/// dependency from this crate back to `phoxal-api`.
27#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
28pub struct RobotApiVersion {
29    major: u16,
30    minor: u16,
31}
32
33impl RobotApiVersion {
34    /// Construct one exact robot API version.
35    #[must_use]
36    pub const fn new(major: u16, minor: u16) -> Self {
37        Self { major, minor }
38    }
39
40    /// The API's major component.
41    #[must_use]
42    pub const fn major(self) -> u16 {
43        self.major
44    }
45
46    /// The API's minor component.
47    #[must_use]
48    pub const fn minor(self) -> u16 {
49        self.minor
50    }
51}
52
53impl std::fmt::Display for RobotApiVersion {
54    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        write!(formatter, "phoxal/robot-api/v{}.{}", self.major, self.minor)
56    }
57}
58
59impl Serialize for RobotApiVersion {
60    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
61        serializer.collect_str(self)
62    }
63}
64
65impl<'de> Deserialize<'de> for RobotApiVersion {
66    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
67        let value = String::deserialize(deserializer)?;
68        const PREFIX: &str = "phoxal/robot-api/v";
69        let Some(version) = value.strip_prefix(PREFIX) else {
70            return Err(serde::de::Error::custom(format!(
71                "invalid robot API '{value}'; expected {PREFIX}<major>.<minor>"
72            )));
73        };
74        let Some((major, minor)) = version.split_once('.') else {
75            return Err(serde::de::Error::custom(format!(
76                "invalid robot API '{value}'; expected {PREFIX}<major>.<minor>"
77            )));
78        };
79        if major.is_empty()
80            || minor.is_empty()
81            || minor.contains('.')
82            || !major.bytes().all(|byte| byte.is_ascii_digit())
83            || !minor.bytes().all(|byte| byte.is_ascii_digit())
84        {
85            return Err(serde::de::Error::custom(format!(
86                "invalid robot API '{value}'; expected {PREFIX}<major>.<minor>"
87            )));
88        }
89        let major = major.parse().map_err(serde::de::Error::custom)?;
90        let minor = minor.parse().map_err(serde::de::Error::custom)?;
91        let parsed = Self::new(major, minor);
92        if parsed.to_string() != value {
93            return Err(serde::de::Error::custom(format!(
94                "robot API '{value}' is not canonical; expected '{parsed}'"
95            )));
96        }
97        Ok(parsed)
98    }
99}
100
101/// Declares one cross-binary version identity.
102///
103/// The canonical spelling is written exactly once per variant and is used for
104/// both the serde rename and `as_str`, so the wire token and the diagnostic
105/// token cannot drift apart.
106macro_rules! version_identity {
107    (
108        $(#[$enum_meta:meta])*
109        $name:ident {
110            $(
111                $(#[$variant_meta:meta])*
112                $variant:ident = $token:literal
113            ),+ $(,)?
114        }
115    ) => {
116        $(#[$enum_meta])*
117        #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
118        pub enum $name {
119            $(
120                $(#[$variant_meta])*
121                #[serde(rename = $token)]
122                $variant,
123            )+
124        }
125
126        impl $name {
127            /// The canonical spelling of this version, for diagnostics. It is
128            /// the same literal the serde rename uses.
129            #[must_use]
130            pub const fn as_str(self) -> &'static str {
131                match self {
132                    $(Self::$variant => $token,)+
133                }
134            }
135        }
136
137        impl std::fmt::Display for $name {
138            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139                f.write_str(self.as_str())
140            }
141        }
142    };
143}
144
145version_identity! {
146    /// The bus wire ABI: the version-qualified key grammar, the sample
147    /// metadata, and the encoding string together.
148    ///
149    /// Distinct from the `phoxal/v0` prefix inside a Zenoh encoding string:
150    /// that token is per-sample wire overhead and stays short, while this one
151    /// is a document identity that has to be unambiguous next to the launch
152    /// and manifest identities it sits beside.
153    BusAbi {
154        V0 = "phoxal/bus-abi/v0",
155    }
156}
157
158version_identity! {
159    /// The process launch compatibility identity.
160    LaunchAbi {
161        V0 = "phoxal/participant-launch/v0",
162    }
163}
164
165version_identity! {
166    /// The compiled runtime document grammar consumed by participants and
167    /// supervisors. This is deliberately distinct from authored robot,
168    /// component, and simulation source schemas.
169    RuntimeSchema {
170        V0 = "phoxal/runtime-bundle/v0",
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    /// `as_str` and the serde rename are generated from one literal, so this
179    /// asserts the property that literal is meant to have: what a peer reads
180    /// off the wire is exactly what a diagnostic prints.
181    macro_rules! assert_round_trip {
182        ($value:expr) => {{
183            let value = $value;
184            let json = serde_json::to_string(&value).expect("a unit variant serializes");
185            assert_eq!(json, format!("\"{}\"", value.as_str()));
186            assert_eq!(
187                serde_json::from_str::<_>(&json).ok(),
188                Some(value),
189                "the canonical spelling must deserialize back to the same variant"
190            );
191        }};
192    }
193
194    #[test]
195    fn every_identity_serializes_to_its_canonical_spelling_and_back() {
196        assert_round_trip!(BusAbi::V0);
197        assert_round_trip!(LaunchAbi::V0);
198        assert_round_trip!(RuntimeSchema::V0);
199    }
200
201    #[test]
202    fn the_canonical_spellings_are_the_tokens_a_peer_binary_expects() {
203        assert_eq!(BusAbi::V0.as_str(), "phoxal/bus-abi/v0");
204        assert_eq!(LaunchAbi::V0.as_str(), "phoxal/participant-launch/v0");
205        assert_eq!(RuntimeSchema::V0.as_str(), "phoxal/runtime-bundle/v0");
206    }
207
208    #[test]
209    fn an_unknown_version_is_rejected_with_the_expected_set_named() {
210        let error = serde_json::from_str::<BusAbi>("\"phoxal/bus-abi/v1\"")
211            .expect_err("a version this train does not speak must not parse");
212        let message = error.to_string();
213        assert!(message.contains("phoxal/bus-abi/v1"), "{message}");
214        assert!(message.contains("phoxal/bus-abi/v0"), "{message}");
215    }
216
217    /// Each process-boundary grammar is its own type, so a record can never
218    /// compare one contract version against another's.
219    #[test]
220    fn identities_of_different_kinds_are_different_types() {
221        assert_ne!(BusAbi::V0.as_str(), RuntimeSchema::V0.as_str());
222    }
223
224    #[test]
225    fn robot_api_is_open_but_canonical() {
226        let known = RobotApiVersion::new(0, 1);
227        let future = RobotApiVersion::new(42, 7);
228        assert_eq!(known.to_string(), "phoxal/robot-api/v0.1");
229        assert_eq!(
230            serde_json::from_str::<RobotApiVersion>("\"phoxal/robot-api/v42.7\"").unwrap(),
231            future
232        );
233        assert!(serde_json::from_str::<RobotApiVersion>("\"phoxal/robot-api/v042.7\"").is_err());
234        assert!(serde_json::from_str::<RobotApiVersion>("\"phoxal/robot-api/v42\"").is_err());
235    }
236}