nros_node/format_check.rs
1//! RFC-0088 / phase-421 W1 — the message-format check, as a compile error.
2//!
3//! ROS 2 names its serialization format with a string and answers
4//! `rmw_get_serialization_format()` at run time, because
5//! `rosidl_typesupport_c` resolves the format's implementation through
6//! `dlopen` and the string is the linker key. nano-ros links one image and
7//! selects its backend by cargo feature, so the same question has a
8//! compile-time answer: [`crate::session::IMAGE_SERIALIZATION_FORMAT_ID`].
9//!
10//! [`assert_message_format`] compares a message's declared
11//! [`nros_core::RosMessage::SERIALIZATION_FORMAT_ID`] against that constant inside an
12//! inline `const {}` block. The comparison therefore happens during
13//! monomorphisation of the entity-creation call, and costs nothing at run
14//! time — no branch appears on the publish path, which is the property
15//! RFC-0088 D1 asks for.
16//!
17//! # What the error looks like
18//!
19//! Creating a publisher for a `Uorb` message in an image whose backend speaks
20//! CDR fails like this. The primary span is the `assert!` below and the
21//! offending type + call site arrive as notes, which is how rustc reports a
22//! post-monomorphisation const failure:
23//!
24//! ```text
25//! error[E0080]: evaluation panicked: message serialization format does not
26//! match the linked backend (RFC-0088)
27//! --> packages/core/nros-node/src/format_check.rs:83:9
28//! | evaluation of `format_check::assert_message_format::<UorbProbe>::{constant#0}`
29//! | failed here
30//!
31//! note: the above error was encountered while instantiating
32//! `fn assert_message_format::<UorbProbe>`
33//! --> src/main.rs:12:9
34//! |
35//! 12 | node.create_publisher::<VehicleStatus>("/status")?;
36//! ```
37//!
38//! **It is a `cargo build` error, not a `cargo check` one.** An inline `const`
39//! block in a generic function is evaluated by the monomorphisation collector,
40//! which only runs during codegen — `cargo check -p nros-node` compiles the
41//! mismatch silently. Measured 2026-09-04. `just ci gate` catches it because
42//! `test-unit` builds; a lane that only type-checks does not.
43//!
44//! # Coverage
45//!
46//! The check reads `nros_core::RosMessage::SERIALIZATION_FORMAT_ID`, and
47//! `MessageForRmw` — the bound every typed creator carries — requires
48//! `RosMessage` under **every** backend. So the assertion is universal: zenoh,
49//! XRCE, Cyclone and uORB alike.
50//!
51//! Keying it on `nros_serdes::schema::Message` instead would have covered only
52//! Cyclone, because `MessageForRmw` requires a schema solely under
53//! `cfg(rmw_needs_type_descriptors)` — and would therefore have been absent
54//! under uORB, the one backend whose format differs and the reason the check
55//! exists. The const is defaulted rather than required for the reason
56//! phase-380 W4 recorded: tightening the message contract to serve a build
57//! assertion broke `examples/native/rust/custom-msg`, the documented
58//! hand-written-message pattern. A default costs those implementors nothing.
59//!
60//! # Proving the negative case
61//!
62//! A compile error cannot be asserted by a running test, and this workspace has
63//! no `trybuild` (or any compile-fail) harness; adding one for a single case is
64//! more machinery than the case is worth. Reproduce it by hand instead —
65//! append to this file:
66//!
67//! ```ignore
68//! fn _mismatch() {
69//! struct UorbProbe;
70//! impl nros_core::RosMessage for UorbProbe {
71//! const SERIALIZATION_FORMAT_ID = nros_serdes::format::SerializationFormatId::Uorb;
72//! const TYPE_NAME: &'static str = "px4/msg/UorbProbe";
73//! const TYPE_HASH: &'static str = "";
74//! }
75//! assert_message_format::<UorbProbe>();
76//! }
77//! ```
78//!
79//! The probe must be `pub` (or otherwise reachable): a private, never-called
80//! function is dropped before the monomorphisation collector runs, and the
81//! assertion then never instantiates — measured, having first written the
82//! probe private and seen a clean build.
83//!
84//! and `cargo build -p nros-node --features rmw-cffi` reports the `E0080`
85//! above (`cargo check` does not — see above). The runnable half
86//! of the claim is `tests::cdr_and_uorb_are_distinguishable`: if the two
87//! formats ever stopped differing, the compile error would stop being
88//! reachable and every assertion in the tree would pass vacuously.
89
90/// Assert at compile time that `M` is encoded in the format the linked backend
91/// speaks.
92///
93/// Zero-sized and inlined away; the whole effect is the `const {}` block, which
94/// is evaluated when this function is monomorphised for `M`. See the module
95/// docs for the error a mismatch produces and for what it does *not* cover.
96#[inline(always)]
97pub fn assert_message_format<M: nros_core::RosMessage>() {
98 const {
99 assert!(
100 <M as nros_core::RosMessage>::SERIALIZATION_FORMAT_ID.as_u8()
101 == crate::session::IMAGE_SERIALIZATION_FORMAT_ID.as_u8(),
102 "message serialization format does not match the linked backend (RFC-0088)"
103 );
104 }
105}
106
107/// The raw-entity counterpart: assert that `F`, the format a caller states its
108/// already-encoded bytes are in, is the one the linked backend speaks.
109///
110/// `EmbeddedRawPublisher::publish_raw` was documented as taking "raw
111/// CDR-encoded data (must include CDR header)" and checked by nothing. This is
112/// that sentence, as a bound a caller can name.
113///
114/// The raw constructors do **not** take `F` today: Rust forbids a default type
115/// parameter on a function (`invalid_type_param_default`, deny-by-default
116/// future-compat lint), so `create_publisher_raw<F = Cdr>` does not exist and
117/// making them generic without a default would break every existing call site's
118/// inference. A caller that wants the check states it explicitly:
119///
120/// ```
121/// # use nros_node::format_check::assert_raw_format;
122/// assert_raw_format::<nros_serdes::format::Cdr>();
123/// ```
124#[inline(always)]
125pub fn assert_raw_format<F: nros_serdes::format::SerializationFormat>() {
126 const {
127 assert!(
128 <F as nros_serdes::format::SerializationFormat>::ID.as_u8()
129 == crate::session::IMAGE_SERIALIZATION_FORMAT_ID.as_u8(),
130 "raw payload format does not match the linked backend (RFC-0088)"
131 );
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use nros_serdes::format::{Cdr, SerializationFormat, Uorb};
138
139 /// The assertion can only be load-bearing if the two formats it compares
140 /// are actually distinguishable. If this ever held, every
141 /// `assert_message_format` in the tree would pass vacuously.
142 #[test]
143 fn cdr_and_uorb_are_distinguishable() {
144 assert_ne!(
145 <Cdr as SerializationFormat>::ID.as_u8(),
146 <Uorb as SerializationFormat>::ID.as_u8(),
147 "Cdr and Uorb share a discriminant: the compile-time format check \
148 cannot fail, so it checks nothing"
149 );
150 assert_ne!(
151 <Cdr as SerializationFormat>::NAME,
152 <Uorb as SerializationFormat>::NAME
153 );
154 }
155
156 /// The positive case, exercised for real: a CDR message passes the same
157 /// assertion the entity creators run, against this image's backend
158 /// constant. If the constant or the message's declared format disagreed,
159 /// this test would fail to COMPILE — which is the intended failure mode.
160 #[test]
161 fn a_cdr_message_matches_this_image() {
162 struct Probe;
163 impl nros_serdes::Serialize for Probe {
164 fn serialize(
165 &self,
166 _w: &mut nros_serdes::cdr::CdrWriter,
167 ) -> Result<(), nros_serdes::error::SerError> {
168 Ok(())
169 }
170 }
171 impl nros_serdes::Deserialize for Probe {
172 fn deserialize(
173 _r: &mut nros_serdes::cdr::CdrReader,
174 ) -> Result<Self, nros_serdes::error::DeserError> {
175 Ok(Probe)
176 }
177 }
178 impl nros_core::RosMessage for Probe {
179 const TYPE_NAME: &'static str = "test_msgs/msg/Probe";
180 const TYPE_HASH: &'static str = "";
181 }
182
183 super::assert_message_format::<Probe>();
184 super::assert_raw_format::<Cdr>();
185
186 // The image this test runs in is a CDR image; state that, so a future
187 // backend swap is a failing assertion here rather than a silent
188 // inversion of what the two tests above mean.
189 assert_eq!(
190 crate::session::IMAGE_SERIALIZATION_FORMAT_ID.as_u8(),
191 <Cdr as SerializationFormat>::ID.as_u8()
192 );
193 assert_eq!(crate::session::IMAGE_SERIALIZATION_FORMAT, "cdr");
194 }
195}