Skip to main content

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) = &param.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) = &param.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}