ridl_ir/lib.rs
1//! RIDL intermediate representation.
2//!
3//! The v2 schema (`proto/ridl/ir/v2/ir.proto`) is compiled from its protobuf
4//! source by `build.rs` (protox + prost-build, ADR-0006 decision 3) and
5//! exposed as [`v2`]. v2 is the typl surface plus the ridl interaction layer
6//! (ridl language reference §3–§14) with exact decimal values — every numeric
7//! value is a canonical decimal string, never a floating-point field (ADR-0007
8//! decision 9, ADR-0008 decision 12). The v1 schema was removed when its last
9//! consumer moved to v2 (task 6 of the E2 plan), mirroring the E1 v0→v1
10//! retirement.
11
12pub mod v2 {
13 //! IR v2 — the typl surface plus the ridl interaction layer (ridl
14 //! language reference §3–§14) with exact decimal values (ADR-0007
15 //! decision 9, ADR-0008 decision 12).
16
17 include!(concat!(env!("OUT_DIR"), "/ridl.ir.v2.rs"));
18 // The canonical protobuf JSON serde impls, generated by pbjson-build in
19 // `build.rs` from the same schema compilation as the types above
20 // (ADR-0014 decision 14).
21 //
22 // The file holds only trait impls, so it sits in a private module to scope
23 // one lint allowance to generated code: pbjson-build 0.9.0 writes
24 // `write!(formatter, "…", &FIELDS)`, which clippy 1.98 reports as
25 // `useless_borrows_in_formatting`. The build script writes the file into
26 // `OUT_DIR`, so an edit to it does not last and the lint cannot be repaired
27 // in source. A lint attribute on the `include!` itself is ignored by rustc.
28 // `expect` rather than `allow`: when the lint no longer fires here, clippy
29 // reports the expectation as unfulfilled. Then remove this module and
30 // include the file directly in `v2` again.
31 #[expect(clippy::useless_borrows_in_formatting)]
32 mod serde_impls {
33 use super::*;
34
35 include!(concat!(env!("OUT_DIR"), "/ridl.ir.v2.serde.rs"));
36 }
37
38 /// The descriptor pool over the compiled IR schema — the reflection data
39 /// the prototext encoding needs (ADR-0014 decision 7; since decision 14
40 /// JSON goes through the pbjson-generated impls and no longer uses the
41 /// pool). Binary needs none of it.
42 /// `build.rs` writes the `FileDescriptorSet` to `OUT_DIR` from the same
43 /// `protox` compilation that generates the types above, so the pool and
44 /// the types cannot disagree; every `expect` on this path leans on that.
45 static DESCRIPTOR_POOL: std::sync::LazyLock<prost_reflect::DescriptorPool> =
46 std::sync::LazyLock::new(|| {
47 prost_reflect::DescriptorPool::decode(
48 include_bytes!(concat!(env!("OUT_DIR"), "/ir_descriptor.binpb")).as_slice(),
49 )
50 .expect("the embedded descriptor set decodes: build.rs wrote it from the schema compilation that generated these types")
51 });
52
53 /// The descriptor of one root message of the compiled schema, by its
54 /// full name.
55 fn descriptor(name: &str) -> prost_reflect::MessageDescriptor {
56 DESCRIPTOR_POOL
57 .get_message_by_name(name)
58 .unwrap_or_else(|| panic!("{name} is declared by the compiled schema"))
59 }
60
61 /// The `Package` message descriptor — the entry point of the prototext
62 /// encoder and decoder, the one reflection path left in this module.
63 pub(crate) fn package_descriptor() -> prost_reflect::MessageDescriptor {
64 descriptor("ridl.ir.v2.Package")
65 }
66
67 /// The `System` message descriptor (`system.proto`), the prototext entry
68 /// point of the rsdl system layer.
69 pub(crate) fn system_descriptor() -> prost_reflect::MessageDescriptor {
70 descriptor("ridl.ir.v2.System")
71 }
72
73 /// The `ridl.codegen.v1.Model` message descriptor — the prototext entry
74 /// point of the lowered codegen model, from the same pool, because
75 /// `build.rs` compiles both schemas in one `protox` call.
76 pub(crate) fn codegen_model_descriptor() -> prost_reflect::MessageDescriptor {
77 descriptor("ridl.codegen.v1.Model")
78 }
79
80 /// Rebuilds a message as a `DynamicMessage` over its descriptor — the
81 /// step `prost-reflect` needs before rendering a text encoding.
82 /// Transcoding goes through the wire encoding, whose decoder enforces
83 /// prost's fixed recursion limit, so a package whose composite nesting
84 /// crosses that limit fails here — an input-dependent failure, not
85 /// schema drift (ADR-0014 decision 12).
86 fn transcode<M: prost::Message>(
87 descriptor: prost_reflect::MessageDescriptor,
88 message: &M,
89 ) -> Result<prost_reflect::DynamicMessage, prost::DecodeError> {
90 let mut dynamic = prost_reflect::DynamicMessage::new(descriptor);
91 dynamic.transcode_from(message)?;
92 Ok(dynamic)
93 }
94
95 /// Derives the synthesized transport identity of an inline `T | E`
96 /// result union (ADR-0008 decision 4): the enclosing interface name plus
97 /// the interaction ordinal plus the ordered arm references. The single
98 /// derivation every consumer — backends and the diff classifier — calls,
99 /// so the identity stays stable under compatible evolution.
100 pub fn fallible_transport_identity(
101 interface: &str,
102 ordinal: u32,
103 fallible: &FallibleType,
104 ) -> String {
105 format!(
106 "{interface}#{ordinal}:{ok}|{err}",
107 ok = fallible.ok,
108 err = fallible.err
109 )
110 }
111
112 /// The error [`to_json_pretty`] and [`to_text_format`] return. The
113 /// serialization surface is fallible on purpose (ADR-0014 decisions 12
114 /// and 14), and the two encodings now fail for different causes, so each
115 /// carries its own variant — its `Display` names the encoding, so a
116 /// build requesting several IR emits attributes each failure to its own
117 /// artifact.
118 #[derive(Debug)]
119 pub enum SerializeError {
120 /// Canonical protobuf JSON (ADR-0014 decision 14): the
121 /// pbjson-generated `Serialize` impl writes the typed message
122 /// directly — no transcode, so no message-level recursion limit —
123 /// and its one error path is an `i32` enum field holding a
124 /// discriminant outside the schema.
125 Json(serde_json::Error),
126 /// Prototext (ADR-0014 decision 12): the transcode into the dynamic
127 /// message goes through the wire encoding, whose decoder enforces
128 /// prost's fixed recursion limit, and legal source can nest
129 /// composites past it.
130 Text(prost::DecodeError),
131 }
132
133 impl std::fmt::Display for SerializeError {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 match self {
136 Self::Json(source) => write!(
137 f,
138 "cannot render the package as canonical protobuf JSON: {source}; the known \
139 cause is an enum field holding a discriminant outside the schema"
140 ),
141 Self::Text(source) => write!(
142 f,
143 "cannot render the package as prototext: {source}; the known cause \
144 is composite nesting deeper than the transcoding decoder's recursion limit"
145 ),
146 }
147 }
148 }
149
150 impl std::error::Error for SerializeError {
151 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
152 match self {
153 Self::Json(source) => Some(source),
154 Self::Text(source) => Some(source),
155 }
156 }
157 }
158
159 /// Renders a package as pretty-printed canonical protobuf JSON — the one
160 /// dialect every IR surface carries: the `--emit ir-json` artifact, the
161 /// baselines, and the goldens (ADR-0014 decision 1) — through the
162 /// pbjson-generated `Serialize` impl (decision 14), byte-identical to
163 /// what the retired reflection path rendered.
164 ///
165 /// A field holding its default is emitted rather than skipped (decision
166 /// 2, `emit_fields()` in `build.rs`); an unset proto3 `optional` field
167 /// is omitted entirely, never rendered as `null` (the answer to that
168 /// decision's open item). 64-bit fields render as strings, the canonical
169 /// mapping JavaScript consumers need (decision 8).
170 ///
171 /// Still fallible (ADR-0014 decision 14, amending decision 12), but the
172 /// error path changed rather than survived: the generated impl writes
173 /// the typed message directly, so the transcode's depth error is gone,
174 /// and the one error path it has is new — an `i32` enum field holding a
175 /// discriminant outside the schema, which the retired reflection path
176 /// serialized successfully as its bare number. The checker never
177 /// produces one, but the value is data, not schema, so the failure is
178 /// returned rather than panicked on.
179 pub fn to_json_pretty(package: &Package) -> Result<String, SerializeError> {
180 render_json(package)
181 }
182
183 /// Renders a lowered system (`system.proto`, rsdl reference §13) as
184 /// pretty-printed canonical protobuf JSON — the `<pkg.Name>.system.json`
185 /// artifact, under the rules of [`to_json_pretty`].
186 pub fn system_to_json_pretty(system: &System) -> Result<String, SerializeError> {
187 render_json(system)
188 }
189
190 /// The one JSON writer behind [`to_json_pretty`] and
191 /// [`system_to_json_pretty`]: the pbjson-generated `Serialize` impl of
192 /// the message, pretty-printed.
193 pub(crate) fn render_json<M: serde::Serialize>(message: &M) -> Result<String, SerializeError> {
194 let mut buf = Vec::new();
195 let mut serializer = serde_json::Serializer::pretty(&mut buf);
196 serde::Serialize::serialize(message, &mut serializer).map_err(SerializeError::Json)?;
197 Ok(String::from_utf8(buf).expect("serde_json emits UTF-8"))
198 }
199
200 /// The nesting ceiling `from_json` enforces, in JSON bracket levels.
201 ///
202 /// It cannot bind on IR this toolchain produces, and the bound is a
203 /// measurement rather than a guess. The parser refuses type nesting past
204 /// 128 levels (FORM-102, `MAX_TYPE_DEPTH` in `ridl-syntax`), and the
205 /// deepest package that limit admits emits JSON **516 brackets** deep —
206 /// so 1,000 leaves a factor of 1.9 over anything `ridlc` can write, and
207 /// the deepest nesting in the corpus is single digits. The figure this
208 /// comment carried until 2026-09-22, 262 brackets and a factor of 3.8,
209 /// was the array shape; the tuple shape costs four brackets per source
210 /// level rather than two and is the one that binds (the IR
211 /// specification, "The nesting bound").
212 ///
213 /// It exists for input this toolchain did not write: a hand-edited
214 /// baseline, or a snapshot from elsewhere. Past the stack ceiling the
215 /// failure mode is a stack-overflow abort, which no caller can catch, so
216 /// the cap turns an abort into a diagnostic (ADR-0014 decisions 12
217 /// and 14).
218 pub(crate) const MAX_JSON_NESTING: usize = 1_000;
219
220 /// The stack `from_json` parses on, in bytes. An explicit size makes the
221 /// depth that fits a constant of this crate rather than of the ambient
222 /// stack, which differs between debug and release builds and between
223 /// platforms — the same input parses everywhere or nowhere.
224 const JSON_PARSE_STACK: usize = 16 * 1024 * 1024;
225
226 /// The maximum bracket nesting of `text`: the largest number of `{` and
227 /// `[` open at once, with string literals skipped — a bracket inside a
228 /// string must not count, an escaped quote (`\"`) must not end the
229 /// string, and an escaped backslash (`\\`) must not disarm the real
230 /// closing quote after it. Runs before the parse in [`from_json`], so it
231 /// tolerates input that is not valid JSON; a stray closer never
232 /// underflows the running depth.
233 pub(crate) fn max_json_nesting(text: &str) -> usize {
234 let mut depth = 0usize;
235 let mut deepest = 0usize;
236 let mut in_string = false;
237 let mut escaped = false;
238 for byte in text.bytes() {
239 if in_string {
240 if escaped {
241 escaped = false;
242 } else if byte == b'\\' {
243 escaped = true;
244 } else if byte == b'"' {
245 in_string = false;
246 }
247 } else {
248 match byte {
249 b'"' => in_string = true,
250 b'{' | b'[' => {
251 depth += 1;
252 deepest = deepest.max(depth);
253 }
254 b'}' | b']' => depth = depth.saturating_sub(1),
255 _ => {}
256 }
257 }
258 }
259 deepest
260 }
261
262 /// Reads a package from canonical protobuf JSON — the inverse of
263 /// [`to_json_pretty`], through the pbjson-generated `Deserialize` impl
264 /// (ADR-0014 decision 14). Unknown fields are rejected (the generated
265 /// deserializer's default; `ignore_unknown_fields()` stays unset in
266 /// `build.rs`), so a snapshot written against a different schema fails
267 /// loudly rather than dropping fields silently.
268 ///
269 /// The generated impl recurses per JSON level, so `serde_json`'s own
270 /// recursion limit of 128 levels is disabled — it would bind far below
271 /// this crate's documented ceiling — and two guards replace it
272 /// (ADR-0014 decision 14):
273 ///
274 /// - input nesting is measured first and refused past
275 /// `MAX_JSON_NESTING`, returning a diagnostic where unbounded
276 /// recursion would eventually abort on a stack overflow no caller can
277 /// catch;
278 /// - the parse runs on a thread of `JSON_PARSE_STACK` bytes, so the
279 /// ceiling behaves identically across build profiles and platforms
280 /// instead of tracking the ambient stack. On the wasm family there is
281 /// no such thread — see the branch below — and the cap alone guards
282 /// the parse.
283 pub fn from_json(text: &str) -> Result<Package, serde_json::Error> {
284 read_json(text)
285 }
286
287 /// Reads a lowered system from canonical protobuf JSON — the inverse of
288 /// [`system_to_json_pretty`], under the rules and guards of
289 /// [`from_json`].
290 pub fn system_from_json(text: &str) -> Result<System, serde_json::Error> {
291 read_json(text)
292 }
293
294 /// The one JSON reader behind [`from_json`] and [`system_from_json`]:
295 /// the nesting cap, then the parse on its own stack.
296 pub(crate) fn read_json<M>(text: &str) -> Result<M, serde_json::Error>
297 where
298 M: serde::de::DeserializeOwned + Send,
299 {
300 if max_json_nesting(text) > MAX_JSON_NESTING {
301 return Err(<serde_json::Error as serde::de::Error>::custom(format!(
302 "the input nests deeper than {MAX_JSON_NESTING} JSON levels, the ceiling this \
303 reader enforces (ADR-0014 decision 14); real IR nests orders of magnitude \
304 shallower"
305 )));
306 }
307 if cfg!(target_family = "wasm") {
308 // The wasm family has no spawnable threads: `spawn_scoped`
309 // returns `Err(Unsupported)` at run time on
310 // `wasm32-unknown-unknown`, the `just wasm-check` target, so a
311 // spawn here would turn every call into a panic. The parse runs
312 // in line instead, on the caller's stack. What this path loses
313 // is the deterministic stack — the ceiling is the ambient stack
314 // — and the `MAX_JSON_NESTING` cap above is the guard that
315 // matters: it is what turns an abort into an error.
316 parse_json(text)
317 } else {
318 std::thread::scope(|scope| {
319 let handle = std::thread::Builder::new()
320 .stack_size(JSON_PARSE_STACK)
321 .spawn_scoped(scope, || parse_json(text))
322 .expect("the JSON parse thread spawns");
323 match handle.join() {
324 Ok(result) => result,
325 Err(payload) => std::panic::resume_unwind(payload),
326 }
327 })
328 }
329 }
330
331 /// The parse both branches of [`from_json`] share; only the stack that
332 /// carries it differs. `serde_json`'s own recursion limit is disabled
333 /// here, so the caller must have applied the `MAX_JSON_NESTING` cap
334 /// first.
335 fn parse_json<M: serde::de::DeserializeOwned>(text: &str) -> Result<M, serde_json::Error> {
336 let mut deserializer = serde_json::Deserializer::from_str(text);
337 deserializer.disable_recursion_limit();
338 let message: M = serde::Deserialize::deserialize(&mut deserializer)?;
339 deserializer.end()?;
340 Ok(message)
341 }
342
343 /// Renders a package in the protobuf text format — the inspection
344 /// encoding (ADR-0014 decision 9): emittable, but not a recommended
345 /// interchange form. Rendered `pretty`, with a field holding its default
346 /// emitted rather than skipped (decision 2) and message fields printed
347 /// in schema index order, so the output ordering is deterministic rather
348 /// than incidental (decision 8).
349 ///
350 /// Fallible on purpose (ADR-0014 decision 12): the transcode into the
351 /// dynamic message goes through the wire encoding, and a package whose
352 /// composite nesting crosses prost's recursion limit fails there. That
353 /// input is legal source, so the failure is returned rather than
354 /// panicked on. JSON lost this failure mode when it moved off the
355 /// transcode (decision 14); prototext keeps it.
356 pub fn to_text_format(package: &Package) -> Result<String, SerializeError> {
357 render_text_for(package_descriptor(), package)
358 }
359
360 /// Renders a lowered system in the protobuf text format — the
361 /// `<pkg.Name>.system.txtpb` artifact, under the rules of
362 /// [`to_text_format`].
363 pub fn system_to_text_format(system: &System) -> Result<String, SerializeError> {
364 render_text_for(system_descriptor(), system)
365 }
366
367 /// The one prototext writer behind [`to_text_format`] and
368 /// [`system_to_text_format`].
369 pub(crate) fn render_text_for<M: prost::Message>(
370 descriptor: prost_reflect::MessageDescriptor,
371 message: &M,
372 ) -> Result<String, SerializeError> {
373 let dynamic = transcode(descriptor, message).map_err(SerializeError::Text)?;
374 Ok(dynamic.to_text_format_with_options(
375 &prost_reflect::text_format::FormatOptions::new()
376 .pretty(true)
377 .skip_default_fields(false)
378 .print_message_fields_in_index_order(true),
379 ))
380 }
381
382 /// The error [`from_text_format`] returns: the input does not parse as
383 /// prototext, or the parsed message does not transcode into the
384 /// generated types. The transcode failure is the read direction of the
385 /// recursion-limit failure mode (ADR-0014 decision 12) — input-dependent,
386 /// so it is mapped into this return rather than expected on.
387 #[cfg(test)]
388 #[derive(Debug)]
389 pub(crate) enum TextFormatError {
390 /// The input is not valid prototext for the `Package` schema.
391 Parse(prost_reflect::text_format::ParseError),
392 /// The parsed message cannot be rebuilt as a typed `Package`.
393 Transcode(prost::DecodeError),
394 }
395
396 #[cfg(test)]
397 impl std::fmt::Display for TextFormatError {
398 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399 match self {
400 Self::Parse(source) => {
401 write!(f, "cannot parse the text as an IR package: {source}")
402 }
403 Self::Transcode(source) => write!(
404 f,
405 "cannot rebuild the parsed prototext as a package: {source}; the known cause \
406 is composite nesting deeper than the transcoding decoder's recursion limit"
407 ),
408 }
409 }
410 }
411
412 #[cfg(test)]
413 impl std::error::Error for TextFormatError {
414 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
415 match self {
416 Self::Parse(source) => Some(source),
417 Self::Transcode(source) => Some(source),
418 }
419 }
420 }
421
422 /// Reads a package from the protobuf text format — the inverse of
423 /// [`to_text_format`], kept because without it the prototext emit has no
424 /// round-trip test, and a write path with no read path is untested by
425 /// construction (ADR-0014 decision 7).
426 ///
427 /// **Deliberately not public.** `prost-reflect`'s text parser recurses per
428 /// message level with frames large enough that a debug build exhausts a
429 /// 2 MiB stack at roughly 45 levels of nesting — *below* prost's recursion
430 /// limit of 100, so on that path the error return below is unreachable and
431 /// the process aborts instead. A stack overflow cannot be caught, so the
432 /// hazard is contained by reach rather than handled: nothing in the
433 /// toolchain reads prototext, `ridl diff` and `ridl check --baseline`
434 /// refuse the encoding by name (ADR-0014 decision 5), and this function is
435 /// compiled only for this crate's tests. The tests that exercise it run on an
436 /// explicitly sized stack (see `with_sized_stack`). Making it public again
437 /// means giving it a stack strategy first — driftsys/ridl#218.
438 ///
439 /// A package whose nesting crosses prost's limit *before* the stack runs
440 /// out fails in the transcode out of the dynamic message; that failure is
441 /// mapped into the error return, not expected on (ADR-0014 decision 12).
442 #[cfg(test)]
443 pub(crate) fn from_text_format(text: &str) -> Result<Package, TextFormatError> {
444 parse_text(package_descriptor(), text)
445 }
446
447 /// Reads a lowered system from the protobuf text format — the inverse of
448 /// [`system_to_text_format`], test-only for the reason
449 /// [`from_text_format`] states.
450 #[cfg(test)]
451 pub(crate) fn system_from_text_format(text: &str) -> Result<System, TextFormatError> {
452 parse_text(system_descriptor(), text)
453 }
454
455 #[cfg(test)]
456 fn parse_text<M: prost::Message + Default>(
457 descriptor: prost_reflect::MessageDescriptor,
458 text: &str,
459 ) -> Result<M, TextFormatError> {
460 let dynamic = prost_reflect::DynamicMessage::parse_text_format(descriptor, text)
461 .map_err(TextFormatError::Parse)?;
462 dynamic.transcode_to().map_err(TextFormatError::Transcode)
463 }
464
465 /// Encodes a package in the protobuf binary wire format — a derived
466 /// encoding since ADR-0014 decision 9's 2026-09-22 amendment, whose
467 /// reader stops 100 message levels below the root where the canonical
468 /// encoding has no such bound (the IR specification, "The derived
469 /// encodings"). Binary needs no descriptors: prost's generated encoding
470 /// is schema-faithful by construction.
471 pub fn to_binary(package: &Package) -> Vec<u8> {
472 prost::Message::encode_to_vec(package)
473 }
474
475 /// Decodes a package from the protobuf binary wire format — the inverse
476 /// of [`to_binary`].
477 pub fn from_binary(bytes: &[u8]) -> Result<Package, prost::DecodeError> {
478 prost::Message::decode(bytes)
479 }
480
481 /// Encodes a lowered system in the protobuf binary wire format — the
482 /// `<pkg.Name>.system.binpb` artifact (ADR-0014 decision 9).
483 pub fn system_to_binary(system: &System) -> Vec<u8> {
484 prost::Message::encode_to_vec(system)
485 }
486
487 /// Decodes a lowered system from the protobuf binary wire format — the
488 /// inverse of [`system_to_binary`].
489 pub fn system_from_binary(bytes: &[u8]) -> Result<System, prost::DecodeError> {
490 prost::Message::decode(bytes)
491 }
492
493 /// `pkg.Name` — how a system, a component or a distribution is referred
494 /// to across the system layer (`system.proto`); a name with no package,
495 /// the implicit component of a lone service (rsdl §6), is its own
496 /// qualified name.
497 fn qualified(package: &str, name: &str) -> String {
498 if package.is_empty() {
499 name.to_string()
500 } else {
501 format!("{package}.{name}")
502 }
503 }
504
505 impl System {
506 /// The system's qualified name, `pkg.Name` — the base name of its
507 /// artifacts.
508 pub fn qualified_name(&self) -> String {
509 qualified(&self.package, &self.name)
510 }
511 }
512
513 impl Component {
514 /// The name every reference to this component uses: `pkg.Name` for a
515 /// declared component, the service's dotted name for an implicit one.
516 pub fn qualified_name(&self) -> String {
517 qualified(&self.package, &self.name)
518 }
519 }
520
521 impl Distribution {
522 /// The name `Distribution.depends_on` and `Installation.distribution`
523 /// use.
524 pub fn qualified_name(&self) -> String {
525 qualified(&self.package, &self.name)
526 }
527 }
528
529 /// One interface shape of a package (ridl §14.0): a declared `interface`,
530 /// or the inline shape of a `service` (§14.5).
531 ///
532 /// **[`Package::interfaces`] is not the complete set.** A `service`
533 /// declared with an inline body carries a full [`Interface`] inside its
534 /// shape list (the single `INLINE` slot, ADR-0015 decision 14), which
535 /// lives outside `interfaces`; a consumer that walks `interfaces` alone
536 /// silently misses it. Six defects of exactly that shape were found
537 /// independently across E2 — observer-stub lowering, both backends'
538 /// transport identity, `ridl test`'s report, the Rust backend's collision
539 /// check, and the desk check's span index.
540 /// [`Package::shapes`] is the one walk that sees both, the way
541 /// [`fallible_transport_identity`] is the one transport-identity
542 /// derivation.
543 ///
544 /// This view is deliberately not a bare `&Interface`, because two of an
545 /// inline shape's own fields are empty by construction and reading them
546 /// is what produced two of those six defects:
547 ///
548 /// - [`Interface::name`] is `""` for an inline shape, so [`Self::name`]
549 /// carries the **identity** name instead — the interface's own name, or
550 /// the owning service's dotted global name. That is the name the diff
551 /// paths, the observer-stub scoping, and both backends' identity fields
552 /// already use.
553 /// - [`Interface::visibility`] is `VISIBILITY_UNSPECIFIED` for an inline
554 /// shape; the owning [`Service`] carries the authoritative one, which
555 /// [`Self::visibility`] reads.
556 ///
557 /// The generated *type* name is not derived here on purpose: mangling is
558 /// language-specific and stays with each backend.
559 #[derive(Debug, Clone, Copy, PartialEq)]
560 pub struct InterfaceShape<'a> {
561 /// The name this shape is known by outside the package: an
562 /// `interface` declaration's own name, or the owning service's dotted
563 /// global name. Never `Interface::name` for an inline shape.
564 pub name: &'a str,
565 /// The interface body — its interactions and its doc envelope.
566 pub interface: &'a Interface,
567 /// The owning service, for an inline shape; `None` for a declared
568 /// `interface`.
569 pub service: Option<&'a Service>,
570 }
571
572 impl InterfaceShape<'_> {
573 /// The authoritative visibility of this shape: the owning service's
574 /// for an inline shape (an inline shape's own field is
575 /// `VISIBILITY_UNSPECIFIED` by construction), the interface's own
576 /// otherwise.
577 pub fn visibility(&self) -> i32 {
578 match self.service {
579 Some(service) => service.visibility,
580 None => self.interface.visibility,
581 }
582 }
583
584 /// `true` when this shape is the inline body of a `service`.
585 pub fn is_inline(&self) -> bool {
586 self.service.is_some()
587 }
588 }
589
590 impl Package {
591 /// Every interface shape the package carries — the declared
592 /// interfaces and the inline shapes of its services. See
593 /// [`InterfaceShape`] for why walking [`Package::interfaces`] alone is
594 /// a defect.
595 ///
596 /// The order is the one every consumer already walked: the declared
597 /// interfaces in source order, then the services in source order. A
598 /// shape-list entry that names an interface yields nothing — its
599 /// target is a declared interface and is already in the sequence, so
600 /// yielding it again would visit one shape twice; a service composing
601 /// several interfaces (ADR-0015 decision 12) therefore contributes
602 /// nothing at all. A tombstone slot names no shape. Only the `INLINE`
603 /// slot of an inline-form service carries an interface of its own,
604 /// and that is what this walk yields.
605 pub fn shapes(&self) -> impl Iterator<Item = InterfaceShape<'_>> {
606 let named = self.interfaces.iter().map(|interface| InterfaceShape {
607 name: &interface.name,
608 interface,
609 service: None,
610 });
611 let inline = self.services.iter().flat_map(|service| {
612 service
613 .shapes
614 .iter()
615 .filter_map(move |slot| match slot.kind.as_ref()? {
616 service_shape::Kind::Inline(interface) => Some(InterfaceShape {
617 name: &service.name,
618 interface,
619 service: Some(service),
620 }),
621 service_shape::Kind::InterfaceRef(_) => None,
622 })
623 });
624 named.chain(inline)
625 }
626 }
627
628 /// Every package named by a type reference in `package`.
629 ///
630 /// A resolved type-reference string is the fully qualified `pkg.Name` for
631 /// a cross-package reference and the bare `Name` for a same-package one,
632 /// never an import alias — the canonical form stated in
633 /// `proto/ridl/ir/v2/ir.proto`, which also enumerates the fields carrying
634 /// one. **That enumeration and this walk are edited together.** A
635 /// reference-bearing field added there and not read here makes the package
636 /// it names invisible to every caller asking what a package depends on.
637 ///
638 /// Every `oneof` below is matched exhaustively with no wildcard arm, so a
639 /// variant added later fails to compile here rather than going unread.
640 pub fn referenced_packages(package: &Package) -> std::collections::BTreeSet<String> {
641 let mut found = std::collections::BTreeSet::new();
642 for decl in &package.decls {
643 walk_decl(decl, &mut found);
644 }
645 for interface in &package.interfaces {
646 for interaction in &interface.interactions {
647 walk_decl(interaction, &mut found);
648 }
649 }
650 for service in &package.services {
651 for slot in &service.shapes {
652 match &slot.kind {
653 Some(service_shape::Kind::InterfaceRef(reference)) => {
654 qualifier(reference, &mut found);
655 }
656 Some(service_shape::Kind::Inline(interface)) => {
657 for interaction in &interface.interactions {
658 walk_decl(interaction, &mut found);
659 }
660 }
661 None => {}
662 }
663 }
664 }
665 found
666 }
667
668 /// Records the package qualifier of a dotted reference. A bare reference
669 /// is same-package and contributes nothing.
670 fn qualifier(reference: &str, found: &mut std::collections::BTreeSet<String>) {
671 if let Some((package, _)) = reference.rsplit_once('.') {
672 found.insert(package.to_string());
673 }
674 }
675
676 /// Records every reference in one declaration — a package-level one or an
677 /// interaction inside an interface, which share the `Decl` envelope.
678 fn walk_decl(decl: &Decl, found: &mut std::collections::BTreeSet<String>) {
679 match &decl.kind {
680 Some(decl::Kind::TypeDef(type_def)) => walk_type_def(type_def, found),
681 Some(decl::Kind::ConstDef(const_def)) => {
682 if let Some(reference) = &const_def.type_ref {
683 qualifier(reference, found);
684 }
685 }
686 Some(decl::Kind::StructDef(struct_def)) => {
687 for member in &struct_def.members {
688 match &member.member {
689 Some(struct_member::Member::Field(field)) => {
690 if let Some(field_type) = &field.r#type {
691 walk_field_type(field_type, found);
692 }
693 }
694 // A tombstone occupies an ordinal and names no type.
695 Some(struct_member::Member::Reserved(_)) | None => {}
696 }
697 }
698 }
699 // An enum's variants are integers; it names no type.
700 Some(decl::Kind::EnumDef(_)) => {}
701 Some(decl::Kind::EnumSetDef(enum_set)) => {
702 if let Some(reference) = &enum_set.backing_enum {
703 qualifier(reference, found);
704 }
705 }
706 Some(decl::Kind::UnionDef(union_def)) => {
707 for arm in &union_def.arms {
708 qualifier(&arm.type_ref, found);
709 }
710 }
711 Some(decl::Kind::SignalDef(signal)) => qualifier(&signal.payload, found),
712 Some(decl::Kind::EventDef(event)) => qualifier(&event.payload, found),
713 Some(decl::Kind::CommandDef(command)) => {
714 for param in &command.params {
715 if let Some(field_type) = ¶m.r#type {
716 walk_field_type(field_type, found);
717 }
718 }
719 }
720 Some(decl::Kind::QueryDef(query)) => {
721 for param in &query.params {
722 if let Some(field_type) = ¶m.r#type {
723 walk_field_type(field_type, found);
724 }
725 }
726 if let Some(return_type) = &query.return_type {
727 walk_return_type(return_type, found);
728 }
729 }
730 Some(decl::Kind::FixedDef(fixed)) => {
731 if let Some(field_type) = &fixed.payload {
732 walk_field_type(field_type, found);
733 }
734 }
735 // A tombstone occupies an ordinal and names no type.
736 Some(decl::Kind::ReservedSlot(_)) | None => {}
737 }
738 }
739
740 /// The recursive half: a reference is reachable at arbitrary depth through
741 /// tuples, arrays, maps, inline scalars, and streams.
742 fn walk_field_type(field_type: &FieldType, found: &mut std::collections::BTreeSet<String>) {
743 match &field_type.kind {
744 Some(field_type::Kind::Named(reference)) => qualifier(reference, found),
745 // A primitive names no package.
746 Some(field_type::Kind::Primitive(_)) => {}
747 Some(field_type::Kind::InlineScalar(type_def)) => walk_type_def(type_def, found),
748 Some(field_type::Kind::Tuple(tuple)) => {
749 for field in &tuple.fields {
750 if let Some(inner) = &field.r#type {
751 walk_field_type(inner, found);
752 }
753 }
754 }
755 Some(field_type::Kind::Array(array)) => {
756 if let Some(element) = &array.element {
757 walk_field_type(element, found);
758 }
759 }
760 Some(field_type::Kind::Map(map)) => {
761 if let Some(key) = &map.key {
762 walk_field_type(key, found);
763 }
764 if let Some(value) = &map.value {
765 walk_field_type(value, found);
766 }
767 }
768 Some(field_type::Kind::Stream(stream)) => match &stream.element {
769 Some(stream_type::Element::Named(reference)) => qualifier(reference, found),
770 // STRING or BYTES only; names no package.
771 Some(stream_type::Element::Primitive(_)) | None => {}
772 },
773 None => {}
774 }
775 }
776
777 /// A `TypeDef`'s only reference is the constant a `match` bound names.
778 fn walk_type_def(type_def: &TypeDef, found: &mut std::collections::BTreeSet<String>) {
779 if let Some(constraint) = &type_def.constraint
780 && let Some(reference) = &constraint.pattern_const
781 {
782 qualifier(reference, found);
783 }
784 }
785
786 fn walk_return_type(return_type: &ReturnType, found: &mut std::collections::BTreeSet<String>) {
787 match &return_type.kind {
788 Some(return_type::Kind::Value(field_type)) => walk_field_type(field_type, found),
789 Some(return_type::Kind::Fallible(fallible)) => {
790 qualifier(&fallible.ok, found);
791 qualifier(&fallible.err, found);
792 }
793 None => {}
794 }
795 }
796
797 /// Whether a constraint leaves a generated constructor nothing to check.
798 ///
799 /// True when no bound and no pattern is present. `step` is excluded on
800 /// purpose: nothing checks a step today, and the design this classifier
801 /// prepares for rounds a value to the nearest step-lattice point rather than
802 /// rejecting it, so a step-only constraint is meant to admit a constructor
803 /// with nothing to check (design spec, Deferred, not yet implemented).
804 ///
805 /// A pattern given by name counts as a pattern: `pattern_const` is read as
806 /// well as `pattern`, because a pattern constant that did not resolve leaves
807 /// `pattern` absent while the type still carries a match constraint.
808 /// `ridl-sem` treats the two fields the same way in its derived-init rule
809 /// (`init.rs`).
810 ///
811 /// Because the checker materializes the typl §4.4 default `[0..256]` into
812 /// `len_min`/`len_max`, every string and bytes type is non-vacuous. In
813 /// practice this reduces to `boolean`, and `integer`/`float` with no declared
814 /// range.
815 pub fn constraint_is_vacuous(constraint: Option<&Constraint>) -> bool {
816 let Some(c) = constraint else { return true };
817 c.min.is_none()
818 && c.max.is_none()
819 && c.len_min.is_none()
820 && c.len_max.is_none()
821 && c.pattern.is_none()
822 && c.pattern_const.is_none()
823 }
824}
825
826pub mod codegen;
827pub mod name;
828pub mod projection;
829pub mod zero;
830
831#[cfg(test)]
832mod v2_round_trip {
833 use crate::v2;
834
835 /// Wraps an interaction kind in the shared `Decl` envelope. Visibility
836 /// and `is_error` stay unset on interactions (ridl §14.1); the ordinal is
837 /// the 1-based declaration order across all interactions of the
838 /// enclosing interface (ridl §11).
839 fn interaction(name: &str, ordinal: u32, kind: v2::decl::Kind) -> v2::Decl {
840 v2::Decl {
841 name: name.to_string(),
842 visibility: v2::Visibility::Unspecified as i32,
843 is_error: false,
844 doc: String::new(),
845 labels: Vec::new(),
846 deprecated: None,
847 ordinal,
848 kind: Some(kind),
849 }
850 }
851
852 fn named_type(name: &str) -> v2::FieldType {
853 v2::FieldType {
854 optional: false,
855 kind: Some(v2::field_type::Kind::Named(name.to_string())),
856 }
857 }
858
859 fn stream_of(element: v2::stream_type::Element) -> v2::FieldType {
860 v2::FieldType {
861 optional: false,
862 kind: Some(v2::field_type::Kind::Stream(v2::StreamType {
863 element: Some(element),
864 })),
865 }
866 }
867
868 /// A representative ridl package: one interface holding all five
869 /// interaction kinds plus a reserved tombstone (ordinals 1–6, the
870 /// tombstone counted, ridl §11), a strict-periodic and a defaulted
871 /// range timing, a fallible query, and two services — a named
872 /// reference and an inline shape holding a stream query.
873 fn fixture() -> v2::Package {
874 // signal speed : Speed @10ms — strict periodic stores the period
875 // in both bounds (ADR-0008 decision 12).
876 let speed = v2::SignalDef {
877 payload: "Speed".to_string(),
878 declared_init: None,
879 init: Some(v2::InitValue {
880 derivable: true,
881 value: Some("0.0".to_string()),
882 }),
883 timing: Some(v2::Timing {
884 mode: v2::TimingMode::StrictPeriodic as i32,
885 min_us: Some("10000".to_string()),
886 max_us: Some("10000".to_string()),
887 default_applied: false,
888 }),
889 };
890
891 // event doorOpened : DoorEvent — untimed in source, so the
892 // configured default range is resolved at compile time (ridl §9.1).
893 let door_opened = v2::EventDef {
894 payload: "DoorEvent".to_string(),
895 timing: Some(v2::Timing {
896 mode: v2::TimingMode::Range as i32,
897 min_us: Some("20000".to_string()),
898 max_us: Some("500000".to_string()),
899 default_applied: true,
900 }),
901 };
902
903 // command setTarget(target : Speed) [ require target >= speed ]
904 let set_target = v2::CommandDef {
905 params: vec![v2::Param {
906 name: "target".to_string(),
907 r#type: Some(named_type("Speed")),
908 }],
909 contracts: vec![v2::Contract {
910 kind: v2::ContractKind::Require as i32,
911 source: "target >= speed".to_string(),
912 signal_refs: vec!["speed".to_string()],
913 param_refs: vec!["target".to_string()],
914 uses_result: false,
915 observer_id: "VehicleStatus.setTarget.require[0]".to_string(),
916 }],
917 timing: None,
918 };
919
920 // query fetchFaults(page : PageSpec) : FaultPage | DiagError
921 // [ ensure result.count <= page.limit ]
922 let fetch_faults = v2::QueryDef {
923 params: vec![v2::Param {
924 name: "page".to_string(),
925 r#type: Some(named_type("PageSpec")),
926 }],
927 return_type: Some(v2::ReturnType {
928 kind: Some(v2::return_type::Kind::Fallible(v2::FallibleType {
929 ok: "FaultPage".to_string(),
930 err: "DiagError".to_string(),
931 })),
932 }),
933 contracts: vec![v2::Contract {
934 kind: v2::ContractKind::Ensure as i32,
935 source: "result.count <= page.limit".to_string(),
936 signal_refs: Vec::new(),
937 param_refs: vec!["page".to_string()],
938 uses_result: true,
939 observer_id: "VehicleStatus.fetchFaults.ensure[0]".to_string(),
940 }],
941 timing: None,
942 };
943
944 // fixed vin : Vin
945 let vin = v2::FixedDef {
946 payload: Some(named_type("Vin")),
947 };
948
949 let vehicle_status = v2::Interface {
950 name: "VehicleStatus".to_string(),
951 visibility: v2::Visibility::Public as i32,
952 doc: "Vehicle status contract".to_string(),
953 labels: Vec::new(),
954 deprecated: None,
955 interactions: vec![
956 interaction("speed", 1, v2::decl::Kind::SignalDef(speed)),
957 interaction("doorOpened", 2, v2::decl::Kind::EventDef(door_opened)),
958 // reserved legacyMode — the tombstone keeps ordinal 3
959 // occupied in the one interaction sequence (ridl §11).
960 v2::Decl {
961 ordinal: 3,
962 kind: Some(v2::decl::Kind::ReservedSlot(v2::Reserved {
963 ordinal: 3,
964 name: Some("legacyMode".to_string()),
965 value: None,
966 })),
967 ..interaction("", 3, v2::decl::Kind::ReservedSlot(v2::Reserved::default()))
968 },
969 interaction("setTarget", 4, v2::decl::Kind::CommandDef(set_target)),
970 interaction("fetchFaults", 5, v2::decl::Kind::QueryDef(fetch_faults)),
971 interaction("vin", 6, v2::decl::Kind::FixedDef(vin)),
972 ],
973 number: 0,
974 provisional: false,
975 };
976
977 // query tailLogs(pattern : <string>) : <LogLine> — a stream param
978 // and a stream return (ridl §12), inside the inline service shape.
979 let tail_logs = v2::QueryDef {
980 params: vec![v2::Param {
981 name: "pattern".to_string(),
982 r#type: Some(stream_of(v2::stream_type::Element::Primitive(
983 v2::PrimitiveType::String as i32,
984 ))),
985 }],
986 return_type: Some(v2::ReturnType {
987 kind: Some(v2::return_type::Kind::Value(stream_of(
988 v2::stream_type::Element::Named("LogLine".to_string()),
989 ))),
990 }),
991 contracts: Vec::new(),
992 timing: None,
993 };
994
995 // service veh.adas.status : VehicleStatus — one named reference in
996 // the service's set (ADR-0015 decision 12).
997 let status_service = v2::Service {
998 name: "veh.adas.status".to_string(),
999 visibility: v2::Visibility::Public as i32,
1000 doc: String::new(),
1001 labels: Vec::new(),
1002 deprecated: None,
1003 shapes: vec![v2::ServiceShape {
1004 kind: Some(v2::service_shape::Kind::InterfaceRef(
1005 "VehicleStatus".to_string(),
1006 )),
1007 }],
1008 };
1009 // service veh.adas.logs { … } — the inline shape as the one entry,
1010 // Interface.name == "" (ridl §14.5).
1011 let logs_service = v2::Service {
1012 name: "veh.adas.logs".to_string(),
1013 visibility: v2::Visibility::Public as i32,
1014 doc: String::new(),
1015 labels: Vec::new(),
1016 deprecated: None,
1017 shapes: vec![v2::ServiceShape {
1018 kind: Some(v2::service_shape::Kind::Inline(v2::Interface {
1019 name: String::new(),
1020 visibility: v2::Visibility::Unspecified as i32,
1021 doc: String::new(),
1022 labels: Vec::new(),
1023 deprecated: None,
1024 interactions: vec![interaction(
1025 "tailLogs",
1026 1,
1027 v2::decl::Kind::QueryDef(tail_logs),
1028 )],
1029 number: 0,
1030 provisional: false,
1031 })),
1032 }],
1033 };
1034
1035 v2::Package {
1036 name: "veh.adas".to_string(),
1037 // One typl declaration proves the verbatim v1 surface rides
1038 // along unchanged in v2; package-level declarations carry
1039 // ordinal 0.
1040 decls: vec![v2::Decl {
1041 name: "Speed".to_string(),
1042 visibility: v2::Visibility::Public as i32,
1043 is_error: false,
1044 doc: String::new(),
1045 labels: Vec::new(),
1046 deprecated: None,
1047 ordinal: 0,
1048 kind: Some(v2::decl::Kind::TypeDef(v2::TypeDef {
1049 backing: Some(v2::Backing {
1050 kind: Some(v2::backing::Kind::Unit("km/h".to_string())),
1051 }),
1052 constraint: None,
1053 declared_init: None,
1054 init: None,
1055 width: Some(v2::type_def::Width::FloatWidth(v2::FloatWidth::F32 as i32)),
1056 })),
1057 }],
1058 interfaces: vec![vehicle_status],
1059 services: vec![status_service, logs_service],
1060 retired: Vec::new(),
1061 }
1062 }
1063
1064 /// The typl vocabulary surface the interaction fixture does not reach:
1065 /// the boxed `inlineScalar` oneof member, genuine 64-bit integer fields
1066 /// (array and map bounds, length bounds, `Reserved.value`,
1067 /// `EnumValue.value`), a tuple, a map, a union, an enum set, a constant,
1068 /// and a set `deprecated`. A second fixture, so each stays readable; the
1069 /// same round-trip tests drive both.
1070 fn vocabulary_fixture() -> v2::Package {
1071 fn decl(name: &str, kind: v2::decl::Kind) -> v2::Decl {
1072 v2::Decl {
1073 name: name.to_string(),
1074 visibility: v2::Visibility::Public as i32,
1075 is_error: false,
1076 doc: String::new(),
1077 labels: Vec::new(),
1078 deprecated: None,
1079 ordinal: 0,
1080 kind: Some(kind),
1081 }
1082 }
1083
1084 fn field(name: &str, ordinal: u32, field_type: v2::FieldType) -> v2::Field {
1085 v2::Field {
1086 name: name.to_string(),
1087 ordinal,
1088 r#type: Some(field_type),
1089 declared_init: None,
1090 init: None,
1091 doc: String::new(),
1092 labels: Vec::new(),
1093 deprecated: None,
1094 }
1095 }
1096
1097 // const MAX_RETRY : integer = 24
1098 let max_retry = v2::ConstDef {
1099 type_ref: Some("integer".to_string()),
1100 value: "24".to_string(),
1101 regex: None,
1102 };
1103
1104 // enum Gear { PARK = 1 DRIVE = 2 reserved 7 } — the tombstone
1105 // retires the integer value, a genuine int64 field.
1106 let gear = v2::EnumDef {
1107 values: vec![
1108 v2::EnumValue {
1109 name: "PARK".to_string(),
1110 value: 1,
1111 doc: String::new(),
1112 },
1113 v2::EnumValue {
1114 name: "DRIVE".to_string(),
1115 value: 2,
1116 doc: String::new(),
1117 },
1118 ],
1119 reserved: vec![v2::Reserved {
1120 ordinal: 0,
1121 name: None,
1122 value: Some(7),
1123 }],
1124 };
1125
1126 // enumset Warnings { LOW_FUEL = 0 ICE_RISK = 33 } — the standalone
1127 // form; bit 33 forces the u64 width and is a genuine int64 value.
1128 let warnings = v2::EnumSetDef {
1129 backing_enum: None,
1130 bits: vec![
1131 v2::EnumValue {
1132 name: "LOW_FUEL".to_string(),
1133 value: 0,
1134 doc: String::new(),
1135 },
1136 v2::EnumValue {
1137 name: "ICE_RISK".to_string(),
1138 value: 33,
1139 doc: String::new(),
1140 },
1141 ],
1142 width: v2::IntWidth::U64 as i32,
1143 };
1144
1145 // type PlateText : string [1..86] — character length bounds, two
1146 // genuine uint64 fields behind proto3 `optional`.
1147 let plate_text = v2::TypeDef {
1148 backing: Some(v2::Backing {
1149 kind: Some(v2::backing::Kind::Primitive(
1150 v2::PrimitiveType::String as i32,
1151 )),
1152 }),
1153 constraint: Some(v2::Constraint {
1154 min: None,
1155 max: None,
1156 step: None,
1157 len_min: Some(1),
1158 len_max: Some(86),
1159 pattern: None,
1160 pattern_const: None,
1161 }),
1162 declared_init: None,
1163 init: None,
1164 width: None,
1165 };
1166
1167 // union Sample { speed : Speed gear : Gear }
1168 let sample = v2::UnionDef {
1169 arms: vec![
1170 v2::UnionArm {
1171 name: "speed".to_string(),
1172 ordinal: 1,
1173 type_ref: "Speed".to_string(),
1174 doc: String::new(),
1175 },
1176 v2::UnionArm {
1177 name: "gear".to_string(),
1178 ordinal: 2,
1179 type_ref: "Gear".to_string(),
1180 doc: String::new(),
1181 },
1182 ],
1183 is_result: false,
1184 reserved: Vec::new(),
1185 };
1186
1187 // retries : integer [0..24] = 3 — the boxed `inlineScalar` oneof
1188 // member: the committed regression guard for ADR-0014 Open item 2,
1189 // which established that the Rust-side `Box` is invisible to the
1190 // reflection path. The enclosing field carries the init; the nested
1191 // TypeDef's stays unset.
1192 let retries = v2::Field {
1193 declared_init: Some("3".to_string()),
1194 init: Some(v2::InitValue {
1195 derivable: true,
1196 value: Some("3".to_string()),
1197 }),
1198 ..field(
1199 "retries",
1200 1,
1201 v2::FieldType {
1202 optional: false,
1203 kind: Some(v2::field_type::Kind::InlineScalar(Box::new(v2::TypeDef {
1204 backing: Some(v2::Backing {
1205 kind: Some(v2::backing::Kind::Primitive(
1206 v2::PrimitiveType::Integer as i32,
1207 )),
1208 }),
1209 constraint: Some(v2::Constraint {
1210 min: Some("0".to_string()),
1211 max: Some("24".to_string()),
1212 step: None,
1213 len_min: None,
1214 len_max: None,
1215 pattern: None,
1216 pattern_const: None,
1217 }),
1218 declared_init: None,
1219 init: None,
1220 width: Some(v2::type_def::Width::IntWidth(v2::IntWidth::U8 as i32)),
1221 }))),
1222 },
1223 )
1224 };
1225
1226 // position : (x : Speed, y : Speed) — an anonymous named-field
1227 // composite (typl §11).
1228 let position = field(
1229 "position",
1230 2,
1231 v2::FieldType {
1232 optional: false,
1233 kind: Some(v2::field_type::Kind::Tuple(v2::TupleType {
1234 fields: vec![
1235 v2::TupleField {
1236 name: "x".to_string(),
1237 r#type: Some(named_type("Speed")),
1238 },
1239 v2::TupleField {
1240 name: "y".to_string(),
1241 r#type: Some(named_type("Speed")),
1242 },
1243 ],
1244 })),
1245 },
1246 );
1247
1248 // gears : [Gear; 1..4096] — array bounds are genuine uint64 fields.
1249 let gears = field(
1250 "gears",
1251 3,
1252 v2::FieldType {
1253 optional: false,
1254 kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
1255 element: Some(Box::new(named_type("Gear"))),
1256 min: 1,
1257 max: 4096,
1258 }))),
1259 },
1260 );
1261
1262 // plates : { PlateText -> Gear } [0..53] — map bounds are genuine
1263 // uint64 fields. The field is deprecated, covering the optional
1264 // string on the Field envelope.
1265 let plates = v2::Field {
1266 deprecated: Some("superseded by gears".to_string()),
1267 ..field(
1268 "plates",
1269 4,
1270 v2::FieldType {
1271 optional: false,
1272 kind: Some(v2::field_type::Kind::Map(Box::new(v2::MapType {
1273 key: Some(Box::new(named_type("PlateText"))),
1274 value: Some(Box::new(named_type("Gear"))),
1275 min: 0,
1276 max: 53,
1277 }))),
1278 },
1279 )
1280 };
1281
1282 let snapshot = v2::StructDef {
1283 members: [retries, position, gears, plates]
1284 .into_iter()
1285 .map(|field| v2::StructMember {
1286 member: Some(v2::struct_member::Member::Field(field)),
1287 })
1288 .collect(),
1289 fixed_layout: false,
1290 };
1291
1292 v2::Package {
1293 name: "veh.vocab".to_string(),
1294 decls: vec![
1295 decl("MAX_RETRY", v2::decl::Kind::ConstDef(max_retry)),
1296 decl("Gear", v2::decl::Kind::EnumDef(gear)),
1297 decl("Warnings", v2::decl::Kind::EnumSetDef(warnings)),
1298 decl("PlateText", v2::decl::Kind::TypeDef(plate_text)),
1299 // The union is deprecated — the optional string on the Decl
1300 // envelope.
1301 v2::Decl {
1302 deprecated: Some("use Snapshot".to_string()),
1303 ..decl("Sample", v2::decl::Kind::UnionDef(sample))
1304 },
1305 decl("Snapshot", v2::decl::Kind::StructDef(snapshot)),
1306 ],
1307 interfaces: Vec::new(),
1308 services: Vec::new(),
1309 retired: Vec::new(),
1310 }
1311 }
1312
1313 #[test]
1314 fn protobuf_round_trip_preserves_package() {
1315 let package = fixture();
1316
1317 let buf = v2::to_binary(&package);
1318 let decoded = v2::from_binary(buf.as_slice()).expect("decode must succeed");
1319
1320 assert_eq!(package, decoded);
1321
1322 // The vocabulary fixture rides the same round trip.
1323 let vocabulary = vocabulary_fixture();
1324 let decoded_vocabulary =
1325 v2::from_binary(v2::to_binary(&vocabulary).as_slice()).expect("decode must succeed");
1326 assert_eq!(vocabulary, decoded_vocabulary);
1327
1328 let interface = &decoded.interfaces[0];
1329 let ordinals: Vec<u32> = interface.interactions.iter().map(|d| d.ordinal).collect();
1330 assert_eq!(
1331 ordinals,
1332 [1, 2, 3, 4, 5, 6],
1333 "one ordinal sequence, tombstone counted (ridl §11)"
1334 );
1335 let Some(v2::decl::Kind::ReservedSlot(tombstone)) = &interface.interactions[2].kind else {
1336 panic!("ordinal 3 must decode as a reserved tombstone");
1337 };
1338 assert_eq!(tombstone.name.as_deref(), Some("legacyMode"));
1339 let Some(v2::service_shape::Kind::Inline(inline)) = decoded.services[1]
1340 .shapes
1341 .first()
1342 .and_then(|slot| slot.kind.as_ref())
1343 else {
1344 panic!("veh.adas.logs must decode as an inline shape");
1345 };
1346 assert_eq!(inline.name, "", "an inline shape carries no name");
1347 let references: Vec<&str> = decoded.services[0]
1348 .shapes
1349 .iter()
1350 .filter_map(|slot| match &slot.kind {
1351 Some(v2::service_shape::Kind::InterfaceRef(reference)) => Some(reference.as_str()),
1352 _ => None,
1353 })
1354 .collect();
1355 assert_eq!(
1356 references,
1357 ["VehicleStatus"],
1358 "a service's set carries its references and nothing else"
1359 );
1360 }
1361
1362 #[test]
1363 fn json_round_trip_preserves_package() {
1364 for package in [fixture(), vocabulary_fixture()] {
1365 let json = v2::to_json_pretty(&package).expect("the fixture serializes as IR JSON");
1366 let decoded = v2::from_json(&json).expect("json deserialization must succeed");
1367
1368 assert_eq!(package, decoded);
1369 }
1370 }
1371
1372 /// The interface identity fields the lock design §9 adds — `number` and
1373 /// `provisional` on every `Interface`, an inline shape included, and the
1374 /// package's `retired` list — ride all three encodings unchanged, and the
1375 /// JSON writes them under their canonical names even when they hold their
1376 /// defaults (ADR-0014 decision 2), so a reader can tell `number` 0 from an
1377 /// absent field only by the schema, never by the text.
1378 #[test]
1379 fn number_provisional_and_retired_round_trip_through_json_text_and_binary() {
1380 let mut package = fixture();
1381 package.interfaces[0].number = 4;
1382 package.interfaces[0].provisional = true;
1383 let Some(v2::service_shape::Kind::Inline(inline)) =
1384 package.services[1].shapes[0].kind.as_mut()
1385 else {
1386 panic!("veh.adas.logs holds an inline shape in slot 1");
1387 };
1388 inline.number = 5;
1389 package.retired = vec![
1390 v2::RetiredInterface {
1391 name: "LaneAssist".to_string(),
1392 number: 2,
1393 },
1394 v2::RetiredInterface {
1395 name: "service:veh.hvac.cabin".to_string(),
1396 number: 3,
1397 },
1398 ];
1399
1400 let json = v2::to_json_pretty(&package).expect("the package serializes as IR JSON");
1401 assert_eq!(v2::from_json(&json).expect("the JSON parses back"), package);
1402 let text = v2::to_text_format(&package).expect("the package serializes as prototext");
1403 assert_eq!(
1404 v2::from_text_format(&text).expect("the prototext parses back"),
1405 package
1406 );
1407 assert_eq!(
1408 v2::from_binary(v2::to_binary(&package).as_slice()).expect("the binary decodes"),
1409 package
1410 );
1411
1412 for needle in [
1413 r#""number": 4"#,
1414 r#""provisional": true"#,
1415 r#""number": 5"#,
1416 r#""name": "LaneAssist""#,
1417 r#""name": "service:veh.hvac.cabin""#,
1418 ] {
1419 assert!(
1420 json.contains(needle),
1421 "the JSON must carry {needle}, got: {json}"
1422 );
1423 }
1424
1425 // A default holds its place in the text (decision 2): an interface
1426 // that was never numbered writes `0` and `false`, and a package with
1427 // nothing retired writes an empty list.
1428 let unnumbered = v2::to_json_pretty(&fixture()).expect("the fixture serializes as IR JSON");
1429 for needle in [
1430 r#""number": 0"#,
1431 r#""provisional": false"#,
1432 r#""retired": []"#,
1433 ] {
1434 assert!(
1435 unnumbered.contains(needle),
1436 "a default field must still be written, expected {needle} in: {unnumbered}"
1437 );
1438 }
1439 }
1440
1441 /// A baseline published before the lock existed carries no `number`, no
1442 /// `provisional` and no `retired` field. It still loads — a missing field
1443 /// reads as its default, which is the `number` 0 the lock design §7 names
1444 /// as the one transition case — while an unknown field is still rejected
1445 /// (`json_parse_rejects_an_unknown_field`).
1446 #[test]
1447 fn a_snapshot_lacking_the_number_fields_still_loads() {
1448 let package = v2::from_json(
1449 r#"{"name": "veh.x", "interfaces": [{"name": "LaneKeeping"}], "services": [{"name": "veh.x.s", "shapes": [{"inline": {"name": ""}}]}]}"#,
1450 )
1451 .expect("a pre-lock snapshot loads");
1452
1453 assert_eq!(package.interfaces[0].number, 0);
1454 assert!(!package.interfaces[0].provisional);
1455 let Some(v2::service_shape::Kind::Inline(inline)) =
1456 package.services[0].shapes[0].kind.as_ref()
1457 else {
1458 panic!("the service holds an inline shape");
1459 };
1460 assert_eq!(inline.number, 0);
1461 assert!(!inline.provisional);
1462 assert_eq!(package.retired, Vec::new());
1463 }
1464
1465 /// The prototext read path (ADR-0014 decision 7): both fixtures survive
1466 /// `to_text_format` then `from_text_format` unchanged. With the binary
1467 /// and JSON round trips above, this is what proves all three encodings
1468 /// carry the same IR.
1469 #[test]
1470 fn text_format_round_trip_preserves_package() {
1471 for package in [fixture(), vocabulary_fixture()] {
1472 let text = v2::to_text_format(&package).expect("the fixture serializes as prototext");
1473 let decoded = v2::from_text_format(&text).expect("prototext parsing must succeed");
1474
1475 assert_eq!(package, decoded);
1476 }
1477 }
1478
1479 /// The prototext options ADR-0014 decision 8 fixes — `pretty`,
1480 /// `skip_default_fields(false)`, `print_message_fields_in_index_order`.
1481 /// Any option set round-trips, which is why the round-trip test above
1482 /// cannot guard them.
1483 ///
1484 /// The first two are asserted through a visible consequence. The third is
1485 /// **not guarded here and cannot be on this schema**: every message in
1486 /// `ir.proto` declares its fields in ascending field-number order, and
1487 /// field-number order is also `prost-reflect`'s default, so index order
1488 /// and default order coincide everywhere and dropping the option would
1489 /// change no output. It is set because the schema's ordering is a
1490 /// property of the schema rather than a guarantee, and a message whose
1491 /// declaration order departs from its numbering would otherwise reorder
1492 /// every artifact it appears in.
1493 #[test]
1494 fn text_format_is_pretty_with_defaults_in_index_order() {
1495 let text = v2::to_text_format(&fixture()).expect("the fixture serializes as prototext");
1496
1497 // pretty: nested messages are indented, one field per line.
1498 assert!(
1499 text.contains("\n "),
1500 "pretty printing must indent nested fields, got: {text}"
1501 );
1502 // skip_default_fields(false): a field holding its default is present
1503 // (decision 2 — `ordinal: 0` is read, not inferred from absence).
1504 assert!(
1505 text.contains("is_error: false"),
1506 "a field holding its default must be emitted, got: {text}"
1507 );
1508 // print_message_fields_in_index_order: `name` is field 1 of
1509 // `Package`, so it opens the output.
1510 assert!(
1511 text.starts_with("name:"),
1512 "fields must print in schema index order, got: {text}"
1513 );
1514 }
1515
1516 /// Parses emitted JSON the way ADR-0014 decision 11's conformance test
1517 /// requires: unknown fields rejected, trailing input rejected. Since
1518 /// decision 14 the strict parser is the pbjson-generated `Deserialize`
1519 /// impl, whose default already rejects unknown fields
1520 /// (`ignore_unknown_fields()` stays unset in `build.rs`), so the
1521 /// strictness needs no option to opt into.
1522 fn strict_parse(json: &str) -> v2::Package {
1523 let mut deserializer = serde_json::Deserializer::from_str(json);
1524 let package = <v2::Package as serde::Deserialize>::deserialize(&mut deserializer)
1525 .expect("a strict conformant parser must accept the emitted JSON");
1526 deserializer.end().expect("no trailing input");
1527 package
1528 }
1529
1530 /// The conformance claim of ADR-0014 decision 11: a conformant protobuf
1531 /// JSON parser configured to reject unknown fields accepts the emitted
1532 /// JSON. Re-reading tests that claim itself; asserting on the rendered
1533 /// text would only restate the serializer's behaviour back to itself.
1534 #[test]
1535 fn emitted_json_survives_a_strict_conformant_parse() {
1536 for package in [fixture(), vocabulary_fixture()] {
1537 let json = v2::to_json_pretty(&package).expect("the fixture serializes as IR JSON");
1538 assert_eq!(package, strict_parse(&json));
1539 }
1540 }
1541
1542 #[test]
1543 fn json_renders_timing_bounds_and_fallible_arms_exactly() {
1544 let json = v2::to_json_pretty(&fixture()).expect("the fixture serializes as IR JSON");
1545
1546 // Exactness is visible: timing bounds are exact-decimal microsecond
1547 // strings, never floating-point numbers (ADR-0008 decision 12) —
1548 // under the canonical lowerCamelCase field name (ADR-0014 decision 1).
1549 assert!(
1550 json.contains(r#""minUs": "10000""#),
1551 "the timing bound must be a JSON string, got: {json}"
1552 );
1553 // Both arms of the inline T | E return are visible by name.
1554 assert!(
1555 json.contains(r#""ok": "FaultPage""#),
1556 "the ok arm must render, got: {json}"
1557 );
1558 assert!(
1559 json.contains(r#""err": "DiagError""#),
1560 "the err arm must render, got: {json}"
1561 );
1562 }
1563
1564 /// ADR-0014 decision 8's stringification, tested on genuine 64-bit
1565 /// fields. The timing assertion above proves nothing about it —
1566 /// `Timing.min_us` is `optional string` in the schema — so the claim
1567 /// needs fields whose wire type actually is `uint64` or `int64`.
1568 #[test]
1569 fn json_renders_64_bit_integer_fields_as_strings() {
1570 let json = v2::to_json_pretty(&vocabulary_fixture())
1571 .expect("the vocabulary fixture serializes as IR JSON");
1572
1573 // uint64: the array's upper bound.
1574 assert!(
1575 json.contains(r#""max": "4096""#),
1576 "an array bound must be a JSON string, got: {json}"
1577 );
1578 // uint64 behind proto3 `optional`: the character length bound.
1579 assert!(
1580 json.contains(r#""lenMax": "86""#),
1581 "a length bound must be a JSON string, got: {json}"
1582 );
1583 // int64: the retired enum value and the enum-set bit position.
1584 assert!(
1585 json.contains(r#""value": "7""#),
1586 "a retired enum value must be a JSON string, got: {json}"
1587 );
1588 assert!(
1589 json.contains(r#""value": "33""#),
1590 "an enum-set bit position must be a JSON string, got: {json}"
1591 );
1592 }
1593
1594 #[test]
1595 fn fallible_transport_identity_follows_the_derivation_rule() {
1596 // The ADR-0008 decision 4 rule: interface + interaction ordinal +
1597 // both arm references, in that order.
1598 let fallible = v2::FallibleType {
1599 ok: "FaultPage".to_string(),
1600 err: "DiagError".to_string(),
1601 };
1602 assert_eq!(
1603 v2::fallible_transport_identity("VehicleStatus", 9, &fallible),
1604 "VehicleStatus#9:FaultPage|DiagError"
1605 );
1606
1607 // Derived from the fixture: the fallible query sits at ordinal 5.
1608 let package = fixture();
1609 let interface = &package.interfaces[0];
1610 let query_decl = &interface.interactions[4];
1611 let Some(v2::decl::Kind::QueryDef(query)) = &query_decl.kind else {
1612 panic!("ordinal 5 must be the fallible query");
1613 };
1614 let Some(v2::return_type::Kind::Fallible(arms)) = &query.return_type.as_ref().unwrap().kind
1615 else {
1616 panic!("fetchFaults must return a fallible type");
1617 };
1618 assert_eq!(
1619 v2::fallible_transport_identity(&interface.name, query_decl.ordinal, arms),
1620 "VehicleStatus#5:FaultPage|DiagError"
1621 );
1622 }
1623
1624 /// `Package::shapes` yields the named interfaces first, then the inline
1625 /// shapes of the services — and each shape carries the name it is known by
1626 /// OUTSIDE the package. The fixture's inline shape has `Interface.name ==
1627 /// ""` by construction, so a walk that yielded the interface bare would
1628 /// hand every consumer the empty string; two of the six E2 defects were
1629 /// exactly that.
1630 #[test]
1631 fn shapes_walks_named_interfaces_and_inline_service_shapes() {
1632 let package = fixture();
1633 let walk: Vec<(&str, bool, usize)> = package
1634 .shapes()
1635 .map(|shape| {
1636 (
1637 shape.name,
1638 shape.is_inline(),
1639 shape.interface.interactions.len(),
1640 )
1641 })
1642 .collect();
1643 assert_eq!(
1644 walk,
1645 [("VehicleStatus", false, 6), ("veh.adas.logs", true, 1)],
1646 "the named interface, then the inline shape under the service's \
1647 dotted name",
1648 );
1649
1650 // The fixture's third shape-bearing declaration is `service
1651 // veh.adas.status : VehicleStatus`, which names an interface already in
1652 // the walk. Yielding it too would visit `VehicleStatus` twice.
1653 assert_eq!(package.services.len(), 2, "one reference form, one inline");
1654 assert!(
1655 !package
1656 .shapes()
1657 .any(|shape| shape.name == "veh.adas.status"),
1658 "a service naming an interface contributes no shape of its own",
1659 );
1660 }
1661
1662 /// The owning service is carried because `Service.visibility` is the
1663 /// authoritative one: an inline shape's own field is
1664 /// `VISIBILITY_UNSPECIFIED` by construction, which is not "internal" and
1665 /// not "public".
1666 #[test]
1667 fn shape_visibility_reads_the_owning_service_for_an_inline_shape() {
1668 let package = fixture();
1669 let shapes: Vec<v2::InterfaceShape<'_>> = package.shapes().collect();
1670
1671 let named = shapes[0];
1672 assert!(named.service.is_none());
1673 assert_eq!(named.visibility(), v2::Visibility::Public as i32);
1674 assert_eq!(named.visibility(), named.interface.visibility);
1675
1676 let inline = shapes[1];
1677 assert_eq!(
1678 inline.interface.visibility,
1679 v2::Visibility::Unspecified as i32,
1680 "the trap: an inline shape's own visibility field is unset",
1681 );
1682 assert_eq!(
1683 inline.service.expect("an inline shape has an owner").name,
1684 "veh.adas.logs",
1685 );
1686 assert_eq!(
1687 inline.visibility(),
1688 v2::Visibility::Public as i32,
1689 "the accessor reads the owning service's, never the unset field",
1690 );
1691 }
1692
1693 /// A package with no service at all still walks its interfaces, and a
1694 /// package with neither yields nothing — the emptiness both backends test
1695 /// for before emitting any interaction vocabulary.
1696 #[test]
1697 fn shapes_is_empty_only_when_the_package_declares_no_shape() {
1698 let mut package = fixture();
1699 package.services.clear();
1700 assert_eq!(package.shapes().count(), 1);
1701
1702 package.interfaces.clear();
1703 assert_eq!(package.shapes().count(), 0);
1704 }
1705
1706 /// A dotted reference contributes its qualifier; a bare one contributes
1707 /// nothing. Every recursive path through `walk_field_type` — array
1708 /// element, tuple field, map key, map value, stream element — carries a
1709 /// distinct qualifier, so no path's absence can hide behind another
1710 /// path's presence: deleting any one arm's body changes the expected set
1711 /// this test compares against, rather than leaving it unchanged.
1712 #[test]
1713 fn referenced_packages_finds_qualifiers_at_depth() {
1714 fn named(reference: &str) -> v2::FieldType {
1715 v2::FieldType {
1716 kind: Some(v2::field_type::Kind::Named(reference.to_string())),
1717 ..Default::default()
1718 }
1719 }
1720
1721 fn fixed(payload: v2::FieldType) -> v2::decl::Kind {
1722 v2::decl::Kind::FixedDef(v2::FixedDef {
1723 payload: Some(payload),
1724 })
1725 }
1726
1727 let package = v2::Package {
1728 name: "veh.cluster".to_string(),
1729 decls: vec![
1730 v2::Decl {
1731 name: "Local".to_string(),
1732 kind: Some(v2::decl::Kind::SignalDef(v2::SignalDef {
1733 payload: "Speed".to_string(),
1734 ..Default::default()
1735 })),
1736 ..Default::default()
1737 },
1738 v2::Decl {
1739 name: "Stamped".to_string(),
1740 kind: Some(v2::decl::Kind::SignalDef(v2::SignalDef {
1741 payload: "ridl.std.Timestamp".to_string(),
1742 ..Default::default()
1743 })),
1744 ..Default::default()
1745 },
1746 v2::Decl {
1747 name: "ArrLabels".to_string(),
1748 kind: Some(fixed(v2::FieldType {
1749 kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
1750 element: Some(Box::new(named("veh.arr.Label"))),
1751 min: 0,
1752 max: 32,
1753 }))),
1754 ..Default::default()
1755 })),
1756 ..Default::default()
1757 },
1758 v2::Decl {
1759 name: "TupThing".to_string(),
1760 kind: Some(fixed(v2::FieldType {
1761 kind: Some(v2::field_type::Kind::Tuple(v2::TupleType {
1762 fields: vec![v2::TupleField {
1763 name: "x".to_string(),
1764 r#type: Some(named("veh.tup.X")),
1765 }],
1766 })),
1767 ..Default::default()
1768 })),
1769 ..Default::default()
1770 },
1771 v2::Decl {
1772 name: "MapThing".to_string(),
1773 kind: Some(fixed(v2::FieldType {
1774 kind: Some(v2::field_type::Kind::Map(Box::new(v2::MapType {
1775 key: Some(Box::new(named("veh.key.X"))),
1776 value: Some(Box::new(named("veh.val.X"))),
1777 min: 0,
1778 max: 8,
1779 }))),
1780 ..Default::default()
1781 })),
1782 ..Default::default()
1783 },
1784 v2::Decl {
1785 name: "StreamThing".to_string(),
1786 kind: Some(fixed(v2::FieldType {
1787 kind: Some(v2::field_type::Kind::Stream(v2::StreamType {
1788 element: Some(v2::stream_type::Element::Named(
1789 "veh.strm.X".to_string(),
1790 )),
1791 })),
1792 ..Default::default()
1793 })),
1794 ..Default::default()
1795 },
1796 ],
1797 ..Default::default()
1798 };
1799
1800 let found = v2::referenced_packages(&package);
1801 let expected: std::collections::BTreeSet<String> = [
1802 "ridl.std", "veh.arr", "veh.tup", "veh.key", "veh.val", "veh.strm",
1803 ]
1804 .into_iter()
1805 .map(str::to_string)
1806 .collect();
1807 assert_eq!(
1808 found, expected,
1809 "each recursive path must contribute its own distinct qualifier"
1810 );
1811 assert!(
1812 !found.contains("Speed") && !found.contains("veh.cluster"),
1813 "a bare reference contributes no package: {found:?}",
1814 );
1815 }
1816
1817 /// An empty package references nothing — the negative case the emit rule in
1818 /// `ridlc` depends on.
1819 #[test]
1820 fn referenced_packages_is_empty_without_references() {
1821 let package = v2::Package {
1822 name: "veh.solo".to_string(),
1823 ..Default::default()
1824 };
1825 assert!(v2::referenced_packages(&package).is_empty());
1826 }
1827
1828 /// Below prost's recursion limit at two message levels per nesting level
1829 /// — the depth ADR-0014 decision 12 measured as round-tripping correctly.
1830 /// Since decision 14 these two constants bound the prototext transcode
1831 /// alone: JSON no longer transcodes and carries its own read-side
1832 /// ceiling, tested separately below.
1833 const NESTING_BELOW_LIMIT: usize = 45;
1834 /// Past the limit today. The tests assert the outcome — an error, never a
1835 /// panic — not the exact threshold, so a prost release that moves the
1836 /// limit moves these constants, not the assertions.
1837 const NESTING_PAST_LIMIT: usize = 60;
1838
1839 /// One declaration whose payload nests `depth` levels of inline arrays —
1840 /// each level costs two message levels on the wire (`FieldType` plus
1841 /// `ArrayType`), the arithmetic ADR-0014 decision 12 records against
1842 /// prost's recursion limit.
1843 fn nested_package(depth: usize) -> v2::Package {
1844 let mut payload = v2::FieldType {
1845 optional: false,
1846 kind: Some(v2::field_type::Kind::Primitive(
1847 v2::PrimitiveType::Integer as i32,
1848 )),
1849 };
1850 for _ in 0..depth {
1851 payload = v2::FieldType {
1852 optional: false,
1853 kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
1854 element: Some(Box::new(payload)),
1855 min: 1,
1856 max: 1,
1857 }))),
1858 };
1859 }
1860 v2::Package {
1861 name: "veh.deep".to_string(),
1862 decls: vec![v2::Decl {
1863 name: "deep".to_string(),
1864 kind: Some(v2::decl::Kind::FixedDef(v2::FixedDef {
1865 payload: Some(payload),
1866 })),
1867 ..Default::default()
1868 }],
1869 ..Default::default()
1870 }
1871 }
1872
1873 /// One package whose nesting sits exactly `levels` message levels below
1874 /// the `Package` root — the unit the derived binary encoding's bound is
1875 /// stated in (the IR specification, "The derived encodings").
1876 ///
1877 /// The chain under a `FixedDef` costs three levels before any nesting
1878 /// (`Decl`, `FixedDef`, the outermost `FieldType`) and two per array level
1879 /// (`ArrayType`, `FieldType`), so an array-only chain reaches the odd
1880 /// depths alone. One tuple level costs three (`FieldType`, `TupleType`,
1881 /// `TupleField`, then the `FieldType` the next level counts), which is what
1882 /// reaches the even depths. Both shapes are what the front end lowers, so
1883 /// neither is a construction the schema would not otherwise see.
1884 fn package_at_message_depth(levels: usize) -> v2::Package {
1885 assert!(levels >= 3, "the chain costs three levels before nesting");
1886 let (arrays, tuple) = if levels % 2 == 1 {
1887 ((levels - 3) / 2, false)
1888 } else {
1889 assert!(levels >= 6, "one tuple level costs three");
1890 ((levels - 6) / 2, true)
1891 };
1892
1893 let mut payload = v2::FieldType {
1894 optional: false,
1895 kind: Some(v2::field_type::Kind::Primitive(
1896 v2::PrimitiveType::Integer as i32,
1897 )),
1898 };
1899 for _ in 0..arrays {
1900 payload = v2::FieldType {
1901 optional: false,
1902 kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
1903 element: Some(Box::new(payload)),
1904 min: 1,
1905 max: 1,
1906 }))),
1907 };
1908 }
1909 if tuple {
1910 payload = v2::FieldType {
1911 optional: false,
1912 kind: Some(v2::field_type::Kind::Tuple(v2::TupleType {
1913 fields: vec![v2::TupleField {
1914 name: "f0".to_string(),
1915 r#type: Some(payload),
1916 }],
1917 })),
1918 };
1919 }
1920 v2::Package {
1921 name: "veh.deep".to_string(),
1922 decls: vec![v2::Decl {
1923 name: "deep".to_string(),
1924 kind: Some(v2::decl::Kind::FixedDef(v2::FixedDef {
1925 payload: Some(payload),
1926 })),
1927 ..Default::default()
1928 }],
1929 ..Default::default()
1930 }
1931 }
1932
1933 /// The nesting of JSON objects in a canonical artifact, which in the
1934 /// protobuf JSON mapping is the nesting of messages: every message is an
1935 /// object, a repeated field is an array of them, and the schema declares no
1936 /// `map<>` field. The root `Package` object is included, so a caller
1937 /// counting levels *below* the root subtracts one. Brackets inside a string
1938 /// literal do not count.
1939 fn message_nesting(json: &str) -> usize {
1940 let (mut depth, mut max) = (0usize, 0usize);
1941 let (mut in_string, mut escaped) = (false, false);
1942 for byte in json.bytes() {
1943 if in_string {
1944 if escaped {
1945 escaped = false;
1946 } else if byte == b'\\' {
1947 escaped = true;
1948 } else if byte == b'"' {
1949 in_string = false;
1950 }
1951 continue;
1952 }
1953 match byte {
1954 b'"' => in_string = true,
1955 b'{' => {
1956 depth += 1;
1957 max = max.max(depth);
1958 }
1959 b'}' => depth = depth.saturating_sub(1),
1960 _ => {}
1961 }
1962 }
1963 max
1964 }
1965
1966 /// [`package_at_message_depth`] builds what it claims, on both parities.
1967 /// Without this the two bound tests below would pin a depth nobody
1968 /// measured.
1969 #[test]
1970 fn package_at_message_depth_builds_the_depth_it_names() {
1971 with_sized_stack(|| {
1972 for levels in [3, 6, 7, 99, 100, 101] {
1973 let json = v2::to_json_pretty(&package_at_message_depth(levels))
1974 .expect("the writer is unrestricted at these depths");
1975 assert_eq!(
1976 message_nesting(&json) - 1,
1977 levels,
1978 "the chain must nest {levels} message levels below the root"
1979 );
1980 }
1981 });
1982 }
1983
1984 /// The derived binary encoding's bound, stated by the IR specification and
1985 /// pinned here: 100 message levels below the root round-trip.
1986 ///
1987 /// `to_binary` writes any depth; it is `from_binary` that stops, at prost's
1988 /// `RECURSION_LIMIT` of 100, decremented once per nested message on decode
1989 /// and not consulted on encode. The test asserts the outcome, not prost's
1990 /// constant: a prost release that moves the limit moves these two tests and
1991 /// the specification's paragraph together.
1992 #[test]
1993 fn binary_round_trip_at_100_message_levels_succeeds() {
1994 let package = package_at_message_depth(100);
1995 let bytes = v2::to_binary(&package);
1996 let decoded = v2::from_binary(&bytes).expect("100 message levels decode");
1997 assert_eq!(package, decoded);
1998 }
1999
2000 /// One level past that bound the binary reader refuses — an error, never a
2001 /// panic — while the canonical encoding carries the same package. This is
2002 /// the asymmetry the specification states as the reason binary is derived
2003 /// rather than canonical (driftsys/ridl#231).
2004 #[test]
2005 fn binary_decode_at_101_message_levels_returns_an_error() {
2006 let package = package_at_message_depth(101);
2007 let bytes = v2::to_binary(&package);
2008 let error = v2::from_binary(&bytes).expect_err("101 message levels must fail, not panic");
2009 assert!(
2010 error.to_string().contains("recursion limit reached"),
2011 "the error must name the limit, got: {error}"
2012 );
2013
2014 let json = v2::to_json_pretty(&package).expect("the canonical encoding has no such bound");
2015 assert_eq!(
2016 package,
2017 v2::from_json(&json).expect("the canonical encoding round-trips the same package")
2018 );
2019 }
2020
2021 /// The write side after ADR-0014 decision 14: the pbjson-generated
2022 /// writer recurses the typed message directly — no transcode, so no
2023 /// message-level recursion limit — and 400 levels of array nesting,
2024 /// roughly eight times the ceiling decision 12 recorded, serialize and
2025 /// round-trip. Run on an explicitly sized stack: the writer recurses on
2026 /// the caller's stack, and debug-build frames at this depth overflow the
2027 /// default test-thread stack (the reader sizes its own thread inside
2028 /// `from_json`).
2029 #[test]
2030 fn json_round_trip_at_400_nested_levels_succeeds() {
2031 with_sized_stack(|| {
2032 let package = nested_package(400);
2033 let json = v2::to_json_pretty(&package).expect("the writer has no message-level limit");
2034 let decoded = v2::from_json(&json).expect("the reader parses within its ceiling");
2035 assert_eq!(package, decoded);
2036 });
2037 }
2038
2039 /// The one error path on the JSON write side (ADR-0014 decision 14),
2040 /// and it is new with the generated impl, not a survivor of the
2041 /// transcode's: an `i32` enum field holding a discriminant outside the
2042 /// schema — data, not depth — which the retired reflection path
2043 /// serialized successfully as its bare number. The checker never
2044 /// produces one, so there is no CLI route to this failure; it is pinned
2045 /// here at the crate surface.
2046 #[test]
2047 fn json_serialization_of_an_out_of_schema_discriminant_returns_an_error() {
2048 let mut package = fixture();
2049 package.decls[0].visibility = 999;
2050 let err = v2::to_json_pretty(&package)
2051 .expect_err("an out-of-schema discriminant must fail, not panic");
2052 let message = err.to_string();
2053 assert!(
2054 message.contains("canonical protobuf JSON"),
2055 "the error must name the encoding that failed, got: {message}"
2056 );
2057 assert!(
2058 message.contains("discriminant outside the schema"),
2059 "the error must name the known cause, got: {message}"
2060 );
2061 }
2062
2063 /// The strictness ADR-0014 decision 11 relies on is the generated
2064 /// deserializer's default: `ignore_unknown_fields()` is the opt-out and
2065 /// stays unset, so a field the schema does not declare is an error,
2066 /// never silently dropped.
2067 #[test]
2068 fn json_parse_rejects_an_unknown_field() {
2069 let error = v2::from_json(r#"{"name": "veh.deep", "notAField": 1}"#)
2070 .expect_err("an unknown field must be rejected");
2071 assert!(
2072 error.to_string().contains("unknown field"),
2073 "the error must name the defect, got: {error}"
2074 );
2075 }
2076
2077 /// A reader narrowing ADR-0014 decision 14 records: the proto3 JSON
2078 /// mapping expects parsers to accept numeric enum values, and the
2079 /// generated deserializer does — within the schema's range. A
2080 /// discriminant outside it (`"visibility": 77`) is rejected, where the
2081 /// retired reflection reader accepted it — and the retired *writer*
2082 /// emitted exactly such a number for an out-of-schema discriminant.
2083 /// Pinned so the narrowing stays a decision rather than an accident: a
2084 /// future mechanism change must confront this test.
2085 #[test]
2086 fn json_parse_rejects_an_out_of_range_numeric_enum_value() {
2087 let with_visibility =
2088 |value: &str| format!(r#"{{"name": "veh.x", "decls": [{{"visibility": {value}}}]}}"#);
2089 v2::from_json(&with_visibility("1"))
2090 .expect("an in-range numeric enum value parses, as the mapping expects");
2091 let error = v2::from_json(&with_visibility("77"))
2092 .expect_err("an out-of-range numeric enum value must be rejected");
2093 assert!(
2094 error.to_string().contains("invalid value: integer `77`"),
2095 "the error must name the value, got: {error}"
2096 );
2097 }
2098
2099 /// A reader narrowing ADR-0014 decision 14 records: the mapping accepts
2100 /// float and exponent notation for integer fields (`"min": 1.0`), and
2101 /// the retired reflection reader did; the generated deserializer
2102 /// rejects both. Pinned for the same reason as the numeric-enum case
2103 /// above.
2104 #[test]
2105 fn json_parse_rejects_a_float_form_integer() {
2106 for spelling in ["1.0", "1e0"] {
2107 let error = v2::from_json(&format!(
2108 r#"{{"name": "veh.x", "decls": [{{"fixedDef": {{"payload": {{"array": {{"min": {spelling}}}}}}}}}]}}"#,
2109 ))
2110 .expect_err("a float-form integer must be rejected");
2111 assert!(
2112 error.to_string().contains("did not match any variant"),
2113 "the integer field's deserializer must be the one refusing `{spelling}`, \
2114 got: {error}"
2115 );
2116 }
2117 }
2118
2119 /// A reader narrowing ADR-0014 decision 14 records: `null` for a
2120 /// repeated field (`"decls": null`), which the retired reflection
2121 /// reader read as empty, is rejected. `null` for an optional scalar or
2122 /// message field is still accepted — parity with the retired reader,
2123 /// asserted alongside so the narrowing's edge is pinned from both
2124 /// sides.
2125 #[test]
2126 fn json_parse_rejects_null_for_a_repeated_field() {
2127 let error = v2::from_json(r#"{"name": "veh.x", "decls": null}"#)
2128 .expect_err("null for a repeated field must be rejected");
2129 assert!(
2130 error.to_string().contains("invalid type: null"),
2131 "the error must name the null, got: {error}"
2132 );
2133 v2::from_json(r#"{"name": "veh.x", "decls": [{"deprecated": null}]}"#)
2134 .expect("null for an optional scalar field still parses");
2135 }
2136
2137 /// A reader narrowing ADR-0014 decision 14 records: a duplicate JSON
2138 /// key, which the retired reflection reader resolved last-wins, is
2139 /// rejected.
2140 #[test]
2141 fn json_parse_rejects_a_duplicate_key() {
2142 let error = v2::from_json(r#"{"name": "a", "name": "b"}"#)
2143 .expect_err("a duplicate key must be rejected");
2144 assert!(
2145 error.to_string().contains("duplicate field `name`"),
2146 "the error must name the duplicated field, got: {error}"
2147 );
2148 }
2149
2150 /// The read-side ceiling (ADR-0014 decision 14): nesting past 1,000
2151 /// bracket levels returns an error before the parse begins — a
2152 /// diagnostic, where unbounded recursion would eventually abort on a
2153 /// stack overflow no caller can catch. The input is real writer output:
2154 /// past the ceiling the asymmetry is deliberate — the writer is
2155 /// unrestricted, the reader is not.
2156 #[test]
2157 fn json_parse_past_the_nesting_ceiling_returns_an_error() {
2158 with_sized_stack(|| {
2159 let json = v2::to_json_pretty(&nested_package(500))
2160 .expect("the writer is unrestricted at this depth");
2161 let error = v2::from_json(&json).expect_err("the reader must refuse past its ceiling");
2162 assert!(
2163 error.to_string().contains("1000 JSON levels"),
2164 "the error must name the ceiling, got: {error}"
2165 );
2166 });
2167 }
2168
2169 /// The ceiling is exact: 1,000 open brackets pass the scan and reach the
2170 /// parser — which then rejects the input as not a package — and 1,001 do
2171 /// not. The scan runs before the parse, so the over-ceiling probe needs
2172 /// no valid JSON behind its brackets.
2173 #[test]
2174 fn json_nesting_ceiling_binds_exactly_at_1000() {
2175 let at = v2::from_json(&"[".repeat(1_000)).expect_err("an array is not a package");
2176 assert!(
2177 !at.to_string().contains("JSON levels"),
2178 "at the ceiling the parser, not the scan, must be the one refusing, got: {at}"
2179 );
2180
2181 let past = v2::from_json(&"[".repeat(1_001)).expect_err("past the ceiling, the scan");
2182 assert!(
2183 past.to_string().contains("1000 JSON levels"),
2184 "past the ceiling the error must name it, got: {past}"
2185 );
2186 }
2187
2188 /// The nesting scan behind the ceiling: brackets count only outside
2189 /// string literals, an escaped quote does not end a literal, an escaped
2190 /// backslash does not disarm the real closing quote after it, and a
2191 /// stray closer never underflows the running depth.
2192 #[test]
2193 fn nesting_scan_counts_brackets_outside_string_literals_only() {
2194 // Plain structural nesting counts every open bracket.
2195 assert_eq!(v2::max_json_nesting(r#"{"a": [{"b": []}]}"#), 4);
2196 // Brackets inside a string literal do not count.
2197 assert_eq!(v2::max_json_nesting(r#"{"doc": "{[[[{"}"#), 1);
2198 // An escaped quote does not end the literal, so the brackets after
2199 // it are still inside it.
2200 assert_eq!(v2::max_json_nesting(r#"{"doc": "a\"[[[", "x": []}"#), 2);
2201 // An escaped backslash does not escape the closing quote: the
2202 // literal ends, and the brackets after it count.
2203 assert_eq!(v2::max_json_nesting(r#"{"doc": "a\\", "x": [[]]}"#), 3);
2204 // A stray closer saturates at zero rather than underflowing.
2205 assert_eq!(v2::max_json_nesting("]]]{"), 1);
2206 }
2207
2208 /// The prototext form of [`nested_package`], built by hand for the same
2209 /// reason [`nested_json`] is: past the limit the serializer rejects the
2210 /// package, so its prototext cannot come from [`v2::to_text_format`].
2211 fn nested_text(depth: usize) -> String {
2212 let mut payload = "primitive: PRIMITIVE_TYPE_INTEGER".to_string();
2213 for _ in 0..depth {
2214 payload = format!("array {{ element {{ {payload} }} min: 1 max: 1 }}");
2215 }
2216 format!(
2217 r#"name: "veh.deep" decls {{ name: "deep" fixed_def {{ payload {{ {payload} }} }} }}"#
2218 )
2219 }
2220
2221 /// The prototext write path carries the same recursion-limit failure mode
2222 /// as JSON — both go through the one transcode (ADR-0014 decision 12) —
2223 /// and reports it as an error naming its own encoding, never a panic.
2224 #[test]
2225 fn text_serialization_past_the_nesting_limit_returns_an_error() {
2226 let err = v2::to_text_format(&nested_package(NESTING_PAST_LIMIT))
2227 .expect_err("serialization past the recursion limit must fail, not panic");
2228 let message = err.to_string();
2229 assert!(
2230 message.contains("recursion limit"),
2231 "the error must name the nesting limit as the known cause, got: {message}"
2232 );
2233 assert!(
2234 message.contains("prototext"),
2235 "the error must name the encoding that failed, got: {message}"
2236 );
2237 }
2238
2239 /// Runs `test` on a thread whose stack fits the recursion the test
2240 /// drives on its own thread. Two groups need one. The prototext parser
2241 /// recurses once per message level with debug-build frames large enough
2242 /// that the default 2 MiB test-thread stack overflows near 45 array
2243 /// levels — under prost's own recursion limit, so the depths
2244 /// [`NESTING_BELOW_LIMIT`] and [`NESTING_PAST_LIMIT`] pin are
2245 /// unreachable on that stack; the production paths are unaffected, since
2246 /// the toolchain writes prototext and never parses it (`ridl diff` and
2247 /// the baselines stay `.ir.json`, ADR-0014 decision 5). And the deep
2248 /// JSON tests drive the pbjson-generated writer, which recurses on the
2249 /// caller's stack (ADR-0014 decision 14 — only the reader sizes a
2250 /// thread of its own, inside `from_json`).
2251 fn with_sized_stack(test: impl FnOnce() + Send + 'static) {
2252 let outcome = std::thread::Builder::new()
2253 .stack_size(16 * 1024 * 1024)
2254 .spawn(test)
2255 .expect("spawn the large-stack test thread")
2256 .join();
2257 if let Err(payload) = outcome {
2258 std::panic::resume_unwind(payload);
2259 }
2260 }
2261
2262 /// The read direction: the text-format parser itself has no depth limit,
2263 /// so the failure is the transcode out of the dynamic message, mapped
2264 /// into the error return instead of expected on (ADR-0014 decision 12).
2265 #[test]
2266 fn text_parse_past_the_nesting_limit_returns_an_error() {
2267 with_sized_stack(|| {
2268 let error = v2::from_text_format(&nested_text(NESTING_PAST_LIMIT))
2269 .expect_err("parsing past the recursion limit must fail, not panic");
2270
2271 // Assert *which* stage failed: prost's transcoding decoder says
2272 // "recursion limit reached", and a parse-stage failure would
2273 // render through the `Parse` variant instead.
2274 let message = error.to_string();
2275 assert!(
2276 message.contains("recursion limit reached"),
2277 "the transcode out of the dynamic message must be the failing \
2278 stage, got: {message}"
2279 );
2280 });
2281 }
2282
2283 /// The prototext bound must not tighten silently either: below the limit
2284 /// the package still serializes and round-trips.
2285 #[test]
2286 fn text_round_trip_below_the_nesting_limit_succeeds() {
2287 with_sized_stack(|| {
2288 let package = nested_package(NESTING_BELOW_LIMIT);
2289 let text = v2::to_text_format(&package)
2290 .expect("below the recursion limit, serialization succeeds");
2291 let decoded =
2292 v2::from_text_format(&text).expect("below the recursion limit, parsing succeeds");
2293 assert_eq!(package, decoded);
2294 });
2295 }
2296}
2297
2298#[cfg(test)]
2299mod vacuous_constraint {
2300 use crate::v2;
2301
2302 /// A constraint with every field absent. Each test sets only the field it
2303 /// is about, so no assertion can pass through a neighbouring field.
2304 fn constraint() -> v2::Constraint {
2305 v2::Constraint {
2306 min: None,
2307 max: None,
2308 step: None,
2309 len_min: None,
2310 len_max: None,
2311 pattern: None,
2312 pattern_const: None,
2313 }
2314 }
2315
2316 #[test]
2317 fn an_absent_or_empty_constraint_is_vacuous() {
2318 assert!(v2::constraint_is_vacuous(None));
2319 assert!(v2::constraint_is_vacuous(Some(&constraint())));
2320 }
2321
2322 #[test]
2323 fn vacuous_constraint_ignores_step() {
2324 // A declared step alone leaves a constructor nothing to check: nothing
2325 // checks a step today, and the design rounds to the lattice rather
2326 // than rejecting (design spec, Deferred, not yet implemented).
2327 let stepped = v2::Constraint {
2328 step: Some("0.5".to_string()),
2329 ..constraint()
2330 };
2331 assert!(v2::constraint_is_vacuous(Some(&stepped)));
2332 }
2333
2334 /// Every constrained field on its own. A fixture setting a pair — `min`
2335 /// with `max`, or `len_min` with `len_max` — cannot tell a predicate that
2336 /// reads both from one that reads either, so each bound here is one-sided.
2337 /// The paired shapes are pinned separately by
2338 /// [`a_bound_pair_set_together_is_non_vacuous`], which a one-sided fixture
2339 /// cannot do.
2340 #[test]
2341 fn any_single_constrained_field_is_non_vacuous() {
2342 let cases = [
2343 (
2344 "min",
2345 v2::Constraint {
2346 min: Some("0.0".to_string()),
2347 ..constraint()
2348 },
2349 ),
2350 (
2351 "max",
2352 v2::Constraint {
2353 max: Some("250.0".to_string()),
2354 ..constraint()
2355 },
2356 ),
2357 (
2358 "len_min",
2359 v2::Constraint {
2360 len_min: Some(1),
2361 ..constraint()
2362 },
2363 ),
2364 (
2365 "len_max",
2366 v2::Constraint {
2367 len_max: Some(256),
2368 ..constraint()
2369 },
2370 ),
2371 (
2372 "pattern",
2373 v2::Constraint {
2374 pattern: Some("^[a-z]+$".to_string()),
2375 ..constraint()
2376 },
2377 ),
2378 (
2379 "pattern_const",
2380 v2::Constraint {
2381 pattern_const: Some("NAME_PATTERN".to_string()),
2382 ..constraint()
2383 },
2384 ),
2385 ];
2386 for (field, case) in cases {
2387 assert!(
2388 !v2::constraint_is_vacuous(Some(&case)),
2389 "`{field}` alone must be non-vacuous"
2390 );
2391 }
2392 }
2393
2394 /// The two shapes the checker actually emits: a declared range, and the
2395 /// typl §4.4 default `[0..256]` every string and bytes type carries.
2396 ///
2397 /// A one-sided fixture cannot pin these. A predicate reading each bound as
2398 /// a pair — `(c.min.is_none() == c.max.is_none())` and the same for the
2399 /// length bounds — passes every one-sided case and still reports both
2400 /// shapes below as vacuous, which would drop the range check from every
2401 /// bounded number and every string.
2402 #[test]
2403 fn a_bound_pair_set_together_is_non_vacuous() {
2404 let ranged = v2::Constraint {
2405 min: Some("0.0".to_string()),
2406 max: Some("250.0".to_string()),
2407 ..constraint()
2408 };
2409 assert!(!v2::constraint_is_vacuous(Some(&ranged)));
2410
2411 let default_length = v2::Constraint {
2412 len_min: Some(0),
2413 len_max: Some(256),
2414 ..constraint()
2415 };
2416 assert!(!v2::constraint_is_vacuous(Some(&default_length)));
2417 }
2418}
2419
2420#[cfg(test)]
2421mod system_round_trip {
2422 use crate::v2;
2423
2424 fn attribute(namespace: &str, key: &str, value: Option<v2::AttributeValue>) -> v2::Attribute {
2425 v2::Attribute {
2426 namespace: namespace.to_string(),
2427 key: key.to_string(),
2428 value,
2429 }
2430 }
2431
2432 fn scalar(text: &str) -> v2::AttributeValue {
2433 v2::AttributeValue {
2434 kind: Some(v2::attribute_value::Kind::Scalar(text.to_string())),
2435 }
2436 }
2437
2438 fn list(items: Vec<v2::AttributeValue>) -> v2::AttributeValue {
2439 v2::AttributeValue {
2440 kind: Some(v2::attribute_value::Kind::List(v2::AttributeList { items })),
2441 }
2442 }
2443
2444 fn interface(catalog: &str, name: &str, inline: bool) -> Option<v2::InterfaceRef> {
2445 Some(v2::InterfaceRef {
2446 catalog: catalog.to_string(),
2447 name: name.to_string(),
2448 inline,
2449 })
2450 }
2451
2452 fn endpoint(component: &str, instance: &str, machine: &str) -> v2::Endpoint {
2453 v2::Endpoint {
2454 component: component.to_string(),
2455 instance: instance.to_string(),
2456 machine: machine.to_string(),
2457 }
2458 }
2459
2460 /// A reduced rsdl reference Appendix A: `Cruise` with two instances
2461 /// offering `veh.adas.cruise` and requiring `LaneAssist`, the implicit
2462 /// component of `veh.diag.access`, one distribution and one deployment.
2463 /// Every message of `system.proto` appears at least once, with every
2464 /// scalar set to a value other than its default — a flag and a nested-list
2465 /// attribute value included — so a round trip that drops a field is
2466 /// caught.
2467 fn fixture() -> v2::System {
2468 let link = v2::Link {
2469 interface: interface("veh.diag", "veh.diag.access", true),
2470 service: "veh.diag.access".to_string(),
2471 consumer: Some(endpoint("veh.topology.Backend", "Unit", "Cloud")),
2472 producer: Some(endpoint("veh.diag.access", "Unit", "AdasHpc")),
2473 crossing: v2::Crossing::OffBoard as i32,
2474 };
2475 v2::System {
2476 name: "Vehicle".to_string(),
2477 package: "veh.topology".to_string(),
2478 labels: vec!["ASIL_B".to_string()],
2479 attributes: vec![attribute("rust", "crate", Some(scalar("\"vehicle\"")))],
2480 members: vec![
2481 v2::MemberLine {
2482 component: "veh.topology.Cruise".to_string(),
2483 attributes: vec![attribute("linux", "pinned", None)],
2484 },
2485 v2::MemberLine {
2486 component: "veh.diag.access".to_string(),
2487 attributes: vec![],
2488 },
2489 ],
2490 components: vec![
2491 v2::Component {
2492 name: "Cruise".to_string(),
2493 package: "veh.topology".to_string(),
2494 implicit: false,
2495 external: true,
2496 instances: vec!["primary".to_string(), "backup".to_string()],
2497 offers: vec![v2::Offer {
2498 service: "veh.adas.cruise".to_string(),
2499 attributes: vec![attribute("someip", "serviceId", Some(scalar("4097")))],
2500 }],
2501 requires: vec![v2::Require {
2502 interface: interface("veh.adas", "LaneAssist", false),
2503 service: "veh.adas.lane".to_string(),
2504 producer: "veh.topology.Lane".to_string(),
2505 attributes: vec![attribute(
2506 "linux",
2507 "cpuset",
2508 Some(list(vec![scalar("2"), list(vec![scalar("3")])])),
2509 )],
2510 }],
2511 labels: vec!["ASIL_B".to_string()],
2512 attributes: vec![attribute("rust", "crate", None)],
2513 },
2514 v2::Component {
2515 name: "veh.diag.access".to_string(),
2516 package: String::new(),
2517 implicit: true,
2518 external: false,
2519 instances: vec!["Unit".to_string()],
2520 offers: vec![v2::Offer {
2521 service: "veh.diag.access".to_string(),
2522 attributes: vec![],
2523 }],
2524 requires: vec![],
2525 labels: vec![],
2526 attributes: vec![],
2527 },
2528 ],
2529 producers: vec![v2::Producer {
2530 service: "veh.adas.cruise".to_string(),
2531 component: "veh.topology.Cruise".to_string(),
2532 instances: vec!["primary".to_string(), "backup".to_string()],
2533 not_yet_realizable: true,
2534 }],
2535 grants: vec![v2::Grant {
2536 component: "veh.topology.Backend".to_string(),
2537 external: true,
2538 regions: vec!["veh.adas".to_string(), "veh.diag".to_string()],
2539 }],
2540 regions: vec![v2::Region {
2541 catalog: "veh.diag".to_string(),
2542 interfaces: vec![v2::RegionInterface {
2543 name: "veh.diag.access".to_string(),
2544 inline: true,
2545 number: 2,
2546 provisional: true,
2547 service: "veh.diag.access".to_string(),
2548 }],
2549 }],
2550 distributions: vec![v2::Distribution {
2551 name: "Adas".to_string(),
2552 package: "veh.topology".to_string(),
2553 members: vec![v2::MemberLine {
2554 component: "veh.topology.Cruise".to_string(),
2555 attributes: vec![],
2556 }],
2557 depends_on: vec!["veh.topology.Base".to_string()],
2558 labels: vec!["PLATFORM_BUNDLE".to_string()],
2559 attributes: vec![attribute("deb", "section", Some(scalar("net")))],
2560 }],
2561 deployments: vec![v2::Deployment {
2562 name: "Production".to_string(),
2563 package: "veh.topology".to_string(),
2564 labels: vec!["FLEET".to_string()],
2565 attributes: vec![attribute("ota", "channel", Some(scalar("stable")))],
2566 machines: vec![v2::Machine {
2567 name: "Cloud".to_string(),
2568 external: true,
2569 labels: vec!["OFF_BOARD".to_string()],
2570 attributes: vec![attribute("net", "zone", Some(scalar("wan")))],
2571 }],
2572 placements: vec![v2::Placement {
2573 component: "veh.topology.Cruise".to_string(),
2574 instance: "backup".to_string(),
2575 machine: "Cockpit".to_string(),
2576 attributes: vec![attribute("linux", "cpuset", Some(list(vec![])))],
2577 }],
2578 links: vec![link.clone()],
2579 routes: vec![v2::Route {
2580 catalog: "veh.adas".to_string(),
2581 interface_number: 2,
2582 member_ordinal: 1,
2583 interface: "LaneAssist".to_string(),
2584 member: "active".to_string(),
2585 service: "veh.adas.lane".to_string(),
2586 producers: vec![endpoint("veh.topology.Lane", "Unit", "AdasHpc")],
2587 }],
2588 surface: vec![v2::Surface {
2589 link: Some(link),
2590 direction: v2::SurfaceDirection::ExternalConsumes as i32,
2591 }],
2592 installations: vec![v2::Installation {
2593 distribution: "veh.topology.Adas".to_string(),
2594 machines: vec!["AdasHpc".to_string(), "Cockpit".to_string()],
2595 }],
2596 }],
2597 }
2598 }
2599
2600 #[test]
2601 fn system_binary_round_trip_preserves_system() {
2602 let system = fixture();
2603 let decoded = v2::system_from_binary(v2::system_to_binary(&system).as_slice())
2604 .expect("decode must succeed");
2605 assert_eq!(system, decoded);
2606 }
2607
2608 /// The canonical JSON of the system artifact re-reads through the same
2609 /// strict pbjson-generated impl the package uses (ADR-0014 decisions 11
2610 /// and 14): unknown fields rejected, enums by name, the nested attribute
2611 /// list intact.
2612 #[test]
2613 fn system_json_round_trip_preserves_system() {
2614 let system = fixture();
2615 let json = v2::system_to_json_pretty(&system).expect("the fixture serializes as JSON");
2616 assert!(
2617 json.contains("\"crossing\": \"CROSSING_OFF_BOARD\""),
2618 "enums render by name, got:\n{json}"
2619 );
2620 assert!(
2621 json.contains("\"notYetRealizable\": true"),
2622 "fields render in lowerCamelCase, got:\n{json}"
2623 );
2624 assert_eq!(
2625 system,
2626 v2::system_from_json(&json).expect("the JSON parses")
2627 );
2628 assert!(
2629 v2::system_from_json(&json.replacen("\"name\"", "\"nam\"", 1)).is_err(),
2630 "an unknown field is rejected"
2631 );
2632 }
2633
2634 #[test]
2635 fn system_text_format_round_trip_preserves_system() {
2636 let system = fixture();
2637 let text = v2::system_to_text_format(&system).expect("the fixture serializes as prototext");
2638 assert!(
2639 text.starts_with("name:"),
2640 "fields print in schema index order, got: {text}"
2641 );
2642 assert_eq!(
2643 system,
2644 v2::system_from_text_format(&text).expect("prototext parsing must succeed")
2645 );
2646 }
2647
2648 /// `pkg.Name` for a declared name; the implicit component of a lone
2649 /// service, which no package declares, is its own qualified name.
2650 #[test]
2651 fn qualified_names_follow_the_one_derivation() {
2652 let system = fixture();
2653 assert_eq!(system.qualified_name(), "veh.topology.Vehicle");
2654 assert_eq!(system.components[0].qualified_name(), "veh.topology.Cruise");
2655 assert_eq!(system.components[1].qualified_name(), "veh.diag.access");
2656 assert_eq!(
2657 system.distributions[0].qualified_name(),
2658 "veh.topology.Adas"
2659 );
2660 }
2661}