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