zerodds_dcps/dds_type.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! `DdsType` — the trait that user types must implement to be sent
4//! over DDS.
5//!
6//! # Usage
7//!
8//! User types implement the trait either by hand or via the codegen
9//! pipeline `zerodds-idl-rust` (IDL → Rust with a derived `DdsType`
10//! impl). The encoder/decoder pairs follow the XCDR2 convention (see
11//! `zerodds-cdr`); the trait stays transport- and QoS-agnostic.
12//!
13//! # Interop note
14//!
15//! For interop with Cyclone/Fast-DDS, the `TYPE_NAME` MUST match the
16//! remote topic type name exactly (strict equality). IDL type
17//! namespacing (e.g. `std_msgs::msg::String`) must be taken into
18//! account.
19
20extern crate alloc;
21use alloc::vec::Vec;
22
23pub use zerodds_cdr::{KEY_HASH_LEN, PlainCdr2BeKeyHolder, compute_key_hash};
24
25/// XTypes 1.3 §7.4.5 struct extensibility kind. Wire-relevant
26/// information for the sample encoder; mirrors the IDL annotations
27/// `@final` / `@appendable` / `@mutable`.
28///
29/// Spec: `zerodds-xcdr2-rust` §2 references this as
30/// `ExtensibilityKind`; the implementation name `Extensibility` and
31/// the spec-aligned alias [`ExtensibilityKind`] are identical.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33#[repr(u8)]
34pub enum Extensibility {
35 /// `@final`: tight-packed body, no header.
36 Final = 0,
37 /// `@appendable`: 4-byte DHEADER + body, forward-compatible.
38 Appendable = 1,
39 /// `@mutable`: one EMHEADER + body per member.
40 Mutable = 2,
41}
42
43/// Spec-aligned alias: `zerodds-xcdr2-rust` §2 references the
44/// Extensibility enum under the name `ExtensibilityKind`. We keep
45/// `Extensibility` as the implementation name; both are identical via
46/// the alias.
47pub type ExtensibilityKind = Extensibility;
48
49/// A type that can be published/subscribed via DDS.
50pub trait DdsType: Sized {
51 /// Fully-qualified topic type name (e.g. `"std_msgs::String"`).
52 /// Must match the peer type name exactly (strict matching).
53 const TYPE_NAME: &'static str;
54
55 /// XTypes 1.3 §7.4.5 struct extensibility kind. Default `Final`
56 /// for backwards compat with pre-`EXTENSIBILITY` codegen outputs.
57 /// Spec: zerodds-xcdr2-rust §2.3.
58 const EXTENSIBILITY: Extensibility = Extensibility::Final;
59
60 /// `true` if the topic type is **keyed** (at least one member with
61 /// a `@key` annotation). Default `false` — the caller (proc-macro)
62 /// overrides this for keyed types and also implements
63 /// [`Self::encode_key_holder_be`].
64 ///
65 /// Spec: XTypes 1.3 §7.6.8 (KeyHash requirement for keyed topics).
66 ///
67 /// Note (`zerodds-xcdr2-rust` §11 errata): the spec references this
68 /// field as `IS_KEYED`. We keep `HAS_KEY` for source compat with
69 /// pre-1.0 code; the spec-aligned alias [`Self::IS_KEYED`] always
70 /// returns the same value.
71 const HAS_KEY: bool = false;
72
73 /// Spec-aligned alias for [`Self::HAS_KEY`].
74 /// `zerodds-xcdr2-rust` §2 references this as `IS_KEYED`.
75 const IS_KEYED: bool = Self::HAS_KEY;
76
77 /// Maximum size of the PLAIN_CDR2-BE KeyHolder stream in bytes
78 /// (XTypes 1.3 §7.6.8.4 step 5). `None` = not keyed or unbounded
79 /// (MD5 path). `Some(n)` with `n <= 16` = zero-pad path.
80 const KEY_HOLDER_MAX_SIZE: Option<usize> = None;
81
82 /// `true` if the type is annotated with `@nested` (XTypes 1.3
83 /// §7.4.6.3.5). Nested types are only intended as members of other
84 /// types and MUST NOT be registered as a DDS topic type.
85 /// `DomainParticipant::create_topic` rejects registration of
86 /// nested types with `PreconditionNotMet`.
87 const IS_NESTED: bool = false;
88
89 /// XTypes 1.3 §7.3.4.2 — TypeIdentifier of the type for
90 /// XTypes-aware discovery + compatibility matching. Default
91 /// `TypeIdentifier::None` signals "type-id not provided;
92 /// reader-writer matching falls back to plain `type_name`
93 /// comparison (DDS 1.4 §2.2.3 default path)".
94 ///
95 /// idl-rust codegen emits the appropriate TypeIdentifier here:
96 /// - Primitive `int32` → `TypeIdentifier::Primitive(PrimitiveKind::Int32)`,
97 /// - String `string<N>` → `TypeIdentifier::String8Small{ bound }`,
98 /// - Composite struct → `TypeIdentifier::EquivalenceHash` (once the
99 /// TypeRegistry lookup is live).
100 ///
101 /// Once both sides (writer + reader) provide a TypeIdentifier, the
102 /// subscriber match path calls
103 /// [`zerodds_types::type_matcher::TypeMatcher::match_types`]
104 /// (XTypes §7.6.3.7 + DDS 1.4 §2.2.3 TypeConsistencyEnforcement).
105 const TYPE_IDENTIFIER: zerodds_types::TypeIdentifier = zerodds_types::TypeIdentifier::None;
106
107 /// Serializes `self` into the XCDR2 payload sent as the
108 /// `serialized_payload` of a DATA submessage. Default endianness:
109 /// little-endian (RTPS 2.5 §10.5
110 /// `RepresentationIdentifier = CDR2_LE = 0x0010`).
111 ///
112 /// # Errors
113 /// CDR encoder error (buffer overflow, etc.).
114 fn encode(&self, out: &mut Vec<u8>) -> core::result::Result<(), EncodeError>;
115
116 /// Big-endian variant of [`Self::encode`]. The default
117 /// implementation delegates to [`Self::encode`] (no byte swap),
118 /// since a generic BE re-encode is not possible without type
119 /// reflection. Codegen overrides this for structures that should
120 /// genuinely go on the wire as BE. Spec: zerodds-xcdr2-rust §2.4.
121 ///
122 /// # Errors
123 /// CDR encoder error.
124 fn encode_be(&self, out: &mut Vec<u8>) -> core::result::Result<(), EncodeError> {
125 self.encode(out)
126 }
127
128 /// Deserializes an XCDR2 payload. The caller ensures that `bytes`
129 /// contains the full sample payload.
130 ///
131 /// # Errors
132 /// CDR decoder error (truncation, unexpected bytes, etc.).
133 fn decode(bytes: &[u8]) -> core::result::Result<Self, DecodeError>;
134
135 /// Serializes the `@key` member values in **PLAIN_CDR2-BE** format
136 /// into the given [`PlainCdr2BeKeyHolder`]. Order: ascending by
137 /// `member_id` (XTypes 1.3 §7.6.8.3.1.b).
138 ///
139 /// **Default implementation**: empty write. Keyed types MUST
140 /// override this.
141 ///
142 /// Called by the DcpsRuntime in the sample-encode path to write
143 /// PID_KEY_HASH into the inline QoS.
144 fn encode_key_holder_be(&self, _holder: &mut PlainCdr2BeKeyHolder) {
145 // Default: no key. Keyed types override.
146 }
147
148 /// Returns the value of a field path (dotted, e.g. `"a.b"`) as a
149 /// `zerodds_sql_filter::Value` for SQL filter evaluation in
150 /// QueryCondition / ContentFilteredTopic. Default: `None` (no field
151 /// reachable — the filter then denies every sample that contains a
152 /// field access).
153 ///
154 /// Spec: DDS 1.4 §B.2.1 (Filter Expressions) together with
155 /// §2.2.2.5.9 (QueryCondition) and §2.2.2.3.5
156 /// (ContentFilteredTopic). Generated IDL stubs override this per
157 /// field.
158 #[must_use]
159 fn field_value(&self, _path: &str) -> Option<zerodds_sql_filter::Value> {
160 None
161 }
162
163 /// Computes the 16-byte KeyHash of this instance per XTypes 1.3
164 /// §7.6.8.4. `None` if `HAS_KEY = false`.
165 ///
166 /// The default implementation uses [`Self::encode_key_holder_be`] +
167 /// [`Self::KEY_HOLDER_MAX_SIZE`] and delegates to
168 /// [`compute_key_hash`].
169 #[must_use]
170 fn compute_key_hash(&self) -> Option<[u8; KEY_HASH_LEN]> {
171 if !Self::HAS_KEY {
172 return None;
173 }
174 let mut holder = PlainCdr2BeKeyHolder::new();
175 self.encode_key_holder_be(&mut holder);
176 let max = Self::KEY_HOLDER_MAX_SIZE.unwrap_or(usize::MAX);
177 Some(compute_key_hash(holder.as_bytes(), max))
178 }
179
180 /// Spec-aligned alias for [`Self::compute_key_hash`].
181 /// `zerodds-xcdr2-rust` §2.5 uses the name `key_hash`; the
182 /// implementation name keeps `compute_key_hash` for historical
183 /// compat. Both return the same value.
184 #[must_use]
185 fn key_hash(&self) -> Option<[u8; KEY_HASH_LEN]> {
186 self.compute_key_hash()
187 }
188}
189
190/// `RowAccess` adapter for a `DdsType` sample value. Used by the
191/// DataReader in `read_w_condition`/`take_w_condition` and by the
192/// `ContentFilteredTopic` filter.
193pub struct DdsTypeRow<'a, T: DdsType> {
194 /// Inner sample whose fields are queried via
195 /// [`DdsType::field_value`].
196 pub sample: &'a T,
197}
198
199impl<'a, T: DdsType> DdsTypeRow<'a, T> {
200 /// Constructor.
201 #[must_use]
202 pub fn new(sample: &'a T) -> Self {
203 Self { sample }
204 }
205}
206
207impl<T: DdsType> zerodds_sql_filter::RowAccess for DdsTypeRow<'_, T> {
208 fn get(&self, path: &str) -> Option<zerodds_sql_filter::Value> {
209 self.sample.field_value(path)
210 }
211}
212
213/// Placeholder error for DdsType::encode. In v1.3 this will be
214/// re-exported as `zerodds_cdr::EncodeError` once the CDR layer is
215/// stabilized from the DCPS perspective.
216#[derive(Debug, Clone, PartialEq, Eq)]
217#[non_exhaustive]
218pub enum EncodeError {
219 /// Buffer overflow or field-specific value-range error.
220 Invalid {
221 /// Static description.
222 what: &'static str,
223 },
224}
225
226impl core::fmt::Display for EncodeError {
227 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
228 match self {
229 Self::Invalid { what } => write!(f, "encode error: {what}"),
230 }
231 }
232}
233
234#[cfg(feature = "std")]
235impl std::error::Error for EncodeError {}
236
237impl From<zerodds_cdr::EncodeError> for EncodeError {
238 fn from(e: zerodds_cdr::EncodeError) -> Self {
239 // zerodds-cdr errors are passed through as an opaque `Invalid`
240 // wrap. That is sufficient for DdsType callers, who only need
241 // the "encoding failed" information — the detailed error
242 // structure lives in the cdr layer and is serialized via
243 // Display when a caller logs the error message.
244 let _ = e;
245 Self::Invalid {
246 what: "zerodds_cdr encode error",
247 }
248 }
249}
250
251/// Placeholder error for DdsType::decode.
252#[derive(Debug, Clone, PartialEq, Eq)]
253#[non_exhaustive]
254pub enum DecodeError {
255 /// Truncation or value out-of-range.
256 Invalid {
257 /// Static description.
258 what: &'static str,
259 },
260}
261
262impl core::fmt::Display for DecodeError {
263 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
264 match self {
265 Self::Invalid { what } => write!(f, "decode error: {what}"),
266 }
267 }
268}
269
270#[cfg(feature = "std")]
271impl std::error::Error for DecodeError {}
272
273impl From<zerodds_cdr::DecodeError> for DecodeError {
274 fn from(e: zerodds_cdr::DecodeError) -> Self {
275 let _ = e;
276 Self::Invalid {
277 what: "zerodds_cdr decode error",
278 }
279 }
280}
281
282// ---------------------------------------------------------------------
283// DdsAny — IDL `any` type erasure (XCDR2 §7.4.4.7)
284//
285// Wire format: TypeIdentifier header (CDR string) + payload bytes.
286// Pure Rust with no external crate dep; full type erasure via a
287// string tag.
288
289/// IDL `any` as a type-erasure wrapper. Carries a type-identifier
290/// string (e.g. `"std_msgs::Header"`) plus the payload bytes.
291///
292/// Consumer pattern: check `type_name`, then deserialize the `payload`
293/// with the concrete DdsType.
294#[derive(Debug, Clone, PartialEq, Eq, Default)]
295pub struct DdsAny {
296 /// Fully-qualified type name (matches `DdsType::TYPE_NAME`).
297 pub type_name: alloc::string::String,
298 /// XCDR2 payload bytes of the wrapped value.
299 pub payload: Vec<u8>,
300}
301
302impl DdsAny {
303 /// Constructs a `DdsAny` from a `DdsType` value.
304 ///
305 /// # Errors
306 /// `EncodeError` on encode failure.
307 pub fn pack<T: DdsType>(value: &T) -> Result<Self, EncodeError> {
308 let mut payload = Vec::new();
309 value.encode(&mut payload)?;
310 Ok(Self {
311 type_name: alloc::string::String::from(T::TYPE_NAME),
312 payload,
313 })
314 }
315
316 /// Attempts to unpack the wrapped value as `T`.
317 ///
318 /// # Errors
319 /// `DecodeError::Invalid` if `T::TYPE_NAME != self.type_name` or on
320 /// a decode error.
321 pub fn unpack<T: DdsType>(&self) -> Result<T, DecodeError> {
322 if self.type_name != T::TYPE_NAME {
323 return Err(DecodeError::Invalid {
324 what: "DdsAny: type-name mismatch",
325 });
326 }
327 T::decode(&self.payload)
328 }
329}
330
331impl zerodds_cdr::CdrEncode for DdsAny {
332 fn encode(
333 &self,
334 w: &mut zerodds_cdr::BufferWriter,
335 ) -> core::result::Result<(), zerodds_cdr::EncodeError> {
336 // Type name as a CDR string + payload bytes with a u32 length prefix.
337 w.write_string(&self.type_name)?;
338 let payload_len = u32::try_from(self.payload.len()).map_err(|_| {
339 zerodds_cdr::EncodeError::ValueOutOfRange {
340 message: "DdsAny: payload > u32::MAX",
341 }
342 })?;
343 w.write_u32(payload_len)?;
344 w.write_bytes(&self.payload)?;
345 Ok(())
346 }
347}
348
349impl zerodds_cdr::CdrDecode for DdsAny {
350 fn decode(
351 r: &mut zerodds_cdr::BufferReader<'_>,
352 ) -> core::result::Result<Self, zerodds_cdr::DecodeError> {
353 let type_name = r.read_string()?;
354 let payload_len = r.read_u32()? as usize;
355 let payload = r.read_bytes(payload_len)?.to_vec();
356 Ok(Self { type_name, payload })
357 }
358}
359
360// ---------------------------------------------------------------------
361// Built-in `DdsType` for &[u8]/Vec<u8> payloads
362//
363// Many ROS use cases and interop tests need to "pass through raw". A
364// `BytesPayload` newtype with a fixed type name allows that.
365// ---------------------------------------------------------------------
366
367/// An opaque raw byte payload with a configurable type name (via an
368/// `impl` of `BytesPayload<T>` or a newtype).
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct RawBytes {
371 /// Payload bytes (placed on the wire as-is, no CDR framing).
372 pub data: Vec<u8>,
373}
374
375impl RawBytes {
376 /// Constructor.
377 #[must_use]
378 pub fn new(data: Vec<u8>) -> Self {
379 Self { data }
380 }
381}
382
383impl DdsType for RawBytes {
384 const TYPE_NAME: &'static str = "zerodds::RawBytes";
385
386 fn encode(&self, out: &mut Vec<u8>) -> core::result::Result<(), EncodeError> {
387 out.extend_from_slice(&self.data);
388 Ok(())
389 }
390
391 fn decode(bytes: &[u8]) -> core::result::Result<Self, DecodeError> {
392 Ok(Self {
393 data: bytes.to_vec(),
394 })
395 }
396}
397
398#[cfg(test)]
399#[allow(clippy::expect_used, clippy::unwrap_used)]
400mod tests {
401 use super::*;
402
403 #[test]
404 fn raw_bytes_roundtrip() {
405 let orig = RawBytes::new(vec![1, 2, 3, 4, 5]);
406 let mut buf = Vec::new();
407 orig.encode(&mut buf).unwrap();
408 let back = RawBytes::decode(&buf).unwrap();
409 assert_eq!(back, orig);
410 }
411
412 #[test]
413 fn raw_bytes_type_name_is_namespaced() {
414 assert_eq!(RawBytes::TYPE_NAME, "zerodds::RawBytes");
415 }
416
417 // ---- .B: keyed types + KeyHash ----
418
419 /// Test fixture: keyed topic with @key u32 id (max 4 byte → zero-pad).
420 struct SmallKeyed {
421 id: u32,
422 }
423
424 impl DdsType for SmallKeyed {
425 const TYPE_NAME: &'static str = "test::SmallKeyed";
426 const HAS_KEY: bool = true;
427 const KEY_HOLDER_MAX_SIZE: Option<usize> = Some(4);
428
429 fn encode(&self, out: &mut Vec<u8>) -> core::result::Result<(), EncodeError> {
430 out.extend_from_slice(&self.id.to_le_bytes());
431 Ok(())
432 }
433 fn decode(bytes: &[u8]) -> core::result::Result<Self, DecodeError> {
434 if bytes.len() < 4 {
435 return Err(DecodeError::Invalid {
436 what: "truncated SmallKeyed",
437 });
438 }
439 let id = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
440 Ok(Self { id })
441 }
442 fn encode_key_holder_be(&self, holder: &mut PlainCdr2BeKeyHolder) {
443 holder.write_u32(self.id);
444 }
445 }
446
447 #[test]
448 fn small_keyed_produces_zero_padded_keyhash() {
449 let s = SmallKeyed { id: 0x1122_3344 };
450 let key = s.compute_key_hash().expect("keyed");
451 assert_eq!(&key[0..4], &[0x11, 0x22, 0x33, 0x44]);
452 assert_eq!(&key[4..16], &[0u8; 12]);
453 }
454
455 #[test]
456 fn non_keyed_returns_none_for_keyhash() {
457 let r = RawBytes::new(vec![1, 2, 3]);
458 assert_eq!(r.compute_key_hash(), None);
459 }
460
461 #[test]
462 fn keyed_two_instances_have_distinct_hashes() {
463 let a = SmallKeyed { id: 1 };
464 let b = SmallKeyed { id: 2 };
465 assert_ne!(a.compute_key_hash(), b.compute_key_hash());
466 }
467
468 /// Test fixture: keyed topic with an unbounded @key string (MD5 path).
469 struct LargeKeyed {
470 topic: alloc::string::String,
471 }
472
473 impl DdsType for LargeKeyed {
474 const TYPE_NAME: &'static str = "test::LargeKeyed";
475 const HAS_KEY: bool = true;
476 const KEY_HOLDER_MAX_SIZE: Option<usize> = None; // unbounded → MD5
477
478 fn encode(&self, out: &mut Vec<u8>) -> core::result::Result<(), EncodeError> {
479 out.extend_from_slice(self.topic.as_bytes());
480 Ok(())
481 }
482 fn decode(_bytes: &[u8]) -> core::result::Result<Self, DecodeError> {
483 Err(DecodeError::Invalid {
484 what: "test fixture",
485 })
486 }
487 fn encode_key_holder_be(&self, holder: &mut PlainCdr2BeKeyHolder) {
488 holder.write_string(&self.topic);
489 }
490 }
491
492 #[test]
493 fn large_keyed_produces_md5_hashed_keyhash() {
494 let s = LargeKeyed {
495 topic: alloc::string::String::from("hello"),
496 };
497 let key = s.compute_key_hash().expect("keyed");
498 // 16-byte deterministic hash, non-zero
499 assert_ne!(key, [0u8; 16]);
500 // Idempotent
501 let key2 = s.compute_key_hash().expect("keyed");
502 assert_eq!(key, key2);
503 }
504
505 #[test]
506 fn spec_aligned_aliases_match_implementation_names() {
507 // zerodds-xcdr2-rust §11 Errata.
508 assert_eq!(
509 <RawBytes as DdsType>::IS_KEYED,
510 <RawBytes as DdsType>::HAS_KEY
511 );
512 fn is_keyed<T: DdsType>() -> bool {
513 T::IS_KEYED
514 }
515 assert!(is_keyed::<SmallKeyed>());
516 let s = SmallKeyed { id: 0xABCD };
517 assert_eq!(s.key_hash(), s.compute_key_hash());
518 }
519
520 #[test]
521 fn extensibility_default_is_final() {
522 assert_eq!(<RawBytes as DdsType>::EXTENSIBILITY, Extensibility::Final);
523 // ExtensibilityKind alias is the same type.
524 let _: ExtensibilityKind = Extensibility::Mutable;
525 }
526
527 #[test]
528 fn encode_be_default_delegates_to_encode() {
529 let r = RawBytes::new(vec![1, 2, 3]);
530 let mut le = Vec::new();
531 let mut be = Vec::new();
532 r.encode(&mut le).unwrap();
533 r.encode_be(&mut be).unwrap();
534 assert_eq!(le, be);
535 }
536
537 #[test]
538 fn keyed_member_order_matters() {
539 // Hypothetically: two members in a different order would yield
540 // different hashes. We verify this with a mock type that writes
541 // two fields in reverse order.
542 struct A {
543 x: u32,
544 y: u32,
545 }
546 impl DdsType for A {
547 const TYPE_NAME: &'static str = "test::A";
548 const HAS_KEY: bool = true;
549 const KEY_HOLDER_MAX_SIZE: Option<usize> = Some(8);
550 fn encode(&self, _out: &mut Vec<u8>) -> Result<(), EncodeError> {
551 Ok(())
552 }
553 fn decode(_b: &[u8]) -> Result<Self, DecodeError> {
554 Err(DecodeError::Invalid { what: "stub" })
555 }
556 fn encode_key_holder_be(&self, holder: &mut PlainCdr2BeKeyHolder) {
557 holder.write_u32(self.x);
558 holder.write_u32(self.y);
559 }
560 }
561 struct B {
562 x: u32,
563 y: u32,
564 }
565 impl DdsType for B {
566 const TYPE_NAME: &'static str = "test::B";
567 const HAS_KEY: bool = true;
568 const KEY_HOLDER_MAX_SIZE: Option<usize> = Some(8);
569 fn encode(&self, _out: &mut Vec<u8>) -> Result<(), EncodeError> {
570 Ok(())
571 }
572 fn decode(_b: &[u8]) -> Result<Self, DecodeError> {
573 Err(DecodeError::Invalid { what: "stub" })
574 }
575 fn encode_key_holder_be(&self, holder: &mut PlainCdr2BeKeyHolder) {
576 holder.write_u32(self.y);
577 holder.write_u32(self.x);
578 }
579 }
580 let a = A { x: 1, y: 2 };
581 let b = B { x: 1, y: 2 };
582 assert_ne!(a.compute_key_hash(), b.compute_key_hash());
583 }
584}