1use std::borrow::Borrow;
9use std::fmt;
10
11use crate::error::{IdentifierKind, ModelError};
12
13pub use phoxal_runtime_contract::identity::{ComponentInstanceId, RobotId};
14
15pub const MODULE_INSTANCE_SEPARATOR: &str = "__";
21
22#[must_use]
33pub fn is_valid_token(value: &str) -> bool {
34 phoxal_runtime_contract::identity::is_topology_token(value)
35}
36
37macro_rules! token_identifier {
41 ($(#[$doc:meta])* $name:ident, $kind:expr) => {
42 $(#[$doc])*
43 #[derive(
44 serde::Serialize,
45 serde::Deserialize,
46 Clone,
47 Debug,
48 PartialEq,
49 Eq,
50 PartialOrd,
51 Ord,
52 Hash,
53 )]
54 #[serde(try_from = "String", into = "String")]
55 pub struct $name(String);
56
57 impl $name {
58 pub const KIND: IdentifierKind = $kind;
60
61 pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
68 let value = value.into();
69 if is_valid_token(&value) {
70 Ok(Self(value))
71 } else {
72 Err(ModelError::NotNormalized {
73 kind: Self::KIND,
74 value,
75 })
76 }
77 }
78
79 #[must_use]
81 pub fn as_str(&self) -> &str {
82 &self.0
83 }
84 }
85
86 impl fmt::Display for $name {
87 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
88 formatter.write_str(&self.0)
89 }
90 }
91
92 impl AsRef<str> for $name {
93 fn as_ref(&self) -> &str {
94 &self.0
95 }
96 }
97
98 impl Borrow<str> for $name {
101 fn borrow(&self) -> &str {
102 &self.0
103 }
104 }
105
106 impl PartialEq<str> for $name {
107 fn eq(&self, other: &str) -> bool {
108 self.0 == other
109 }
110 }
111
112 impl PartialEq<&str> for $name {
113 fn eq(&self, other: &&str) -> bool {
114 self.0 == *other
115 }
116 }
117
118 impl std::str::FromStr for $name {
119 type Err = ModelError;
120
121 fn from_str(value: &str) -> Result<Self, Self::Err> {
122 Self::new(value)
123 }
124 }
125
126 impl TryFrom<String> for $name {
127 type Error = ModelError;
128
129 fn try_from(value: String) -> Result<Self, Self::Error> {
130 Self::new(value)
131 }
132 }
133
134 impl From<$name> for String {
135 fn from(value: $name) -> Self {
136 value.0
137 }
138 }
139
140 impl phoxal_runtime_contract::wire_schema::DescribeWire for $name {
141 fn wire_schema() -> phoxal_runtime_contract::wire_schema::WireSchema {
145 phoxal_runtime_contract::wire_schema::WireSchema::opaque(
146 stringify!($name),
147 phoxal_runtime_contract::wire_schema::WireSchema::String,
148 )
149 }
150 }
151 };
152}
153
154token_identifier!(
155 ComponentTypeId,
157 IdentifierKind::ComponentType
158);
159
160token_identifier!(
161 CapabilityId,
163 IdentifierKind::Capability
164);
165
166macro_rules! structural_identifier {
173 ($(#[$doc:meta])* $name:ident) => {
174 $(#[$doc])*
175 #[derive(
176 serde::Serialize,
177 serde::Deserialize,
178 Clone,
179 Debug,
180 PartialEq,
181 Eq,
182 PartialOrd,
183 Ord,
184 Hash,
185 )]
186 #[serde(transparent)]
187 pub struct $name(String);
188
189 impl $name {
190 pub fn new(value: impl Into<String>) -> Self {
192 Self(value.into())
193 }
194
195 #[must_use]
197 pub fn as_str(&self) -> &str {
198 &self.0
199 }
200
201 #[must_use]
204 pub fn namespaced(&self, component_id: &ComponentInstanceId) -> Self {
205 Self(format!(
206 "{component_id}{MODULE_INSTANCE_SEPARATOR}{}",
207 self.0
208 ))
209 }
210 }
211
212 impl fmt::Display for $name {
213 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214 formatter.write_str(&self.0)
215 }
216 }
217
218 impl AsRef<str> for $name {
219 fn as_ref(&self) -> &str {
220 &self.0
221 }
222 }
223
224 impl Borrow<str> for $name {
225 fn borrow(&self) -> &str {
226 &self.0
227 }
228 }
229
230 impl PartialEq<str> for $name {
231 fn eq(&self, other: &str) -> bool {
232 self.0 == other
233 }
234 }
235
236 impl PartialEq<&str> for $name {
237 fn eq(&self, other: &&str) -> bool {
238 self.0 == *other
239 }
240 }
241
242 impl From<String> for $name {
243 fn from(value: String) -> Self {
244 Self(value)
245 }
246 }
247
248 impl From<$name> for String {
249 fn from(value: $name) -> Self {
250 value.0
251 }
252 }
253
254 impl phoxal_runtime_contract::wire_schema::DescribeWire for $name {
255 fn wire_schema() -> phoxal_runtime_contract::wire_schema::WireSchema {
258 phoxal_runtime_contract::wire_schema::WireSchema::opaque(
259 stringify!($name),
260 phoxal_runtime_contract::wire_schema::WireSchema::String,
261 )
262 }
263 }
264 };
265}
266
267structural_identifier!(
268 LinkId
270);
271
272structural_identifier!(
273 JointId
275);
276
277#[derive(
281 serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash,
282)]
283#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
284#[cfg_attr(feature = "schemars", schemars(with = "String", inline))]
287#[serde(try_from = "String", into = "String")]
288pub struct CapabilityRef {
289 pub component_id: ComponentInstanceId,
290 pub capability_id: CapabilityId,
291}
292
293impl CapabilityRef {
294 #[must_use]
296 pub const fn new(component_id: ComponentInstanceId, capability_id: CapabilityId) -> Self {
297 Self {
298 component_id,
299 capability_id,
300 }
301 }
302}
303
304impl fmt::Display for CapabilityRef {
305 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
306 write!(formatter, "{}.{}", self.component_id, self.capability_id)
307 }
308}
309
310impl std::str::FromStr for CapabilityRef {
311 type Err = ModelError;
312
313 fn from_str(value: &str) -> Result<Self, Self::Err> {
314 let (component_id, capability_id) =
315 value
316 .split_once('.')
317 .ok_or_else(|| ModelError::MalformedCapabilityReference {
318 value: value.to_string(),
319 })?;
320 Ok(Self::new(
321 ComponentInstanceId::new(component_id)?,
322 CapabilityId::new(capability_id)?,
323 ))
324 }
325}
326
327impl TryFrom<String> for CapabilityRef {
328 type Error = ModelError;
329
330 fn try_from(value: String) -> Result<Self, Self::Error> {
331 value.parse()
332 }
333}
334
335impl From<CapabilityRef> for String {
336 fn from(value: CapabilityRef) -> Self {
337 value.to_string()
338 }
339}
340
341impl phoxal_runtime_contract::wire_schema::DescribeWire for CapabilityRef {
342 fn wire_schema() -> phoxal_runtime_contract::wire_schema::WireSchema {
346 phoxal_runtime_contract::wire_schema::WireSchema::opaque(
347 "CapabilityRef",
348 phoxal_runtime_contract::wire_schema::WireSchema::String,
349 )
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn a_token_is_lowercase_ascii_digits_underscore_or_dash() {
359 for valid in ["a", "front_left_drive", "vl53l1x", "imu-0", "0"] {
360 assert!(is_valid_token(valid), "{valid}");
361 }
362 for invalid in ["", "Abc", "a.b", "a/b", "a b", "café"] {
363 assert!(!is_valid_token(invalid), "{invalid}");
364 }
365 }
366
367 #[test]
368 fn surrounding_whitespace_is_rejected_never_trimmed() {
369 assert!(!is_valid_token(" abc"));
372 assert!(!is_valid_token("abc "));
373 assert!(!is_valid_token(" abc "));
374 assert!(!is_valid_token(" "));
375 assert!(!is_valid_token(""));
376 assert!(is_valid_token("abc"));
377 }
378
379 #[test]
380 fn token_identifiers_reject_untrimmed_values() {
381 assert!(ComponentInstanceId::new(" abc").is_err());
382 assert!(CapabilityId::new("abc ").is_err());
383 assert!(ComponentTypeId::new("").is_err());
384 assert_eq!(RobotId::new("rover").unwrap().as_str(), "rover");
385 }
386
387 #[test]
388 fn token_identifiers_round_trip_as_bare_strings() {
389 let id = ComponentInstanceId::new("front_left_drive").unwrap();
390 let json = serde_json::to_string(&id).unwrap();
391 assert_eq!(json, "\"front_left_drive\"");
392 assert_eq!(
393 serde_json::from_str::<ComponentInstanceId>(&json).unwrap(),
394 id
395 );
396
397 for json in ["\" abc\"", "\"Abc\"", "\"\""] {
398 assert!(
399 serde_json::from_str::<CapabilityId>(json).is_err(),
400 "{json}"
401 );
402 }
403 }
404
405 #[test]
406 fn token_identifiers_are_usable_as_map_keys_on_the_wire() {
407 let mut map = std::collections::BTreeMap::new();
408 map.insert(CapabilityId::new("rgb").unwrap(), 1_u8);
409 let json = serde_json::to_string(&map).unwrap();
410 assert_eq!(json, "{\"rgb\":1}");
411 assert_eq!(
412 serde_json::from_str::<std::collections::BTreeMap<CapabilityId, u8>>(&json).unwrap(),
413 map
414 );
415 }
416
417 #[test]
418 fn structural_identifiers_round_trip_as_bare_strings() {
419 let link = LinkId::new("base_link");
420 assert_eq!(serde_json::to_string(&link).unwrap(), "\"base_link\"");
421 assert_eq!(
422 serde_json::from_str::<LinkId>("\"base_link\"").unwrap(),
423 link
424 );
425
426 let joint = JointId::new("wheel_joint");
427 assert_eq!(serde_json::to_string(&joint).unwrap(), "\"wheel_joint\"");
428 assert_eq!(
429 serde_json::from_str::<JointId>("\"wheel_joint\"").unwrap(),
430 joint
431 );
432 }
433
434 #[test]
435 fn namespacing_joins_the_instance_id_with_the_reserved_separator() {
436 let instance = ComponentInstanceId::new("left_drive").unwrap();
437 assert_eq!(
438 LinkId::new("wheel").namespaced(&instance).as_str(),
439 "left_drive__wheel"
440 );
441 assert_eq!(
442 JointId::new("axle").namespaced(&instance).as_str(),
443 "left_drive__axle"
444 );
445 }
446
447 #[test]
448 fn a_capability_reference_is_one_dotted_string_on_the_wire() {
449 let reference: CapabilityRef = "front_camera.rgb".parse().unwrap();
450 assert_eq!(reference.component_id, "front_camera");
451 assert_eq!(reference.capability_id, "rgb");
452
453 let json = serde_json::to_string(&reference).unwrap();
454 assert_eq!(json, "\"front_camera.rgb\"");
455 assert_eq!(
456 serde_json::from_str::<CapabilityRef>(&json).unwrap(),
457 reference
458 );
459 }
460
461 #[test]
462 fn a_capability_reference_needs_both_halves_normalized() {
463 assert!("front_camera".parse::<CapabilityRef>().is_err());
464 assert!("Front.rgb".parse::<CapabilityRef>().is_err());
465 assert!("front. rgb".parse::<CapabilityRef>().is_err());
466 }
467
468 #[test]
469 fn references_sort_by_component_then_capability() {
470 let mut references = [
471 CapabilityRef::new(
472 ComponentInstanceId::new("b").unwrap(),
473 CapabilityId::new("a").unwrap(),
474 ),
475 CapabilityRef::new(
476 ComponentInstanceId::new("a").unwrap(),
477 CapabilityId::new("b").unwrap(),
478 ),
479 CapabilityRef::new(
480 ComponentInstanceId::new("a").unwrap(),
481 CapabilityId::new("a").unwrap(),
482 ),
483 ];
484 references.sort();
485 assert_eq!(
486 references
487 .iter()
488 .map(ToString::to_string)
489 .collect::<Vec<_>>(),
490 ["a.a", "a.b", "b.a"]
491 );
492 }
493}