Skip to main content

nros_serdes/
schema.rs

1//! Static field schema for runtime introspection.
2//!
3//! Each generated message type exposes its CDR field layout as a
4//! `&'static [Field]` slice plus a `&'static str` ROS type name via the
5//! [`Message`] trait. Backends that need to construct type descriptors at
6//! runtime (Cyclone DDS dynamic types, FastRTPS DynamicTypeBuilder, …) walk
7//! this static metadata instead of pulling in per-RMW codegen at compile
8//! time.
9//!
10//! All schema items are `&'static` / [`Copy`] / contain no allocations,
11//! keeping the surface usable on `no_std` + alloc-free embedded targets.
12//!
13//! # Example
14//!
15//! ```
16//! use nros_serdes::schema::{Field, FieldType, Message};
17//!
18//! /// Hand-rolled mirror of `std_msgs/msg/Int32`.
19//! pub struct Int32 {
20//!     pub data: i32,
21//! }
22//!
23//! impl Message for Int32 {
24//!     const TYPE_NAME: &'static str = "std_msgs/msg/Int32";
25//!     const FIELDS: &'static [Field] = &[Field {
26//!         name: "data",
27//!         ty: FieldType::Int32,
28//!         offset: 0,
29//!     }];
30//! }
31//!
32//! assert_eq!(Int32::FIELDS.len(), 1);
33//! assert!(matches!(Int32::FIELDS[0].ty, FieldType::Int32));
34//! ```
35
36/// One field of a ROS message, in declaration order.
37///
38/// `offset` is the byte offset of the field within the host Rust struct
39/// (typically derived from `core::mem::offset_of!`). Backends that build
40/// runtime descriptors use it to compute serializer per-field strides;
41/// pure schema consumers (e.g. type-name renderers) may ignore it.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct Field {
44    /// Field name as written in the `.msg` IDL.
45    pub name: &'static str,
46    /// CDR / IDL type of the field.
47    pub ty: FieldType,
48    /// Byte offset of the field within the host Rust struct.
49    pub offset: usize,
50}
51
52/// CDR / ROS-IDL type of a single field.
53///
54/// Covers every variant Cyclone DDS' dynamic-type C API needs for
55/// constructing a `dds_topic_descriptor_t` at runtime:
56///
57/// * primitives (bool, [iu]{8,16,32,64}, f{32,64})
58/// * strings (unbounded / bounded; narrow / wide)
59/// * nested structs (recurse into a child `&'static [Field]`)
60/// * fixed-size arrays (`T[N]`)
61/// * unbounded sequences (`sequence<T>`)
62/// * bounded sequences (`sequence<T, N>`)
63///
64/// The recursive variants (`Nested`, `Array`, `Sequence`, `BoundedSequence`)
65/// take a `&'static` reference so the entire schema graph stays in `.rodata`
66/// with no heap touch.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum FieldType {
69    /// IDL `boolean` — 1 byte, no alignment.
70    Bool,
71    /// IDL `octet` / `uint8` — 1 byte, no alignment.
72    Uint8,
73    /// IDL `int8` — 1 byte, no alignment.
74    Int8,
75    /// IDL `uint16` — 2 bytes, 2-byte aligned.
76    Uint16,
77    /// IDL `int16` — 2 bytes, 2-byte aligned.
78    Int16,
79    /// IDL `uint32` — 4 bytes, 4-byte aligned.
80    Uint32,
81    /// IDL `int32` — 4 bytes, 4-byte aligned.
82    Int32,
83    /// IDL `uint64` — 8 bytes, 8-byte aligned.
84    Uint64,
85    /// IDL `int64` — 8 bytes, 8-byte aligned.
86    Int64,
87    /// IDL `float` / `float32` — 4 bytes, 4-byte aligned.
88    Float32,
89    /// IDL `double` / `float64` — 8 bytes, 8-byte aligned.
90    Float64,
91    /// Unbounded `string` (UTF-8 narrow).
92    String,
93    /// Unbounded `wstring` (UTF-16 wide).
94    WString,
95    /// Bounded `string<N>` (UTF-8 narrow, max `N` bytes excluding null).
96    BoundedString(usize),
97    /// Bounded `wstring<N>` (UTF-16 wide, max `N` code units).
98    BoundedWString(usize),
99    /// Nested struct field; the inner slice is the child's schema.
100    Nested(&'static NestedType),
101    /// Fixed-size array `T[N]`.
102    Array(usize, &'static FieldType),
103    /// Unbounded `sequence<T>`.
104    Sequence(&'static FieldType),
105    /// Bounded `sequence<T, N>`.
106    BoundedSequence(usize, &'static FieldType),
107}
108
109/// Metadata for a nested struct field.
110///
111/// Carried by [`FieldType::Nested`] so the runtime descriptor builder can
112/// recurse into the child with the correct ROS type name (Cyclone DDS uses
113/// it to dedupe identical nested types in the registry).
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct NestedType {
116    /// Full ROS type name of the nested struct, e.g. `"builtin_interfaces/msg/Time"`.
117    pub type_name: &'static str,
118    /// Schema of the nested struct's fields.
119    pub fields: &'static [Field],
120}
121
122/// Trait implemented by every generated ROS message type for runtime
123/// introspection.
124///
125/// Provides the ROS type name plus the static field schema. Implementors
126/// also typically implement [`crate::Serialize`] + [`crate::Deserialize`]
127/// for the CDR fast path; this trait is the *introspection* surface used
128/// by RMW backends that build type descriptors at runtime.
129///
130/// All items are `&'static`, so the trait is fully usable in `no_std` +
131/// alloc-free environments. The blanket bound is just `Sized` — no
132/// `Serialize` / `Deserialize` super-bound, so verification-only mirror
133/// types (`nros-ghost-types`) and CycloneDDS-only "descriptor probe"
134/// types can implement `Message` without dragging the CDR codecs in.
135pub trait Message: Sized {
136    /// ROS topic-type name in `package/msg/Type` form
137    /// (e.g. `"std_msgs/msg/String"`).
138    ///
139    /// Wire-level DDS encoding (`"std_msgs::msg::dds_::String_"`) is the
140    /// concern of the per-RMW topic-name renderer, *not* this trait.
141    const TYPE_NAME: &'static str;
142
143    /// Field schema in declaration order.
144    const FIELDS: &'static [Field];
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use core::mem::offset_of;
151
152    // ── Fixtures: hand-rolled mirrors of real ROS messages ──────────────
153    //
154    // These stand in for what the codegen template (in the standalone
155    // `nros-cli` repo, K.7.1) will eventually emit for every msg crate.
156    // They exist only to exercise the trait surface in isolation; the
157    // real generated msg crates pick up the same impls automatically once
158    // the codegen template is updated.
159
160    /// Mirrors `std_msgs/msg/Int32` (one primitive field).
161    #[repr(C)]
162    struct Int32 {
163        data: i32,
164    }
165
166    impl Message for Int32 {
167        const TYPE_NAME: &'static str = "std_msgs/msg/Int32";
168        const FIELDS: &'static [Field] = &[Field {
169            name: "data",
170            ty: FieldType::Int32,
171            offset: offset_of!(Int32, data),
172        }];
173    }
174
175    /// Mirrors `builtin_interfaces/msg/Time` (two primitives).
176    #[repr(C)]
177    struct Time {
178        sec: i32,
179        nanosec: u32,
180    }
181
182    impl Message for Time {
183        const TYPE_NAME: &'static str = "builtin_interfaces/msg/Time";
184        const FIELDS: &'static [Field] = &[
185            Field {
186                name: "sec",
187                ty: FieldType::Int32,
188                offset: offset_of!(Time, sec),
189            },
190            Field {
191                name: "nanosec",
192                ty: FieldType::Uint32,
193                offset: offset_of!(Time, nanosec),
194            },
195        ];
196    }
197
198    /// `Time`'s schema, re-exposed as a nested-type descriptor for
199    /// recursion testing.
200    const TIME_NESTED: NestedType = NestedType {
201        type_name: <Time as Message>::TYPE_NAME,
202        fields: <Time as Message>::FIELDS,
203    };
204
205    /// Mirrors `std_msgs/msg/Header` (nested struct + string + bounded fields).
206    #[repr(C)]
207    #[allow(dead_code)]
208    struct Header {
209        stamp: Time,
210        frame_id: &'static str, // representative, not real layout
211    }
212
213    impl Message for Header {
214        const TYPE_NAME: &'static str = "std_msgs/msg/Header";
215        const FIELDS: &'static [Field] = &[
216            Field {
217                name: "stamp",
218                ty: FieldType::Nested(&TIME_NESTED),
219                offset: offset_of!(Header, stamp),
220            },
221            Field {
222                name: "frame_id",
223                ty: FieldType::String,
224                offset: offset_of!(Header, frame_id),
225            },
226        ];
227    }
228
229    /// Mirrors a message with every collection-shape variant the runtime
230    /// descriptor builder needs to handle.
231    #[repr(C)]
232    #[allow(dead_code)]
233    struct Collections {
234        fixed: [i32; 4],
235        bytes: &'static [u8],
236        bounded_seq: &'static [u8],
237        bounded_str: &'static str,
238        bounded_wstr: &'static str,
239        wide: &'static str,
240    }
241
242    const FIXED_I32: FieldType = FieldType::Int32;
243    const SEQ_U8: FieldType = FieldType::Uint8;
244
245    impl Message for Collections {
246        const TYPE_NAME: &'static str = "test_msgs/msg/Collections";
247        const FIELDS: &'static [Field] = &[
248            Field {
249                name: "fixed",
250                ty: FieldType::Array(4, &FIXED_I32),
251                offset: offset_of!(Collections, fixed),
252            },
253            Field {
254                name: "bytes",
255                ty: FieldType::Sequence(&SEQ_U8),
256                offset: offset_of!(Collections, bytes),
257            },
258            Field {
259                name: "bounded_seq",
260                ty: FieldType::BoundedSequence(16, &SEQ_U8),
261                offset: offset_of!(Collections, bounded_seq),
262            },
263            Field {
264                name: "bounded_str",
265                ty: FieldType::BoundedString(32),
266                offset: offset_of!(Collections, bounded_str),
267            },
268            Field {
269                name: "bounded_wstr",
270                ty: FieldType::BoundedWString(8),
271                offset: offset_of!(Collections, bounded_wstr),
272            },
273            Field {
274                name: "wide",
275                ty: FieldType::WString,
276                offset: offset_of!(Collections, wide),
277            },
278        ];
279    }
280
281    // ── Tests: shape of the public surface ──────────────────────────────
282
283    #[test]
284    fn message_consts_visible_in_const_context() {
285        // If `TYPE_NAME` / `FIELDS` weren't `const`, this wouldn't compile.
286        const NAME: &str = <Int32 as Message>::TYPE_NAME;
287        const FIELDS: &[Field] = <Int32 as Message>::FIELDS;
288        assert_eq!(NAME, "std_msgs/msg/Int32");
289        assert_eq!(FIELDS.len(), 1);
290    }
291
292    #[test]
293    fn primitive_field_round_trip() {
294        let f = Int32::FIELDS[0];
295        assert_eq!(f.name, "data");
296        assert!(matches!(f.ty, FieldType::Int32));
297        assert_eq!(f.offset, 0);
298    }
299
300    #[test]
301    fn multi_field_offsets_match_struct_layout() {
302        let fields = Time::FIELDS;
303        assert_eq!(fields.len(), 2);
304        assert_eq!(fields[0].name, "sec");
305        assert_eq!(fields[1].name, "nanosec");
306        // sec is at offset 0, nanosec immediately after on a #[repr(C)] {i32, u32}.
307        assert_eq!(fields[0].offset, 0);
308        assert_eq!(fields[1].offset, 4);
309    }
310
311    #[test]
312    fn nested_field_recurses_into_child_schema() {
313        let fields = Header::FIELDS;
314        assert_eq!(fields.len(), 2);
315        match fields[0].ty {
316            FieldType::Nested(nested) => {
317                assert_eq!(nested.type_name, "builtin_interfaces/msg/Time");
318                assert_eq!(nested.fields.len(), 2);
319                assert_eq!(nested.fields[0].name, "sec");
320            }
321            _ => panic!("expected Nested variant"),
322        }
323        assert!(matches!(fields[1].ty, FieldType::String));
324    }
325
326    #[test]
327    fn collection_variants_cover_array_sequence_bounded_string() {
328        let fields = Collections::FIELDS;
329        assert_eq!(fields.len(), 6);
330
331        assert!(matches!(
332            fields[0].ty,
333            FieldType::Array(4, inner) if matches!(*inner, FieldType::Int32),
334        ));
335        assert!(matches!(
336            fields[1].ty,
337            FieldType::Sequence(inner) if matches!(*inner, FieldType::Uint8),
338        ));
339        assert!(matches!(
340            fields[2].ty,
341            FieldType::BoundedSequence(16, inner) if matches!(*inner, FieldType::Uint8),
342        ));
343        assert!(matches!(fields[3].ty, FieldType::BoundedString(32)));
344        assert!(matches!(fields[4].ty, FieldType::BoundedWString(8)));
345        assert!(matches!(fields[5].ty, FieldType::WString));
346    }
347
348    #[test]
349    fn field_and_fieldtype_are_copy_and_eq() {
350        // Compile-time check via trait bound: forces Copy + Eq.
351        fn assert_copy_eq<T: Copy + Eq>() {}
352        assert_copy_eq::<Field>();
353        assert_copy_eq::<FieldType>();
354        assert_copy_eq::<NestedType>();
355
356        // Spot-check Eq equality.
357        let a = Field {
358            name: "x",
359            ty: FieldType::Int32,
360            offset: 4,
361        };
362        let b = a; // Copy
363        assert_eq!(a, b);
364    }
365
366    #[test]
367    fn all_primitive_variants_constructible() {
368        // Smoke: walking a slice of every primitive variant compiles
369        // and matches end-to-end. If any variant is removed the match
370        // becomes non-exhaustive.
371        const PRIMS: &[FieldType] = &[
372            FieldType::Bool,
373            FieldType::Uint8,
374            FieldType::Int8,
375            FieldType::Uint16,
376            FieldType::Int16,
377            FieldType::Uint32,
378            FieldType::Int32,
379            FieldType::Uint64,
380            FieldType::Int64,
381            FieldType::Float32,
382            FieldType::Float64,
383            FieldType::String,
384            FieldType::WString,
385        ];
386        assert_eq!(PRIMS.len(), 13);
387
388        let mut seen = 0u32;
389        for ty in PRIMS {
390            seen += match ty {
391                FieldType::Bool => 1 << 0,
392                FieldType::Uint8 => 1 << 1,
393                FieldType::Int8 => 1 << 2,
394                FieldType::Uint16 => 1 << 3,
395                FieldType::Int16 => 1 << 4,
396                FieldType::Uint32 => 1 << 5,
397                FieldType::Int32 => 1 << 6,
398                FieldType::Uint64 => 1 << 7,
399                FieldType::Int64 => 1 << 8,
400                FieldType::Float32 => 1 << 9,
401                FieldType::Float64 => 1 << 10,
402                FieldType::String => 1 << 11,
403                FieldType::WString => 1 << 12,
404                FieldType::BoundedString(_)
405                | FieldType::BoundedWString(_)
406                | FieldType::Nested(_)
407                | FieldType::Array(..)
408                | FieldType::Sequence(_)
409                | FieldType::BoundedSequence(..) => 0,
410            };
411        }
412        assert_eq!(seen, (1u32 << 13) - 1, "every primitive variant matched");
413    }
414}