Skip to main content

phoxal_model/
identity.rs

1//! Validated canonical identifiers and the token grammar they share.
2//!
3//! Every domain identifier in the canonical model is a newtype rather than a
4//! bare `String`, so a component type can never be passed where a capability id
5//! is expected. Each newtype is transparent on the wire: it serializes as the
6//! same string the authored document carried.
7
8use std::borrow::Borrow;
9use std::fmt;
10
11use crate::error::{IdentifierKind, ModelError};
12
13pub use phoxal_runtime_contract::identity::{ComponentInstanceId, RobotId, ServiceId};
14
15/// The separator that joins a component instance id to a component-local
16/// structural id when a component's structure is flattened into the robot's.
17///
18/// Robot links, robot joints and component instance ids may not contain it;
19/// that is what makes a flattened structural identity unambiguous.
20pub const MODULE_INSTANCE_SEPARATOR: &str = "__";
21
22/// Whether `value` is a normalized canonical token.
23///
24/// A token is a non-empty sequence of ASCII lowercase letters (`a`-`z`), ASCII
25/// digits (`0`-`9`), `_` and `-`. Nothing else is accepted: uppercase letters,
26/// `.`, `/`, and any non-ASCII character are rejected.
27///
28/// Whitespace is rejected, never trimmed. `" abc"` and `"abc "` are not tokens.
29/// A canonical identifier is compared byte for byte - it is a map key, a topic
30/// component and a frame name - so trimming one on the way in would let two
31/// distinct authored identifiers collide into a single runtime identity.
32#[must_use]
33pub fn is_valid_token(value: &str) -> bool {
34    phoxal_runtime_contract::identity::is_topology_token(value)
35}
36
37/// Declare a newtype whose values are exactly the strings [`is_valid_token`]
38/// accepts. The wire form is the bare string, and every construction path -
39/// including deserialization - goes through the one validating constructor.
40macro_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            /// What this identifier names, used in rejection messages.
59            pub const KIND: IdentifierKind = $kind;
60
61            /// Validate and construct the identifier.
62            ///
63            /// # Errors
64            ///
65            /// Returns [`ModelError::NotNormalized`] when `value` is not a
66            /// token as defined by [`is_valid_token`].
67            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            /// The normalized token.
80            #[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        // Lets a `BTreeMap` keyed by this type be looked up with a plain `&str`:
99        // a lookup does not need a validated key, only a construction does.
100        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            // Invariant: this states what `#[serde(into = "String")]` above
142            // writes - the bare normalized token as one string. The token
143            // grammar itself is a decode rule, not a shape.
144            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    /// The identity of a component *type* - one authored component definition.
156    ComponentTypeId,
157    IdentifierKind::ComponentType
158);
159
160token_identifier!(
161    /// The identity of one capability within its component.
162    CapabilityId,
163    IdentifierKind::Capability
164);
165
166/// Declare a structural identifier newtype.
167///
168/// Structural names come from authored URDF, whose grammar is wider than
169/// [`is_valid_token`] and is not ours to narrow, so these carry no validation.
170/// They exist to keep a link id and a joint id from being interchangeable, and
171/// to give namespacing a single owner.
172macro_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            /// Adopt an authored structural name.
191            pub fn new(value: impl Into<String>) -> Self {
192                Self(value.into())
193            }
194
195            /// The structural name.
196            #[must_use]
197            pub fn as_str(&self) -> &str {
198                &self.0
199            }
200
201            /// This component-local identity as it appears in the robot's
202            /// flattened structure, under the instance that mounts it.
203            #[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            // Invariant: this states what `#[serde(transparent)]` above writes -
256            // the authored structural name as one string, with no wrapper.
257            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    /// The identity of one structural link.
269    LinkId
270);
271
272structural_identifier!(
273    /// The identity of one structural joint.
274    JointId
275);
276
277/// One capability of one component instance, written `component.capability`.
278///
279/// The wire form is that single dotted string, not a two-field object.
280#[derive(
281    serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash,
282)]
283#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
284// The wire form is one dotted string, so that is also the schema: describing
285// the two private halves would describe a shape no document ever carries.
286#[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    /// Pair two already-validated identities.
295    #[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    // Invariant: this states what `#[serde(into = "String")]` above writes -
343    // the one dotted `component.capability` string, never the two-field struct
344    // the type is made of.
345    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        // Pinned deliberately: trimming would collapse distinct authored ids
370        // onto one runtime identity.
371        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}