Skip to main content

nros_serdes/
size.rs

1//! Phase 380 W1 — a message's serialized size, computed instead of guessed.
2//!
3//! `NROS_SUBSCRIPTION_BUFFER_SIZE` defaults to 1024 bytes and is a GUESS.
4//! Nothing checks it against the messages an image actually subscribes to, so a
5//! sample that does not fit is dropped AFTER the transport ACKed it, and
6//! `report_dropped_take` can only say "raise the knob" because the runtime does
7//! not know what value would have worked. On a target that knob is static RAM
8//! nobody can spare. Issue 0776 is the gap; this module is the calculator.
9//!
10//! # Why this is not a vtable slot
11//!
12//! Settled in phase-376 W4: nothing about a size bound varies by backend, and
13//! upstream proves it by not varying at all — both `librmw_cyclonedds_cpp.so`
14//! and `librmw_fastrtps_cpp.so` answer `rmw_get_serialized_message_size` with
15//! `RMW_RET_UNSUPPORTED`, and nothing in a Humble install calls it. Upstream can
16//! afford that because its serialized buffer RESIZES; the bound is a hint that
17//! saves a realloc. Ours cannot resize, so the same number is load-bearing here.
18//!
19//! # Thread the offset — do not sum the maxima
20//!
21//! Padding is a function of WHERE a field starts, so a calculation that sums
22//! per-field maxima is wrong the moment a variable-length field shifts what
23//! follows. [`size_bound`] therefore takes `current_alignment` in and returns
24//! the size from there, which is also what makes nested structs compose with no
25//! special case — the same signature rosidl's generated Fast-RTPS support uses
26//! (`max_serialized_size_T(full_bounded, is_plain, current_alignment)`).
27//!
28//! # Agreement with the writer is the only thing that matters
29//!
30//! Every rule below was read out of `CdrWriter`, not out of the CDR spec:
31//! `align()` caps alignment at 4 under XCDR2 and honours 8 under XCDR1;
32//! `begin_dheader()` aligns to 4 and reserves 4 bytes for EVERY struct under
33//! XCDR2 (a generated `serialize` opens with it, so nested structs get one too);
34//! `write_string` writes `len + 1` and then the NUL. A bound that merely looks
35//! right is worthless — see `size_tests.rs`, which checks it against the bytes
36//! the writer actually produced.
37
38use core::ops::ControlFlow;
39
40use crate::{
41    cdr::EncodingVersion,
42    schema::{Field, FieldType},
43};
44
45/// What one walk of a schema can say about size.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct SizeBound {
48    /// Bytes, given the starting offset the walk was handed.
49    pub bytes: usize,
50    /// False once an unbounded `String` / `WString` / `Sequence` is reached —
51    /// `bytes` is then a FLOOR, not a bound, and callers must not size a buffer
52    /// from it.
53    pub bounded: bool,
54    /// No variable-length member anywhere, so the WIRE length is fixed: `bytes`
55    /// is EXACT rather than an upper bound.
56    ///
57    /// **Nothing dispatches on this, by decision** (issues 1369 and 1422). It is
58    /// the walk's exactness property, asserted as such by
59    /// `plain_struct_bound_is_exact` here and by
60    /// `bound_holds_against_the_writer_for_every_generated_type` over the whole
61    /// generated corpus, and it is surfaced as
62    /// [`crate::schema::Message::IS_PLAIN`] / [`is_loan_eligible`] so there is
63    /// ONE definition of "fixed layout" for whoever finally needs one.
64    ///
65    /// The earlier text here said phase-380 W5 "wires this to
66    /// `borrow_loaned_message` / `subscription_supports_in_place`". It does not:
67    /// W5 shipped the definition and left the wiring to whoever owns those slots
68    /// (`docs/roadmap/archived/phase-380-serialized-size-bound.md:228-232`), and
69    /// **issue 0814 step 5 is where the wiring now waits** — deliberately, since
70    /// 0814 measures the loan surface as a cost on three of four backends and
71    /// the natural consumer of this predicate is Cyclone-with-iceoryx, which no
72    /// target we ship can reach. Do not gate the in-place path on it: that path
73    /// hands the callback raw CDR to deserialize, which needs no fixed layout, so
74    /// the flag would only take it away from every type with a `String`.
75    ///
76    /// It is NOT "POD-blit eligible", and a second flag under that name in the
77    /// codegen IR was deleted by issue 1422 for disagreeing with this one on
78    /// `bool`, on nested all-`float64` structs and on mixed alignment. Nor does
79    /// it make the bound version-INDEPENDENT: XCDR2's DHEADER is fixed-width, so
80    /// a plain type stays plain while its bound still differs per encoding
81    /// (`Time` measures 12 under XCDR1 and 16 under XCDR2).
82    pub plain: bool,
83}
84
85/// The encapsulation header the payload crossing our vtable carries: 2 bytes of
86/// representation id + 2 of options.
87///
88/// TOP LEVEL ONLY, not per nested struct — which is why the Cyclone backend
89/// computes `total = paylen + 4`. [`size_bound`] deliberately does not include
90/// it; [`max_serialized_size`] does.
91pub const ENCAPSULATION_HEADER_BYTES: usize = 4;
92
93/// Padding needed to reach `alignment` from `offset`, under `version`.
94///
95/// Mirrors `CdrWriter::align` exactly, including the XCDR2 cap at 4 — a message
96/// containing an `int64` therefore has TWO different bounds, and a single
97/// constant would be silently wrong for one encoding.
98const fn pad_to(offset: usize, alignment: usize, version: EncodingVersion) -> usize {
99    let alignment = match version {
100        EncodingVersion::Xcdr2 => {
101            if alignment > 4 {
102                4
103            } else {
104                alignment
105            }
106        }
107        EncodingVersion::Xcdr1 => alignment,
108    };
109    if alignment == 0 {
110        return 0;
111    }
112    (alignment - (offset % alignment)) % alignment
113}
114
115/// Size of one field, starting at `offset`. Returns the new offset plus the two
116/// flags, folded by the caller.
117const fn field_bound(
118    ty: &FieldType,
119    version: EncodingVersion,
120    offset: usize,
121) -> (usize, bool, bool) {
122    match ty {
123        FieldType::Bool | FieldType::Uint8 | FieldType::Int8 => (offset + 1, true, true),
124        FieldType::Uint16 | FieldType::Int16 => {
125            let o = offset + pad_to(offset, 2, version);
126            (o + 2, true, true)
127        }
128        FieldType::Uint32 | FieldType::Int32 | FieldType::Float32 => {
129            let o = offset + pad_to(offset, 4, version);
130            (o + 4, true, true)
131        }
132        FieldType::Uint64 | FieldType::Int64 | FieldType::Float64 => {
133            let o = offset + pad_to(offset, 8, version);
134            (o + 8, true, true)
135        }
136        // `write_string` writes `len + 1` as the u32 prefix and then the NUL,
137        // so a bound of `n` payload bytes costs `4 + n + 1`.
138        FieldType::BoundedString(n) => {
139            let o = offset + pad_to(offset, 4, version);
140            (o + 4 + *n + 1, true, false)
141        }
142        FieldType::BoundedWString(n) => {
143            let o = offset + pad_to(offset, 4, version);
144            (o + 4 + 2 * *n, true, false)
145        }
146        // No bound exists. Account the length prefix so `bytes` is an honest
147        // floor, and clear `bounded` so nobody sizes a buffer from it.
148        FieldType::String | FieldType::WString | FieldType::Sequence(_) => {
149            let o = offset + pad_to(offset, 4, version);
150            (o + 4, false, false)
151        }
152        FieldType::Array(n, inner) => {
153            let mut o = offset;
154            let mut bounded = true;
155            let mut plain = true;
156            let mut i = 0;
157            while i < *n {
158                let (next, b, p) = field_bound(inner, version, o);
159                o = next;
160                bounded &= b;
161                plain &= p;
162                i += 1;
163            }
164            // A fixed array of plain elements is itself plain; of anything else,
165            // not — the element count is fixed but each element's size is not.
166            (o, bounded, plain)
167        }
168        FieldType::BoundedSequence(n, inner) => {
169            let mut o = offset + pad_to(offset, 4, version);
170            o += 4;
171            let mut bounded = true;
172            let mut i = 0;
173            while i < *n {
174                let (next, b, _) = field_bound(inner, version, o);
175                o = next;
176                bounded &= b;
177                i += 1;
178            }
179            // Never plain: the wire length varies with the actual element count
180            // even though its maximum is known.
181            (o, bounded, false)
182        }
183        FieldType::Nested(nested) => {
184            let inner = size_bound(nested.fields, version, offset);
185            (inner.bytes, inner.bounded, inner.plain)
186        }
187    }
188}
189
190/// Walk `fields` from `current_alignment`, returning the size bound of the
191/// struct they describe.
192///
193/// The returned `bytes` is an ABSOLUTE offset — the position after the last
194/// field, measured from the same origin `current_alignment` was measured from —
195/// so a nested struct composes by being handed the parent's current offset.
196/// Subtract the starting offset if a length is what you want.
197///
198/// Excludes the encapsulation header; see [`max_serialized_size`].
199pub const fn size_bound(
200    fields: &'static [Field],
201    version: EncodingVersion,
202    current_alignment: usize,
203) -> SizeBound {
204    let mut offset = current_alignment;
205    let mut bounded = true;
206    let mut plain = true;
207
208    // XCDR2 delimits EVERY appendable struct, nested ones included: a generated
209    // `serialize` opens with `begin_dheader()`, which aligns to 4 and reserves
210    // 4. Under XCDR1 the call is a no-op. Missing this UNDER-reports, which is
211    // the dangerous direction — an under-reported bound sizes a buffer too
212    // small and reintroduces the very drop this exists to stop.
213    if matches!(version, EncodingVersion::Xcdr2) {
214        offset += pad_to(offset, 4, version);
215        offset += 4;
216        // A DHEADER does not make a struct non-plain: its width is fixed.
217    }
218
219    let mut i = 0;
220    while i < fields.len() {
221        let (next, b, p) = field_bound(&fields[i].ty, version, offset);
222        offset = next;
223        bounded &= b;
224        plain &= p;
225        i += 1;
226    }
227
228    SizeBound {
229        bytes: offset,
230        bounded,
231        plain,
232    }
233}
234
235/// How deep a field path [`first_unbounded`] will name before it stops.
236///
237/// ROS message nesting is shallow in practice (`PoseStamped.header.stamp.sec`
238/// is three), and this walk runs on `no_std` with no allocator, so the path is
239/// a fixed array rather than a `Vec`. A deeper type still reports — the path is
240/// simply truncated, and [`UnboundedField::truncated`] says so, because a path
241/// that silently stops short would point at the wrong member.
242pub const MAX_FIELD_PATH_DEPTH: usize = 8;
243
244/// Which unbounded member kind was reached.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum UnboundedKind {
247    /// `string` with no IDL bound.
248    String,
249    /// `wstring` with no IDL bound.
250    WString,
251    /// `sequence<T>` with no IDL bound.
252    Sequence,
253}
254
255impl UnboundedKind {
256    /// The IDL spelling, for a diagnostic.
257    pub const fn as_str(self) -> &'static str {
258        match self {
259            UnboundedKind::String => "string",
260            UnboundedKind::WString => "wstring",
261            UnboundedKind::Sequence => "sequence<T>",
262        }
263    }
264}
265
266/// The first member that makes a type unbounded, named.
267///
268/// [`max_serialized_size`] answers `None`, which is honest and useless on its
269/// own: a user told their type has no bound cannot act without knowing WHICH
270/// member costs it, and the offender is routinely three structs down in a type
271/// they did not write. This is that answer.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub struct UnboundedField {
274    /// Field names from the root outward; only `depth` entries are meaningful.
275    pub path: [&'static str; MAX_FIELD_PATH_DEPTH],
276    /// How many entries of `path` are set.
277    pub depth: usize,
278    /// True when nesting exceeded [`MAX_FIELD_PATH_DEPTH`] and `path` names a
279    /// prefix rather than the whole route.
280    pub truncated: bool,
281    /// What kind of member it is.
282    pub kind: UnboundedKind,
283}
284
285impl core::fmt::Display for UnboundedField {
286    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
287        for (i, seg) in self.path.iter().take(self.depth).enumerate() {
288            if i > 0 {
289                f.write_str(".")?;
290            }
291            f.write_str(seg)?;
292        }
293        if self.truncated {
294            f.write_str(".…")?;
295        }
296        write!(f, " ({})", self.kind.as_str())
297    }
298}
299
300/// Report EVERY member that makes `fields` unbounded, in declaration order.
301///
302/// [`first_unbounded`] answers "which member costs the bound" one member at a
303/// time, and that is the wrong shape for the caller that actually asks.
304/// phase-403 W0 made an unbounded type a BUILD ERROR, and a stock ROS type is
305/// routinely unbounded in several places at once — `nav_msgs/Odometry` has
306/// `header.frame_id` and `child_frame_id`, and `sensor_msgs/PointCloud2` has
307/// four. Naming only the first turns "bound your types" into cap, rebuild,
308/// discover the next one, repeat, once per member, with a whole codegen run
309/// between each step. One build should name everything that needs a bound.
310///
311/// THE walk. [`first_unbounded`] is expressed on top of this rather than beside
312/// it: two walks of one schema is the shape the sizes-header mirror defect keeps
313/// taking (issues 0088 -> 0268), and "the first thing this reports" is exactly
314/// what "the first unbounded member" means, so there is nothing left for a
315/// second implementation to say.
316///
317/// `visit` returns [`ControlFlow`] so a caller that wants only the first can
318/// stop the walk rather than let it enumerate a whole type and discard all but
319/// one answer. The return value is `Break` iff the visitor broke.
320///
321/// `&mut dyn FnMut` rather than `impl FnMut`: the walk recurses, and a recursive
322/// generic function cannot name the closure type it would instantiate itself
323/// with. `no_std` and allocation-free either way — nothing is collected here,
324/// which is what lets an all-members form exist at all on a target with no
325/// allocator. The COLLECTING is the caller's, and only codegen (`std`) does it.
326pub fn visit_unbounded(
327    fields: &'static [Field],
328    visit: &mut dyn FnMut(UnboundedField) -> ControlFlow<()>,
329) -> ControlFlow<()> {
330    fn walk(
331        fields: &'static [Field],
332        prefix: &mut [&'static str; MAX_FIELD_PATH_DEPTH],
333        depth: usize,
334        visit: &mut dyn FnMut(UnboundedField) -> ControlFlow<()>,
335    ) -> ControlFlow<()> {
336        for field in fields {
337            let truncated = depth >= MAX_FIELD_PATH_DEPTH;
338            if !truncated {
339                prefix[depth] = field.name;
340            }
341            let here = |kind: UnboundedKind| UnboundedField {
342                path: *prefix,
343                depth: if truncated {
344                    MAX_FIELD_PATH_DEPTH
345                } else {
346                    depth + 1
347                },
348                truncated,
349                kind,
350            };
351            match &field.ty {
352                FieldType::String => visit(here(UnboundedKind::String))?,
353                FieldType::WString => visit(here(UnboundedKind::WString))?,
354                FieldType::Sequence(_) => visit(here(UnboundedKind::Sequence))?,
355                // A fixed array or a bounded sequence is bounded only if its
356                // ELEMENT is, and the element can itself be an unbounded
357                // string — `string[4]` has no bound. The element carries no
358                // name of its own, so it reports at the field's own path, and
359                // ONCE: several offenders inside one unnamed element would all
360                // print the same path, which reads as a repeated line rather
361                // than as more information.
362                FieldType::Array(_, inner) | FieldType::BoundedSequence(_, inner) => {
363                    if let Some(kind) = element_unbounded(inner) {
364                        visit(here(kind))?;
365                    }
366                }
367                FieldType::Nested(nested) => {
368                    if truncated {
369                        // Cannot record another segment; report the deepest
370                        // path we can name rather than descending silently.
371                        walk(nested.fields, prefix, depth, &mut |mut u| {
372                            u.truncated = true;
373                            visit(u)
374                        })?;
375                    } else {
376                        walk(nested.fields, prefix, depth + 1, visit)?;
377                    }
378                }
379                _ => {}
380            }
381        }
382        ControlFlow::Continue(())
383    }
384
385    /// An element type is not a field, so it has no name — report only its kind.
386    fn element_unbounded(ty: &'static FieldType) -> Option<UnboundedKind> {
387        match ty {
388            FieldType::String => Some(UnboundedKind::String),
389            FieldType::WString => Some(UnboundedKind::WString),
390            FieldType::Sequence(_) => Some(UnboundedKind::Sequence),
391            FieldType::Array(_, inner) | FieldType::BoundedSequence(_, inner) => {
392                element_unbounded(inner)
393            }
394            FieldType::Nested(nested) => {
395                // A nested struct inside an array: any unbounded member counts.
396                first_unbounded(nested.fields).map(|u| u.kind)
397            }
398            _ => None,
399        }
400    }
401
402    let mut prefix = [""; MAX_FIELD_PATH_DEPTH];
403    walk(fields, &mut prefix, 0, visit)
404}
405
406/// Find the first member that makes `fields` unbounded, if any.
407///
408/// Returns `None` exactly when [`size_bound`] reports `bounded` — the two walk
409/// the same schema by the same rules, so a type that has a bound has no
410/// offender to name and vice versa. That agreement is asserted by test, not
411/// assumed: two walks of one schema is the shape the sizes-header mirror defect
412/// keeps taking (issues 0088 -> 0268), so the second one exists only because it
413/// answers a question the first cannot (WHICH member) and must be checked
414/// against it.
415///
416/// The walk is [`visit_unbounded`]; this is its "stop at the first one" caller.
417/// Reach for [`visit_unbounded`] when the diagnostic should name every member a
418/// user has to fix, which is what codegen wants now that an unbounded type is a
419/// build error (phase-403 W0).
420///
421/// Not `const`: it recurses through `&'static NestedType`, and the array
422/// bookkeeping is not worth expressing in a const walk when every caller is a
423/// diagnostic path.
424pub fn first_unbounded(fields: &'static [Field]) -> Option<UnboundedField> {
425    let mut found = None;
426    let _ = visit_unbounded(fields, &mut |u| {
427        found = Some(u);
428        ControlFlow::Break(())
429    });
430    found
431}
432
433/// The whole payload a publisher hands the transport: encapsulation header plus
434/// the struct's body, starting from offset 0.
435///
436/// `None` when the type is unbounded — the honest answer, and the one that keeps
437/// a caller from sizing a buffer off a floor. Reach for `serialized_size(&self)`
438/// (phase-380 W3) when an unbounded type still needs a number for THIS message.
439pub const fn max_serialized_size(
440    fields: &'static [Field],
441    version: EncodingVersion,
442) -> Option<usize> {
443    // The body's offsets are measured from after the encapsulation header —
444    // that is where `CdrWriter`'s `origin` sits — so the walk starts at 0 and
445    // the header is added once, at the top.
446    let bound = size_bound(fields, version, 0);
447    if bound.bounded {
448        Some(ENCAPSULATION_HEADER_BYTES + bound.bytes)
449    } else {
450        None
451    }
452}
453
454/// Phase 380 W3 — the EXACT serialized size of THIS message.
455///
456/// Two questions get asked about size and they are not the same one:
457///
458/// | question | asked by | this module |
459/// | --- | --- | --- |
460/// | how large can this TYPE ever be? | build-time buffer sizing | [`max_serialized_size`] |
461/// | how large is THIS message? | a publisher before publishing; a drop report | `serialized_size` |
462///
463/// The second is the only honest answer for an unbounded type, where the first
464/// is `None` — a `String` field has no maximum, but the string in hand has a
465/// length. It is what lets a drop report name the number that would have worked
466/// instead of saying "raise the knob".
467///
468/// Exact by construction: it runs the REAL writer with its stores disabled
469/// (`CdrWriter::measuring`), so the count comes from the same code that emits
470/// the bytes. A second walk of the schema could not see the actual string
471/// lengths and sequence counts, and would be a second implementation to keep in
472/// step besides.
473///
474/// Includes the encapsulation header, matching [`max_serialized_size`], so the
475/// two are directly comparable — which is the whole point at a call site
476/// deciding whether a message fits.
477pub fn serialized_size<T: crate::traits::Serialize>(
478    value: &T,
479    version: EncodingVersion,
480) -> Result<usize, crate::error::SerError> {
481    let mut w = crate::cdr::CdrWriter::measuring(&mut [], version);
482    value.serialize(&mut w)?;
483    Ok(ENCAPSULATION_HEADER_BYTES + w.position())
484}
485
486/// Phase 380 W4 — does a receive buffer of `rx_buf` bytes fit every message of
487/// this type?
488///
489/// `true` when the type is bounded and the bound fits. **`false` when the type
490/// is UNBOUNDED**, deliberately: no finite buffer fits a `String`, so the
491/// honest answer to "is this guaranteed to fit" is no. A caller that wants
492/// "fits unless proven otherwise" is asking a different question and should
493/// look at [`serialized_size`] per message.
494///
495/// Const, so a call site can put it in a `const { assert!(...) }` and turn a
496/// runtime drop into a build error:
497///
498/// ```ignore
499/// const { assert!(buffer_fits::<Odometry>(RX_BUF, EncodingVersion::Xcdr1)) };
500/// ```
501///
502/// # Why this is not simply a bound on `Subscription`
503///
504/// `Subscription<M, RX_BUF>` bounds `M: RosMessage`, which is a DIFFERENT trait
505/// from [`crate::schema::Message`] — and measured 2026-08-26, hand-written
506/// `RosMessage` types with no schema DO exist (`nros-core/src/service.rs`, the
507/// component-runtime tests). Tightening that bound would break them, so the
508/// assertion is opt-in at sites where the schema is known rather than universal
509/// at a site where it is not. See the phase doc for what closing that gap needs.
510pub const fn buffer_fits<M: crate::schema::Message>(
511    rx_buf: usize,
512    version: EncodingVersion,
513) -> bool {
514    let bound = match version {
515        EncodingVersion::Xcdr1 => M::MAX_SERIALIZED_SIZE_XCDR1,
516        EncodingVersion::Xcdr2 => M::MAX_SERIALIZED_SIZE_XCDR2,
517    };
518    match bound {
519        Some(n) => rx_buf >= n,
520        None => false,
521    }
522}
523
524/// Phase 392 W3a — the type's own receive-buffer bound, or `None` when it has
525/// none.
526///
527/// The VALUE behind [`bound_fits`]'s predicate. A subscription that knows its
528/// type's bound can be routed to a size class instead of forcing the GLOBAL
529/// buffer knob up: `NROS_SUBSCRIBER_BUFFER_SIZE` multiplies across
530/// `MAX_SUBSCRIBERS x RING_DEPTH`, so raising it from 1024 to 4096 for one
531/// 4 KiB topic costs 98,304 bytes, while the large class that topic belongs in
532/// is already reserved and empty.
533///
534/// Takes the larger of the two encodings, for the same reason `bound_fits`
535/// does: the peer picks the encoding at runtime, so sizing from XCDR1 alone is
536/// a trap.
537///
538/// `None` means "no bound EXISTS", never "unknown" — phase 380 is explicit that
539/// a buffer must not be sized from a fallback. A caller routing by size class
540/// must treat `None` as "keep the default", not as "assume small".
541pub const fn max_serialized_bound<M: crate::schema::Message>() -> Option<usize> {
542    match (M::MAX_SERIALIZED_SIZE_XCDR1, M::MAX_SERIALIZED_SIZE_XCDR2) {
543        (Some(x1), Some(x2)) => Some(if x1 > x2 { x1 } else { x2 }),
544        // One encoding unbounded makes the type unbounded on the wire, because
545        // the peer chooses. Not `Some(the_other)`.
546        _ => None,
547    }
548}
549
550/// Phase 380 W4 — the BUILD-ASSERTION predicate: `false` only when the type is
551/// provably too large for `rx_buf`.
552///
553/// Distinct from [`buffer_fits`], and the difference is the whole reason both
554/// exist:
555///
556/// * `buffer_fits` answers "is this GUARANTEED to fit", so an unbounded type is
557///   `false` — no finite buffer fits a `String`.
558/// * this answers "can we PROVE it will not fit", so an unbounded type is
559///   `true` — there is nothing to check, and failing the build for every
560///   `std_msgs/String` subscription would be absurd.
561///
562/// Asserting `buffer_fits` at a subscription site was my first attempt and
563/// would have refused the most common message in ROS. Kept as two named
564/// functions rather than one with a flag, because the wrong one is silently
565/// plausible at either call site.
566///
567/// Checks BOTH encodings and takes the larger: the encoding is a runtime
568/// property of the peer, so a buffer sized from XCDR1 alone is a trap — XCDR2
569/// adds a DHEADER per struct and is the bigger of the two for nested types.
570pub const fn bound_fits<M: crate::schema::Message>(rx_buf: usize) -> bool {
571    let x1 = match M::MAX_SERIALIZED_SIZE_XCDR1 {
572        Some(n) => n,
573        None => return true, // unbounded: nothing to prove
574    };
575    let x2 = match M::MAX_SERIALIZED_SIZE_XCDR2 {
576        Some(n) => n,
577        None => return true,
578    };
579    let largest = if x1 > x2 { x1 } else { x2 };
580    rx_buf >= largest
581}
582
583/// Phase 380 W5 — is this type loan-eligible?
584///
585/// A loan hands out a pointer into the transport's own memory, which is sound
586/// only when the layout is fixed: no length prefix to chase, no variable
587/// member. That is exactly [`SizeBound::plain`], so the answer falls out of W1
588/// rather than becoming a second notion of "fixed layout" maintained by hand —
589/// which is the drift `borrow_loaned_message` and
590/// `subscription_supports_in_place` would otherwise each grow their own version
591/// of.
592///
593/// **It has no non-test caller, and that is the decision rather than an
594/// oversight** (issue 1422; the same finding as issue 1369). Its natural
595/// consumer is a TYPED loan, and issue 0814 recommends not building one now:
596/// the byte-span loan surface it would sit beside is measured as strictly worse
597/// than `publish_raw` on three of four backends, and the one place `IS_PLAIN`
598/// matches a real gate is Cyclone's own `fixed_size` check, which loans only
599/// under iceoryx. **0814 step 5 is the consumer**, if and when it exists. The
600/// function is kept — not inlined into that future caller — because its whole
601/// job is to be the one place the predicate is spelled; two spellings is the
602/// defect issue 1422 deleted from the codegen IR.
603pub const fn is_loan_eligible<M: crate::schema::Message>() -> bool {
604    M::IS_PLAIN
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use crate::{
611        cdr::CdrWriter,
612        schema::{Field, NestedType},
613    };
614
615    const fn f(name: &'static str, ty: FieldType) -> Field {
616        Field {
617            name,
618            ty,
619            offset: 0,
620        }
621    }
622
623    // ========================================================================
624    // issue 0896 layer 0 — naming the member that costs the bound
625    // ========================================================================
626
627    /// The two walks must agree. `first_unbounded` is a SECOND walk of the same
628    /// schema, which is the shape the sizes-header mirror defect keeps taking
629    /// (0088 -> 0268), so it earns its existence only by answering a question
630    /// `size_bound` cannot — and only while it never disagrees about the
631    /// question they share.
632    fn agree(fields: &'static [Field]) {
633        for version in [EncodingVersion::Xcdr1, EncodingVersion::Xcdr2] {
634            let bounded = size_bound(fields, version, 0).bounded;
635            let named = first_unbounded(fields);
636            assert_eq!(
637                bounded,
638                named.is_none(),
639                "size_bound says bounded={bounded} but first_unbounded says \
640                 {named:?} ({version:?})"
641            );
642        }
643    }
644
645    #[test]
646    fn a_bounded_type_has_no_offender_to_name() {
647        static FIELDS: &[Field] = &[
648            f("x", FieldType::Uint32),
649            f("s", FieldType::BoundedString(8)),
650        ];
651        agree(FIELDS);
652    }
653
654    #[test]
655    fn an_unbounded_string_is_named() {
656        static FIELDS: &[Field] = &[f("flag", FieldType::Bool), f("label", FieldType::String)];
657        agree(FIELDS);
658        let u = first_unbounded(FIELDS).unwrap();
659        assert_eq!(u.kind, UnboundedKind::String);
660        assert_eq!(&u.path[..u.depth], &["label"]);
661    }
662
663    /// The offender is usually not at the top level — this is the whole reason
664    /// the path exists rather than a bare field name.
665    #[test]
666    fn a_nested_offender_reports_its_full_path() {
667        static INNER: &[Field] = &[f("sec", FieldType::Int32), f("frame_id", FieldType::String)];
668        static INNER_TY: NestedType = NestedType {
669            type_name: "std_msgs/msg/Header",
670            fields: INNER,
671        };
672        static FIELDS: &[Field] = &[f("header", FieldType::Nested(&INNER_TY))];
673        agree(FIELDS);
674        let u = first_unbounded(FIELDS).unwrap();
675        assert_eq!(&u.path[..u.depth], &["header", "frame_id"]);
676    }
677
678    /// `string[4]` is a FIXED array of an UNBOUNDED element: the count is
679    /// known and the bound is not. Reported at the field's own path, because
680    /// an element has no name of its own.
681    #[test]
682    fn a_fixed_array_of_unbounded_elements_is_unbounded() {
683        static ELEM: FieldType = FieldType::String;
684        static FIELDS: &[Field] = &[f("names", FieldType::Array(4, &ELEM))];
685        agree(FIELDS);
686        let u = first_unbounded(FIELDS).unwrap();
687        assert_eq!(&u.path[..u.depth], &["names"]);
688        assert_eq!(u.kind, UnboundedKind::String);
689    }
690
691    /// A bounded sequence of bounded elements IS bounded — the easy way to get
692    /// this wrong is to treat any sequence as unbounded.
693    #[test]
694    fn a_bounded_sequence_of_bounded_elements_is_bounded() {
695        static ELEM: FieldType = FieldType::Uint16;
696        static FIELDS: &[Field] = &[f("ranges", FieldType::BoundedSequence(16, &ELEM))];
697        agree(FIELDS);
698        assert!(first_unbounded(FIELDS).is_none());
699    }
700
701    #[test]
702    fn the_first_offender_wins_so_the_message_names_one_thing() {
703        static FIELDS: &[Field] = &[
704            f("a", FieldType::String),
705            f("b", FieldType::Sequence(&FieldType::Uint8)),
706        ];
707        let u = first_unbounded(FIELDS).unwrap();
708        assert_eq!(&u.path[..u.depth], &["a"]);
709    }
710
711    /// A stack-only `core::fmt::Write` sink.
712    ///
713    /// `alloc::format!` would be shorter and would test the wrong build:
714    /// `Display` here exists FOR the `no_std` diagnostic path, so the test runs
715    /// where that path runs. Silently drops overflow — the assertion below
716    /// catches a truncated result either way.
717    struct Buf {
718        bytes: [u8; 128],
719        len: usize,
720    }
721
722    impl core::fmt::Write for Buf {
723        fn write_str(&mut self, s: &str) -> core::fmt::Result {
724            for b in s.as_bytes() {
725                if self.len < self.bytes.len() {
726                    self.bytes[self.len] = *b;
727                    self.len += 1;
728                }
729            }
730            Ok(())
731        }
732    }
733
734    #[test]
735    fn the_display_form_reads_as_a_path_and_a_kind() {
736        use core::fmt::Write;
737        static INNER: &[Field] = &[f("frame_id", FieldType::String)];
738        static INNER_TY: NestedType = NestedType {
739            type_name: "std_msgs/msg/Header",
740            fields: INNER,
741        };
742        static FIELDS: &[Field] = &[f("header", FieldType::Nested(&INNER_TY))];
743        let u = first_unbounded(FIELDS).unwrap();
744
745        let mut buf = Buf {
746            bytes: [0; 128],
747            len: 0,
748        };
749        write!(buf, "{u}").unwrap();
750        assert_eq!(
751            core::str::from_utf8(&buf.bytes[..buf.len]).unwrap(),
752            "header.frame_id (string)"
753        );
754    }
755
756    // ========================================================================
757    // phase-403 W0 — naming EVERY member, not the first
758    // ========================================================================
759
760    /// Collect a whole type's offenders. `heapless` rather than `Vec` because
761    /// this crate is `no_std`; that is also the point of the visitor shape.
762    fn all(fields: &'static [Field]) -> heapless::Vec<UnboundedField, 16> {
763        let mut out = heapless::Vec::new();
764        let _ = visit_unbounded(fields, &mut |u| {
765            let _ = out.push(u);
766            ControlFlow::Continue(())
767        });
768        out
769    }
770
771    fn names(fields: &'static [Field]) -> heapless::Vec<&'static str, 16> {
772        let mut out = heapless::Vec::new();
773        for u in all(fields) {
774            let _ = out.push(u.path[u.depth - 1]);
775        }
776        out
777    }
778
779    /// An unbounded type is a build error now, and a stock ROS type is unbounded
780    /// in several places at once, so one build has to name all of them —
781    /// otherwise bounding a package is one cap and one full codegen run per
782    /// member.
783    #[test]
784    fn every_unbounded_member_is_visited_in_declaration_order() {
785        static FIELDS: &[Field] = &[
786            f("a", FieldType::String),
787            f("keep", FieldType::Int32),
788            f("b", FieldType::Sequence(&FieldType::Int64)),
789            f("c", FieldType::WString),
790        ];
791        assert_eq!(names(FIELDS).as_slice(), &["a", "b", "c"]);
792    }
793
794    /// Nested members are visited too, and a bounded sibling does not stop the
795    /// walk from continuing past the struct that contained one.
796    #[test]
797    fn the_walk_continues_past_a_nested_offender_to_its_siblings() {
798        static INNER: &[Field] = &[f("frame_id", FieldType::String)];
799        static INNER_TY: NestedType = NestedType {
800            type_name: "std_msgs/msg/Header",
801            fields: INNER,
802        };
803        static FIELDS: &[Field] = &[
804            f("header", FieldType::Nested(&INNER_TY)),
805            f("child_frame_id", FieldType::String),
806        ];
807        assert_eq!(names(FIELDS).as_slice(), &["frame_id", "child_frame_id"]);
808        // The path, not just the leaf name, so a diagnostic can be acted on.
809        assert_eq!(all(FIELDS)[0].path[0], "header");
810        assert_eq!(all(FIELDS)[0].depth, 2);
811    }
812
813    /// `first_unbounded` is now expressed on top of this walk, so it must still
814    /// answer exactly what the walk reports first — and must still stop, rather
815    /// than enumerating a type and discarding all but one answer.
816    #[test]
817    fn the_first_offender_is_the_first_one_visited() {
818        static INNER: &[Field] = &[f("frame_id", FieldType::String)];
819        static INNER_TY: NestedType = NestedType {
820            type_name: "std_msgs/msg/Header",
821            fields: INNER,
822        };
823        static FLAT: &[Field] = &[f("a", FieldType::String), f("b", FieldType::String)];
824        static NESTED: &[Field] = &[
825            f("header", FieldType::Nested(&INNER_TY)),
826            f("tail", FieldType::String),
827        ];
828        static CLEAN: &[Field] = &[f("only", FieldType::Int32)];
829        for fields in [FLAT, NESTED, CLEAN] {
830            assert_eq!(first_unbounded(fields), all(fields).first().copied());
831        }
832    }
833
834    /// A visitor that breaks stops the walk where it broke — which is the
835    /// mechanism `first_unbounded` uses, asserted rather than assumed.
836    #[test]
837    fn a_visitor_that_breaks_stops_the_walk() {
838        static FIELDS: &[Field] = &[
839            f("a", FieldType::String),
840            f("b", FieldType::String),
841            f("c", FieldType::String),
842        ];
843        let mut seen = 0usize;
844        let flow = visit_unbounded(FIELDS, &mut |_| {
845            seen += 1;
846            if seen == 2 {
847                ControlFlow::Break(())
848            } else {
849                ControlFlow::Continue(())
850            }
851        });
852        assert_eq!(seen, 2);
853        assert_eq!(flow, ControlFlow::Break(()));
854    }
855
856    /// A bounded type has nothing to report, from either form.
857    #[test]
858    fn a_bounded_type_visits_nothing() {
859        static FIELDS: &[Field] = &[
860            f("a", FieldType::BoundedString(8)),
861            f("b", FieldType::Int32),
862        ];
863        assert!(all(FIELDS).is_empty());
864        assert!(first_unbounded(FIELDS).is_none());
865    }
866
867    /// Serialize a MAXIMAL instance of a schema with the real `CdrWriter`, and
868    /// return the byte length the writer produced (header included).
869    ///
870    /// This is the whole point of the test module: a bound that is merely
871    /// self-consistent proves nothing. Every assertion below compares
872    /// [`max_serialized_size`] against bytes this function actually wrote.
873    fn write_maximal(fields: &[Field], version: EncodingVersion, buf: &mut [u8]) -> usize {
874        let mut w = match version {
875            EncodingVersion::Xcdr1 => CdrWriter::new_with_header(buf).unwrap(),
876            EncodingVersion::Xcdr2 => CdrWriter::new_with_header_xcdr2(buf).unwrap(),
877        };
878        let dh = w.begin_dheader().unwrap();
879        write_fields(&mut w, fields);
880        w.end_dheader(dh).unwrap();
881        w.position()
882    }
883
884    /// Phase 392 W3a — `max_serialized_bound` is what routes a subscription to a
885    /// size class, so the two ways it can be wrong both cost real RAM or real
886    /// truncation: reporting the smaller encoding under-sizes the block the peer
887    /// may actually fill, and inventing a number for an unbounded type sizes a
888    /// buffer from a fallback, which phase 380 forbids in as many words.
889    struct BoundedMsg;
890    impl crate::schema::Message for BoundedMsg {
891        const TYPE_NAME: &'static str = "test/msg/BoundedMsg";
892        const FIELDS: &'static [Field] = &[
893            f("a", FieldType::Uint8),
894            f("b", FieldType::Uint64),
895            f("c", FieldType::Uint8),
896        ];
897    }
898
899    struct UnboundedMsg;
900    impl crate::schema::Message for UnboundedMsg {
901        const TYPE_NAME: &'static str = "test/msg/UnboundedMsg";
902        const FIELDS: &'static [Field] = &[f("s", FieldType::String)];
903    }
904
905    #[test]
906    fn max_serialized_bound_takes_the_larger_encoding() {
907        let x1 = <BoundedMsg as crate::schema::Message>::MAX_SERIALIZED_SIZE_XCDR1
908            .expect("bounded in xcdr1");
909        let x2 = <BoundedMsg as crate::schema::Message>::MAX_SERIALIZED_SIZE_XCDR2
910            .expect("bounded in xcdr2");
911        let got = max_serialized_bound::<BoundedMsg>().expect("bounded type has a bound");
912        assert_eq!(
913            got,
914            x1.max(x2),
915            "must take the LARGER encoding — the peer picks it at runtime, so \
916             sizing from one alone is the trap phase 380 documents"
917        );
918        assert!(
919            bound_fits::<BoundedMsg>(got),
920            "the value must satisfy the predicate it is derived from"
921        );
922        assert!(
923            !bound_fits::<BoundedMsg>(got - 1),
924            "one byte under the bound must NOT fit, or the value is not tight"
925        );
926    }
927
928    #[test]
929    fn max_serialized_bound_is_none_for_an_unbounded_type() {
930        assert_eq!(
931            max_serialized_bound::<UnboundedMsg>(),
932            None,
933            "an unbounded type has NO bound; returning a number here would let a \
934             caller size a buffer from a fallback"
935        );
936        assert!(
937            bound_fits::<UnboundedMsg>(1),
938            "unbounded still passes the BUILD assertion — nothing is provable"
939        );
940    }
941
942    fn write_fields(w: &mut CdrWriter<'_>, fields: &[Field]) {
943        for field in fields {
944            write_one(w, &field.ty);
945        }
946    }
947
948    fn write_one(w: &mut CdrWriter<'_>, ty: &FieldType) {
949        match ty {
950            FieldType::Bool => w.write_bool(true).unwrap(),
951            FieldType::Uint8 => w.write_u8(0xAB).unwrap(),
952            FieldType::Int8 => w.write_i8(-1).unwrap(),
953            FieldType::Uint16 => w.write_u16(0xBEEF).unwrap(),
954            FieldType::Int16 => w.write_i16(-2).unwrap(),
955            FieldType::Uint32 => w.write_u32(0xDEADBEEF).unwrap(),
956            FieldType::Int32 => w.write_i32(-3).unwrap(),
957            FieldType::Float32 => w.write_f32(1.5).unwrap(),
958            FieldType::Uint64 => w.write_u64(u64::MAX).unwrap(),
959            FieldType::Int64 => w.write_i64(-4).unwrap(),
960            FieldType::Float64 => w.write_f64(2.5).unwrap(),
961            // Maximal = exactly `n` payload bytes, which is what the bound
962            // claims room for.
963            FieldType::BoundedString(n) => {
964                let s = "x".repeat(*n);
965                w.write_string(&s).unwrap()
966            }
967            FieldType::String => w.write_string("").unwrap(),
968            FieldType::Array(n, inner) => {
969                for _ in 0..*n {
970                    write_one(w, inner);
971                }
972            }
973            FieldType::BoundedSequence(n, inner) => {
974                w.write_u32(*n as u32).unwrap();
975                for _ in 0..*n {
976                    write_one(w, inner);
977                }
978            }
979            FieldType::Nested(nested) => {
980                let dh = w.begin_dheader().unwrap();
981                write_fields(w, nested.fields);
982                w.end_dheader(dh).unwrap();
983            }
984            other => panic!("test writer has no maximal value for {other:?}"),
985        }
986    }
987
988    /// The core property, in both encodings: the computed bound is an upper
989    /// bound on what the writer produces, and an EXACT one when `plain`.
990    fn assert_agrees(fields: &'static [Field], version: EncodingVersion) {
991        let mut buf = [0u8; 4096];
992        let actual = write_maximal(fields, version, &mut buf);
993        let bound = max_serialized_size(fields, version).expect("these fixtures are all bounded");
994        assert!(
995            actual <= bound,
996            "{version:?}: writer produced {actual} bytes, bound claimed {bound} — \
997             an UNDER-reported bound sizes a buffer too small, which is the drop \
998             this module exists to stop"
999        );
1000        if size_bound(fields, version, 0).plain {
1001            assert_eq!(
1002                actual, bound,
1003                "{version:?}: a plain type's bound must be EXACT, not merely an \
1004                 upper bound"
1005            );
1006        }
1007    }
1008
1009    fn both(fields: &'static [Field]) {
1010        assert_agrees(fields, EncodingVersion::Xcdr1);
1011        assert_agrees(fields, EncodingVersion::Xcdr2);
1012    }
1013
1014    // `builtin_interfaces/Time` — the plain case, and the one whose bound must
1015    // be exact.
1016    static TIME: &[Field] = &[f("sec", FieldType::Int32), f("nanosec", FieldType::Uint32)];
1017
1018    #[test]
1019    fn plain_struct_bound_is_exact() {
1020        both(TIME);
1021        assert!(size_bound(TIME, EncodingVersion::Xcdr1, 0).plain);
1022    }
1023
1024    /// Issue 1422 — the FLAG is version-independent; the bound it qualifies is
1025    /// NOT, and conflating the two is how "plain" gets read as "one number
1026    /// serves both encodings".
1027    ///
1028    /// `plain` answers "is there a variable-length member", which no encoding
1029    /// changes, so [`crate::schema::Message::IS_PLAIN`] is one const rather than
1030    /// two. XCDR2 still adds a fixed-width DHEADER per struct, so the exact size
1031    /// differs — and both of those facts have to hold for `plain` to mean
1032    /// "`bytes` is exact" rather than "`bytes` is the same everywhere". Written
1033    /// as a test because the issue that asked for a consumer for this flag
1034    /// described its one pinning assertion as version-INDEPENDENCE of the bound,
1035    /// which is not a property this walk has.
1036    #[test]
1037    fn plainness_is_version_independent_and_the_exact_size_is_not() {
1038        let x1 = size_bound(TIME, EncodingVersion::Xcdr1, 0);
1039        let x2 = size_bound(TIME, EncodingVersion::Xcdr2, 0);
1040        assert_eq!(
1041            x1.plain, x2.plain,
1042            "plainness cannot depend on the encoding"
1043        );
1044        assert!(x1.plain);
1045        assert_eq!(x1.bytes, 8, "two 4-byte scalars");
1046        assert_eq!(x2.bytes, 12, "same, plus the 4-byte DHEADER");
1047        assert_ne!(
1048            x1.bytes, x2.bytes,
1049            "a plain type still has two bounds — one constant would be wrong \
1050             for one encoding (issue 0776)"
1051        );
1052    }
1053
1054    /// The defect issue 0776 calls out first: a message containing an `int64`
1055    /// has TWO different bounds, because `CdrWriter::align` honours 8 under
1056    /// XCDR1 and caps at 4 under XCDR2. A single constant is silently wrong for
1057    /// one of them.
1058    #[test]
1059    fn eight_byte_alignment_differs_by_encoding() {
1060        // Two `int64`s, deliberately: with only ONE the two encodings come out
1061        // EQUAL, and that is a coincidence rather than a missing cap. For
1062        // `[u8, i64]` XCDR1 pads 7 before the i64 while XCDR2 caps alignment at
1063        // 4 and pads 3 — saving exactly the 4 bytes its DHEADER costs, so both
1064        // totals are 20. A test built on that single field would have "passed"
1065        // while asserting nothing, and would keep passing if the cap were
1066        // deleted. Repeating the pattern breaks the tie: the padding saving
1067        // scales with the number of 8-byte members, the DHEADER does not.
1068        static S: &[Field] = &[
1069            f("flag", FieldType::Uint8),
1070            f("big", FieldType::Int64),
1071            f("flag2", FieldType::Uint8),
1072            f("big2", FieldType::Int64),
1073        ];
1074        both(S);
1075        let x1 = max_serialized_size(S, EncodingVersion::Xcdr1).unwrap();
1076        let x2 = max_serialized_size(S, EncodingVersion::Xcdr2).unwrap();
1077        assert_ne!(
1078            x1, x2,
1079            "8-byte primitives must pad differently under the two encodings; if \
1080             these agree the alignment cap is not being applied"
1081        );
1082        assert!(
1083            x1 > x2,
1084            "XCDR1 aligns 8-byte primitives to 8 and so must be the larger of \
1085             the two here ({x1} vs {x2})"
1086        );
1087    }
1088
1089    /// The coincidence above, pinned so nobody "simplifies" the test back into
1090    /// it: for this one layout the encodings genuinely agree, and a bound that
1091    /// reported one number for both would look correct here and nowhere else.
1092    #[test]
1093    fn a_single_int64_makes_the_two_encodings_agree_by_coincidence() {
1094        static S: &[Field] = &[f("flag", FieldType::Uint8), f("big", FieldType::Int64)];
1095        both(S);
1096        assert_eq!(
1097            max_serialized_size(S, EncodingVersion::Xcdr1),
1098            max_serialized_size(S, EncodingVersion::Xcdr2),
1099            "XCDR2's DHEADER (+4) exactly offsets the padding its alignment cap \
1100             saves (-4) for this shape"
1101        );
1102    }
1103
1104    /// Padding depends on WHERE a field starts, so summing per-field maxima is
1105    /// wrong. Same fields, different order, different size.
1106    #[test]
1107    fn offset_is_threaded_not_summed() {
1108        static A: &[Field] = &[f("a", FieldType::Uint8), f("b", FieldType::Uint32)];
1109        static B: &[Field] = &[f("b", FieldType::Uint32), f("a", FieldType::Uint8)];
1110        both(A);
1111        both(B);
1112        assert_ne!(
1113            max_serialized_size(A, EncodingVersion::Xcdr1),
1114            max_serialized_size(B, EncodingVersion::Xcdr1),
1115            "u8,u32 pads and u32,u8 does not — a sum of maxima cannot tell them apart"
1116        );
1117    }
1118
1119    #[test]
1120    fn bounded_string_and_sequence_are_bounded_but_not_plain() {
1121        static S: &[Field] = &[
1122            f("name", FieldType::BoundedString(8)),
1123            f("vals", FieldType::BoundedSequence(3, &FieldType::Uint32)),
1124        ];
1125        both(S);
1126        let b = size_bound(S, EncodingVersion::Xcdr1, 0);
1127        assert!(b.bounded);
1128        assert!(!b.plain, "variable-length members are not loan-eligible");
1129    }
1130
1131    #[test]
1132    fn nested_struct_composes_at_the_parent_offset() {
1133        static NESTED: NestedType = NestedType {
1134            type_name: "builtin_interfaces/msg/Time",
1135            fields: TIME,
1136        };
1137        static S: &[Field] = &[
1138            f("flag", FieldType::Uint8),
1139            f("stamp", FieldType::Nested(&NESTED)),
1140        ];
1141        both(S);
1142    }
1143
1144    /// An unbounded member makes the type unbounded, and `max_serialized_size`
1145    /// must answer `None` rather than a floor someone could size a buffer from.
1146    #[test]
1147    fn unbounded_member_yields_none() {
1148        static S: &[Field] = &[
1149            f(
1150                "stamp",
1151                FieldType::Nested(&NestedType {
1152                    type_name: "builtin_interfaces/msg/Time",
1153                    fields: TIME,
1154                }),
1155            ),
1156            f("frame_id", FieldType::String),
1157        ];
1158        assert_eq!(max_serialized_size(S, EncodingVersion::Xcdr1), None);
1159        assert!(!size_bound(S, EncodingVersion::Xcdr1, 0).bounded);
1160        assert!(!size_bound(S, EncodingVersion::Xcdr1, 0).plain);
1161    }
1162
1163    /// XCDR2 delimits every struct, nested ones included. Missing the nested
1164    /// DHEADER under-reports — the dangerous direction.
1165    #[test]
1166    fn xcdr2_counts_a_dheader_per_struct() {
1167        static INNER: NestedType = NestedType {
1168            type_name: "t/Inner",
1169            fields: TIME,
1170        };
1171        static FLAT: &[Field] = &[f("sec", FieldType::Int32), f("nanosec", FieldType::Uint32)];
1172        static WRAPPED: &[Field] = &[f("inner", FieldType::Nested(&INNER))];
1173        both(WRAPPED);
1174        let flat = max_serialized_size(FLAT, EncodingVersion::Xcdr2).unwrap();
1175        let wrapped = max_serialized_size(WRAPPED, EncodingVersion::Xcdr2).unwrap();
1176        assert_eq!(
1177            wrapped,
1178            flat + 4,
1179            "the nested struct's own DHEADER must be counted under XCDR2"
1180        );
1181        // ...and must NOT be under XCDR1, where begin_dheader is a no-op.
1182        assert_eq!(
1183            max_serialized_size(WRAPPED, EncodingVersion::Xcdr1).unwrap(),
1184            max_serialized_size(FLAT, EncodingVersion::Xcdr1).unwrap(),
1185            "XCDR1 has no DHEADER; wrapping must cost nothing"
1186        );
1187    }
1188}