use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RobotApiVersion {
major: u16,
minor: u16,
}
impl RobotApiVersion {
#[must_use]
pub const fn new(major: u16, minor: u16) -> Self {
Self { major, minor }
}
#[must_use]
pub const fn major(self) -> u16 {
self.major
}
#[must_use]
pub const fn minor(self) -> u16 {
self.minor
}
}
impl std::fmt::Display for RobotApiVersion {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "phoxal/robot-api/v{}.{}", self.major, self.minor)
}
}
impl Serialize for RobotApiVersion {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for RobotApiVersion {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
const PREFIX: &str = "phoxal/robot-api/v";
let Some(version) = value.strip_prefix(PREFIX) else {
return Err(serde::de::Error::custom(format!(
"invalid robot API '{value}'; expected {PREFIX}<major>.<minor>"
)));
};
let Some((major, minor)) = version.split_once('.') else {
return Err(serde::de::Error::custom(format!(
"invalid robot API '{value}'; expected {PREFIX}<major>.<minor>"
)));
};
if major.is_empty()
|| minor.is_empty()
|| minor.contains('.')
|| !major.bytes().all(|byte| byte.is_ascii_digit())
|| !minor.bytes().all(|byte| byte.is_ascii_digit())
{
return Err(serde::de::Error::custom(format!(
"invalid robot API '{value}'; expected {PREFIX}<major>.<minor>"
)));
}
let major = major.parse().map_err(serde::de::Error::custom)?;
let minor = minor.parse().map_err(serde::de::Error::custom)?;
let parsed = Self::new(major, minor);
if parsed.to_string() != value {
return Err(serde::de::Error::custom(format!(
"robot API '{value}' is not canonical; expected '{parsed}'"
)));
}
Ok(parsed)
}
}
macro_rules! version_identity {
(
$(#[$enum_meta:meta])*
$name:ident {
$(
$(#[$variant_meta:meta])*
$variant:ident = $token:literal
),+ $(,)?
}
) => {
$(#[$enum_meta])*
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum $name {
$(
$(#[$variant_meta])*
#[serde(rename = $token)]
$variant,
)+
}
impl $name {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
$(Self::$variant => $token,)+
}
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
};
}
version_identity! {
BusAbi {
V0 = "phoxal/bus-abi/v0",
}
}
version_identity! {
LaunchAbi {
V0 = "phoxal/participant-launch/v0",
}
}
version_identity! {
RuntimeSchema {
V0 = "phoxal/runtime-bundle/v0",
}
}
#[cfg(test)]
mod tests {
use super::*;
macro_rules! assert_round_trip {
($value:expr) => {{
let value = $value;
let json = serde_json::to_string(&value).expect("a unit variant serializes");
assert_eq!(json, format!("\"{}\"", value.as_str()));
assert_eq!(
serde_json::from_str::<_>(&json).ok(),
Some(value),
"the canonical spelling must deserialize back to the same variant"
);
}};
}
#[test]
fn every_identity_serializes_to_its_canonical_spelling_and_back() {
assert_round_trip!(BusAbi::V0);
assert_round_trip!(LaunchAbi::V0);
assert_round_trip!(RuntimeSchema::V0);
}
#[test]
fn the_canonical_spellings_are_the_tokens_a_peer_binary_expects() {
assert_eq!(BusAbi::V0.as_str(), "phoxal/bus-abi/v0");
assert_eq!(LaunchAbi::V0.as_str(), "phoxal/participant-launch/v0");
assert_eq!(RuntimeSchema::V0.as_str(), "phoxal/runtime-bundle/v0");
}
#[test]
fn an_unknown_version_is_rejected_with_the_expected_set_named() {
let error = serde_json::from_str::<BusAbi>("\"phoxal/bus-abi/v1\"")
.expect_err("a version this train does not speak must not parse");
let message = error.to_string();
assert!(message.contains("phoxal/bus-abi/v1"), "{message}");
assert!(message.contains("phoxal/bus-abi/v0"), "{message}");
}
#[test]
fn identities_of_different_kinds_are_different_types() {
assert_ne!(BusAbi::V0.as_str(), RuntimeSchema::V0.as_str());
}
#[test]
fn robot_api_is_open_but_canonical() {
let known = RobotApiVersion::new(0, 1);
let future = RobotApiVersion::new(42, 7);
assert_eq!(known.to_string(), "phoxal/robot-api/v0.1");
assert_eq!(
serde_json::from_str::<RobotApiVersion>("\"phoxal/robot-api/v42.7\"").unwrap(),
future
);
assert!(serde_json::from_str::<RobotApiVersion>("\"phoxal/robot-api/v042.7\"").is_err());
assert!(serde_json::from_str::<RobotApiVersion>("\"phoxal/robot-api/v42\"").is_err());
}
}