1use serde::{Deserialize, Serialize};
9
10use crate::identity::ParticipantArtifactId;
11use crate::version::{BusAbi, LaunchAbi, RobotApiVersion, RuntimeSchema};
12
13#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
18#[serde(deny_unknown_fields)]
19pub struct ParticipantSchemas {
20 pub bus: BusAbi,
22 pub launch: LaunchAbi,
24 pub runtime: RuntimeSchema,
26}
27
28#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
35#[serde(deny_unknown_fields)]
36pub struct ParticipantContract {
37 pub id: ParticipantArtifactId,
39 pub kind: ParticipantKind,
41 pub api: RobotApiVersion,
43 pub schemas: ParticipantSchemas,
45 pub requirement: Option<ParticipantRequirement>,
47 pub config_schema: serde_json::Value,
49}
50
51#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ParticipantKind {
57 Service,
58 Driver,
59 Simulator,
60 Brain,
63}
64
65impl ParticipantKind {
66 #[must_use]
70 pub const fn as_str(self) -> &'static str {
71 match self {
72 ParticipantKind::Service => "service",
73 ParticipantKind::Driver => "driver",
74 ParticipantKind::Simulator => "simulator",
75 ParticipantKind::Brain => "brain",
76 }
77 }
78}
79
80#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
82#[serde(rename_all = "snake_case")]
83pub enum ParticipantRequirement {
84 DifferentialDriveVelocity,
86}
87
88impl ParticipantRequirement {
89 #[must_use]
91 pub const fn as_str(self) -> &'static str {
92 match self {
93 Self::DifferentialDriveVelocity => "differential_drive_velocity",
94 }
95 }
96}
97
98#[derive(Clone, Debug, Deserialize, PartialEq)]
105#[serde(tag = "schema", deny_unknown_fields)]
106pub enum ParticipantMetadata {
107 #[serde(rename = "phoxal/participant-metadata/v0")]
108 V0 {
109 #[serde(flatten)]
110 contract: ParticipantContract,
111 },
112}
113
114impl ParticipantMetadata {
115 pub fn from_bytes(bytes: &[u8]) -> Result<Self, MetadataError> {
117 serde_json::from_slice(bytes).map_err(MetadataError)
118 }
119
120 #[must_use]
122 pub const fn contract(&self) -> &ParticipantContract {
123 match self {
124 Self::V0 { contract } => contract,
125 }
126 }
127}
128
129#[derive(Debug, thiserror::Error)]
133#[error("participant metadata is not a readable phoxal document: {0}")]
134pub struct MetadataError(#[from] serde_json::Error);
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 const SCHEMAS: &str = r#"{"bus":"phoxal/bus-abi/v0","launch":"phoxal/participant-launch/v0","runtime":"phoxal/runtime-bundle/v0"}"#;
141
142 fn record(fields: &str) -> Vec<u8> {
143 format!(
144 r#"{{"schema":"phoxal/participant-metadata/v0","api":"phoxal/robot-api/v0.1","schemas":{SCHEMAS},"requirement":null,{fields}}}"#
145 )
146 .into_bytes()
147 }
148
149 #[test]
150 fn a_v0_record_parses_into_the_canonical_artifact_contract() {
151 let ParticipantMetadata::V0 { contract } = ParticipantMetadata::from_bytes(&record(
152 r#""id":"drive","kind":"service","config_schema":{"type":"null"}"#,
153 ))
154 .expect("the exact document a role macro embeds must parse");
155
156 assert_eq!(contract.api, RobotApiVersion::new(0, 1));
157 assert_eq!(contract.schemas.bus, BusAbi::V0);
158 assert_eq!(contract.schemas.launch, LaunchAbi::V0);
159 assert_eq!(contract.schemas.runtime, RuntimeSchema::V0);
160 assert_eq!(contract.id.as_str(), "drive");
161 assert_eq!(contract.kind, ParticipantKind::Service);
162 assert_eq!(contract.requirement, None);
163 assert_eq!(contract.config_schema, serde_json::json!({"type": "null"}));
164 }
165
166 #[test]
167 fn the_root_brain_kind_is_distinct_from_a_service() {
168 let metadata = ParticipantMetadata::from_bytes(&record(
169 r#""id":"brain","kind":"brain","config_schema":{"type":"null"}"#,
170 ))
171 .expect("the exact document `#[phoxal::brain]` embeds must parse");
172 let contract = metadata.contract();
173 assert_eq!(contract.id.as_str(), "brain");
174 assert_eq!(contract.kind, ParticipantKind::Brain);
175 assert_ne!(contract.kind, ParticipantKind::Service);
176 }
177
178 #[test]
179 fn the_kind_wire_token_is_the_serde_rename() {
180 for kind in [
181 ParticipantKind::Service,
182 ParticipantKind::Driver,
183 ParticipantKind::Simulator,
184 ParticipantKind::Brain,
185 ] {
186 let json = serde_json::to_string(&kind).expect("a unit variant serializes");
187 assert_eq!(json, format!("\"{}\"", kind.as_str()));
188 }
189 }
190
191 #[test]
192 fn an_unknown_schema_tag_is_rejected() {
193 let bytes = br#"{"schema":"phoxal/participant-metadata/v1","api":"phoxal/robot-api/v0.1","schemas":{"bus":"phoxal/bus-abi/v0","launch":"phoxal/participant-launch/v0","runtime":"phoxal/runtime-bundle/v0"},"id":"drive","kind":"service","config_schema":null}"#;
194 assert!(ParticipantMetadata::from_bytes(bytes).is_err());
195 }
196
197 #[test]
198 fn a_future_robot_api_identity_is_preserved() {
199 let bytes = format!(
200 r#"{{"schema":"phoxal/participant-metadata/v0","api":"phoxal/robot-api/v0.3","schemas":{SCHEMAS},"id":"drive","kind":"service","config_schema":null}}"#
201 )
202 .into_bytes();
203 let metadata = ParticipantMetadata::from_bytes(&bytes)
204 .expect("the process boundary keeps a validated API identity open");
205 assert_eq!(metadata.contract().api, RobotApiVersion::new(0, 3));
206 }
207
208 #[test]
209 fn an_unknown_field_is_rejected() {
210 assert!(
211 ParticipantMetadata::from_bytes(&record(
212 r#""id":"drive","kind":"service","config_schema":null,"extra":true"#,
213 ))
214 .is_err()
215 );
216 }
217
218 #[test]
219 fn a_record_missing_a_runtime_schema_is_rejected() {
220 let bytes = br#"{"schema":"phoxal/participant-metadata/v0","api":"phoxal/robot-api/v0.1","schemas":{"bus":"phoxal/bus-abi/v0","launch":"phoxal/participant-launch/v0"},"id":"drive","kind":"service","config_schema":null}"#;
221 assert!(ParticipantMetadata::from_bytes(bytes).is_err());
222 }
223
224 #[test]
225 fn requirement_tokens_round_trip() {
226 let requirement = ParticipantRequirement::DifferentialDriveVelocity;
227 let json = serde_json::to_string(&requirement).expect("requirement serializes");
228 assert_eq!(json, format!("\"{}\"", requirement.as_str()));
229 assert_eq!(
230 serde_json::from_str::<ParticipantRequirement>(&json).expect("requirement parses"),
231 requirement
232 );
233 }
234}