phoxal_runtime_contract/
version.rs1use serde::{Deserialize, Serialize};
19
20#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
28pub struct RobotApiVersion {
29 major: u16,
30 minor: u16,
31}
32
33impl RobotApiVersion {
34 #[must_use]
36 pub const fn new(major: u16, minor: u16) -> Self {
37 Self { major, minor }
38 }
39
40 #[must_use]
42 pub const fn major(self) -> u16 {
43 self.major
44 }
45
46 #[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
101macro_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 #[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 BusAbi {
154 V0 = "phoxal/bus-abi/v0",
155 }
156}
157
158version_identity! {
159 LaunchAbi {
161 V0 = "phoxal/participant-launch/v0",
162 }
163}
164
165version_identity! {
166 RuntimeSchema {
170 V0 = "phoxal/runtime-bundle/v0",
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 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 #[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}