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