zerodds_wire_probe/encode.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! One value, one type, the bytes between them — at
4//! `serde_json::Value`.
5//!
6//! The walk is `zerodds_wire_codec::encode`'s and follows the layout
7//! plan, not a second reading of the model. What is here is the value
8//! file's end of it: the three entry points pinned to the representation
9//! the probe reads, and the front end that turns IDL text into the model
10//! they take — which is the probe's business and not the engine's, so
11//! `zerodds-wire-codec` names no parser.
12//!
13//! The three carry a recursion marker for the reason `contract.rs`'s
14//! module note gives: `dds_bounded_recursion` reads a path call by its
15//! last segment, so forwarding to the function of the same name in the
16//! other crate looks like recursion. Depth 1 each.
17
18use serde_json::Value;
19use zerodds_ast::Ast;
20use zerodds_ast_layout::{Encoding, Endianness};
21use zerodds_wire_codec::contract::Consumed;
22use zerodds_wire_codec::value::ProbeRequest;
23
24pub use zerodds_wire_codec::encode::ProbeError;
25
26/// Serialise `values` as `request.type_name`. See
27/// [`zerodds_wire_codec::encode::encode`].
28///
29/// # Errors
30/// [`ProbeError`], always naming the path in the value.
31///
32/// zerodds-lint: recursion-depth 1 — this does not recurse. The one call
33/// it makes is `zerodds_wire_codec::encode::encode`, and the lint reads a
34/// path call by its last segment. See the module note.
35#[inline]
36pub fn encode(ast: &Ast, request: &ProbeRequest, values: &Value) -> Result<Vec<u8>, ProbeError> {
37 zerodds_wire_codec::encode::encode(ast, request, values)
38}
39
40/// As [`encode`], recording which scalars of the value were read. See
41/// [`zerodds_wire_codec::encode::encode_recording`].
42///
43/// # Errors
44/// As [`encode`].
45///
46/// zerodds-lint: recursion-depth 1 — this does not recurse. The one call
47/// it makes is `zerodds_wire_codec::encode::encode_recording`, and the
48/// lint reads a path call by its last segment. See the module note.
49#[inline]
50pub fn encode_recording(
51 ast: &Ast,
52 request: &ProbeRequest,
53 values: &Value,
54 seen: &mut Consumed,
55) -> Result<Vec<u8>, ProbeError> {
56 zerodds_wire_codec::encode::encode_recording(ast, request, values, seen)
57}
58
59/// As [`encode`], and the offsets of the octets that carry no value. See
60/// [`zerodds_wire_codec::encode::encode_with_padding`].
61///
62/// # Errors
63/// As [`encode`].
64///
65/// zerodds-lint: recursion-depth 1 — this does not recurse. The one call
66/// it makes is `zerodds_wire_codec::encode::encode_with_padding`, and the
67/// lint reads a path call by its last segment. See the module note.
68#[inline]
69pub fn encode_with_padding(
70 ast: &Ast,
71 request: &ProbeRequest,
72 values: &Value,
73) -> Result<(Vec<u8>, Vec<bool>), ProbeError> {
74 zerodds_wire_codec::encode::encode_with_padding(ast, request, values)
75}
76
77/// Parse and lower one IDL source into a model with everything
78/// resolved, which is what a probe needs before it can plan anything.
79///
80/// # Errors
81/// [`ProbeError::NoLayout`] with the diagnostics, because a probe over
82/// a model that did not parse would report a wire difference that is
83/// really a syntax error.
84pub fn model_from_idl(source: &str, path: &str) -> Result<Ast, ProbeError> {
85 model_from_idl_in_dialect(source, path, zerodds_ast_layout::Dialect::default())
86}
87
88/// The same model, lowered under one dialect's front-end answers.
89///
90/// Most of a dialect reaches the bytes through the layout plan, which is
91/// built later and takes a `LayoutKey`. One does not: whether a
92/// `@hashid` member advances the sequential counter is settled in
93/// `zerodds_idl_lower::complete`, before any plan exists, and it changes
94/// the member id a peer has to match. DDS-XTypes 1.3 §7.3.1.2.1.1 states
95/// the rule twice and not the same way; Cyclone and Fast DDS take one
96/// reading and RTI the other. See
97/// `Dialect::hashed_id_advances_the_counter`.
98///
99/// # Errors
100/// As [`model_from_idl`].
101pub fn model_from_idl_in_dialect(
102 source: &str,
103 path: &str,
104 dialect: zerodds_ast_layout::Dialect,
105) -> Result<Ast, ProbeError> {
106 let options = zerodds_idl_lower::Options {
107 hashed_id_advances_the_counter: if dialect.hashed_id_advances_the_counter() {
108 zerodds_idl_lower::HashedIdCounter::Advances
109 } else {
110 zerodds_idl_lower::HashedIdCounter::Skipped
111 },
112 hashed_name_spelling: if dialect.hashid_reads_the_escaped_spelling() {
113 zerodds_idl_lower::HashedNameSpelling::AsWritten
114 } else {
115 zerodds_idl_lower::HashedNameSpelling::Stripped
116 },
117 name_of_the_enclosing_scope: if dialect.admits_the_name_of_the_enclosing_scope() {
118 zerodds_idl_lower::NameOfTheEnclosingScope::Admitted
119 } else {
120 zerodds_idl_lower::NameOfTheEnclosingScope::Refused
121 },
122 ..zerodds_idl_lower::Options::default()
123 };
124 model_from_idl_with(source, path, options)
125}
126
127/// The same model, with every front-end option spelled out.
128///
129/// # Errors
130/// As [`model_from_idl`].
131pub fn model_from_idl_with(
132 source: &str,
133 path: &str,
134 options: zerodds_idl_lower::Options,
135) -> Result<Ast, ProbeError> {
136 // Through `zerodds-idl-compose`, which is the one load path — the
137 // same one a `build.rs` and `zerodds-idlc` take. It used to be two
138 // with the same intent, and they drifted: this one ran
139 // `resolve_all_by_name` and checked `is_clean()`, the other ran no
140 // resolve pass at all, so a member whose type lived in another
141 // module reached the generator unresolved and the field never made
142 // it onto the wire. The corpus held the case that would have shown
143 // it (`module-issue41-shape`) and measured the correct answer, on a
144 // path no user took.
145 //
146 // What stays different here is the strictness, not the substance: a
147 // probe refuses a model with any diagnostic, because a wire
148 // difference reported over a file that did not parse is not a wire
149 // difference. A build reports every diagnostic and keeps going,
150 // which is what a caller fixing six declarations needs.
151 let composed = zerodds_idl_compose::compose_source_with(
152 path,
153 source,
154 &zerodds_idl_compose::PathLoader::bare(),
155 zerodds_idl_compose::ComposeOptions {
156 default_extensibility: options.default_extensibility,
157 default_nested: options.default_nested,
158 hashed_id_advances_the_counter: options.hashed_id_advances_the_counter,
159 hashed_name_spelling: options.hashed_name_spelling,
160 name_of_the_enclosing_scope: options.name_of_the_enclosing_scope,
161 defines: Vec::new(),
162 // The clause permits `@key` on a union discriminator
163 // (Table 21, §7.2.2.4.4.4.8), so the probe carries it: the
164 // Rust column is the specification's answer, and a vendor that
165 // refuses the file (measured 2026-08-10: Cyclone's `idlc`,
166 // `rtiddsgen`, `fastddsgen`) shows as a refusal in its own
167 // column. `Refused` stays an option of the front end.
168 key_on_union_discriminator: zerodds_idl_compose::KeyOnUnionDiscriminator::Carried,
169 // A probe reproduces a vendor, so it reads that vendor's
170 // dialect by definition.
171 dialect: zerodds_idl_compose::ComposeOptions::default().dialect,
172 },
173 );
174 if !composed.diagnostics.is_empty() {
175 // Rendered through `Display`, not `Debug`: this string is what a
176 // refusal row in `tests/interop-results/ledger.tsv` records as the
177 // reason, and the ledger holds the tool's own sentence. A struct
178 // dump puts field names and a `Span` into that column, where the
179 // clause reference belongs — and the column is truncated, so the
180 // dump loses the clause and keeps the punctuation.
181 return Err(ProbeError::NoLayout {
182 detail: composed
183 .diagnostics
184 .iter()
185 .map(ToString::to_string)
186 .collect::<Vec<_>>()
187 .join("; "),
188 });
189 }
190 Ok(composed.ast)
191}
192
193/// The bytes as lowercase hex, which is how the fixtures record them.
194#[must_use]
195pub fn hex(bytes: &[u8]) -> String {
196 let mut out = String::with_capacity(bytes.len() * 2);
197 for byte in bytes {
198 out.push_str(&format!("{byte:02x}"));
199 }
200 out
201}
202
203/// Shorthand for the common case: one IDL source, one type, one value.
204///
205/// # Errors
206/// As [`encode`], plus the frontend's own diagnostics.
207pub fn probe(
208 idl: &str,
209 type_name: &str,
210 values: &Value,
211 encoding: Encoding,
212 endianness: Endianness,
213) -> Result<Vec<u8>, ProbeError> {
214 let ast = model_from_idl(idl, "probe.idl")?;
215 encode(
216 &ast,
217 &ProbeRequest::new(type_name, encoding, endianness),
218 values,
219 )
220}