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    /// Every package named by a type reference in `package`.
821    ///
822    /// A resolved type-reference string is the fully qualified `pkg.Name` for
823    /// a cross-package reference and the bare `Name` for a same-package one,
824    /// never an import alias — the canonical form stated in
825    /// `proto/ridl/ir/v2/ir.proto`, which also enumerates the fields carrying
826    /// one. **That enumeration and this walk are edited together.** A
827    /// reference-bearing field added there and not read here makes the package
828    /// it names invisible to every caller asking what a package depends on.
829    ///
830    /// Every `oneof` below is matched exhaustively with no wildcard arm, so a
831    /// variant added later fails to compile here rather than going unread.
832    pub fn referenced_packages(package: &Package) -> std::collections::BTreeSet<String> {
833        let mut found = std::collections::BTreeSet::new();
834        for decl in &package.decls {
835            walk_decl(decl, &mut found);
836        }
837        for interface in &package.interfaces {
838            for interaction in &interface.interactions {
839                walk_decl(interaction, &mut found);
840            }
841        }
842        for service in &package.services {
843            for slot in &service.shapes {
844                match &slot.kind {
845                    Some(service_shape::Kind::InterfaceRef(reference)) => {
846                        qualifier(reference, &mut found);
847                    }
848                    Some(service_shape::Kind::Inline(interface)) => {
849                        for interaction in &interface.interactions {
850                            walk_decl(interaction, &mut found);
851                        }
852                    }
853                    None => {}
854                }
855            }
856        }
857        found
858    }
859
860    /// Records the package qualifier of a dotted reference. A bare reference
861    /// is same-package and contributes nothing.
862    fn qualifier(reference: &str, found: &mut std::collections::BTreeSet<String>) {
863        if let Some((package, _)) = reference.rsplit_once('.') {
864            found.insert(package.to_string());
865        }
866    }
867
868    /// Records every reference in one declaration — a package-level one or an
869    /// interaction inside an interface, which share the `Decl` envelope.
870    fn walk_decl(decl: &Decl, found: &mut std::collections::BTreeSet<String>) {
871        match &decl.kind {
872            Some(decl::Kind::TypeDef(type_def)) => walk_type_def(type_def, found),
873            Some(decl::Kind::ConstDef(const_def)) => {
874                if let Some(reference) = &const_def.type_ref {
875                    qualifier(reference, found);
876                }
877            }
878            Some(decl::Kind::StructDef(struct_def)) => {
879                for member in &struct_def.members {
880                    match &member.member {
881                        Some(struct_member::Member::Field(field)) => {
882                            if let Some(field_type) = &field.r#type {
883                                walk_field_type(field_type, found);
884                            }
885                        }
886                        // A tombstone occupies an ordinal and names no type.
887                        Some(struct_member::Member::Reserved(_)) | None => {}
888                    }
889                }
890            }
891            // An enum's variants are integers; it names no type.
892            Some(decl::Kind::EnumDef(_)) => {}
893            Some(decl::Kind::EnumSetDef(enum_set)) => {
894                if let Some(reference) = &enum_set.backing_enum {
895                    qualifier(reference, found);
896                }
897            }
898            Some(decl::Kind::UnionDef(union_def)) => {
899                for arm in &union_def.arms {
900                    qualifier(&arm.type_ref, found);
901                }
902            }
903            Some(decl::Kind::SignalDef(signal)) => qualifier(&signal.payload, found),
904            Some(decl::Kind::EventDef(event)) => qualifier(&event.payload, found),
905            Some(decl::Kind::CommandDef(command)) => {
906                for param in &command.params {
907                    if let Some(field_type) = &param.r#type {
908                        walk_field_type(field_type, found);
909                    }
910                }
911            }
912            Some(decl::Kind::QueryDef(query)) => {
913                for param in &query.params {
914                    if let Some(field_type) = &param.r#type {
915                        walk_field_type(field_type, found);
916                    }
917                }
918                if let Some(return_type) = &query.return_type {
919                    walk_return_type(return_type, found);
920                }
921            }
922            Some(decl::Kind::FixedDef(fixed)) => {
923                if let Some(field_type) = &fixed.payload {
924                    walk_field_type(field_type, found);
925                }
926            }
927            // A tombstone occupies an ordinal and names no type.
928            Some(decl::Kind::ReservedSlot(_)) | None => {}
929        }
930    }
931
932    /// The recursive half: a reference is reachable at arbitrary depth through
933    /// tuples, arrays, maps, inline scalars, and streams.
934    fn walk_field_type(field_type: &FieldType, found: &mut std::collections::BTreeSet<String>) {
935        match &field_type.kind {
936            Some(field_type::Kind::Named(reference)) => qualifier(reference, found),
937            // A primitive names no package.
938            Some(field_type::Kind::Primitive(_)) => {}
939            Some(field_type::Kind::InlineScalar(type_def)) => walk_type_def(type_def, found),
940            Some(field_type::Kind::Tuple(tuple)) => {
941                for field in &tuple.fields {
942                    if let Some(inner) = &field.r#type {
943                        walk_field_type(inner, found);
944                    }
945                }
946            }
947            Some(field_type::Kind::Array(array)) => {
948                if let Some(element) = &array.element {
949                    walk_field_type(element, found);
950                }
951            }
952            Some(field_type::Kind::Map(map)) => {
953                if let Some(key) = &map.key {
954                    walk_field_type(key, found);
955                }
956                if let Some(value) = &map.value {
957                    walk_field_type(value, found);
958                }
959            }
960            Some(field_type::Kind::Stream(stream)) => match &stream.element {
961                Some(stream_type::Element::Named(reference)) => qualifier(reference, found),
962                // STRING or BYTES only; names no package.
963                Some(stream_type::Element::Primitive(_)) | None => {}
964            },
965            None => {}
966        }
967    }
968
969    /// A `TypeDef`'s only reference is the constant a `match` bound names.
970    fn walk_type_def(type_def: &TypeDef, found: &mut std::collections::BTreeSet<String>) {
971        if let Some(constraint) = &type_def.constraint
972            && let Some(reference) = &constraint.pattern_const
973        {
974            qualifier(reference, found);
975        }
976    }
977
978    fn walk_return_type(return_type: &ReturnType, found: &mut std::collections::BTreeSet<String>) {
979        match &return_type.kind {
980            Some(return_type::Kind::Value(field_type)) => walk_field_type(field_type, found),
981            Some(return_type::Kind::Fallible(fallible)) => {
982                qualifier(&fallible.ok, found);
983                qualifier(&fallible.err, found);
984            }
985            None => {}
986        }
987    }
988
989    /// Whether a constraint leaves a generated constructor nothing to check.
990    ///
991    /// True when no bound, step or pattern is present. A step is an enforced
992    /// quantization constraint, including when its origin defaults to zero.
993    ///
994    /// A pattern given by name counts as a pattern: `pattern_const` is read as
995    /// well as `pattern`, because a pattern constant that did not resolve leaves
996    /// `pattern` absent while the type still carries a match constraint.
997    /// `ridl-sem` treats the two fields the same way in its derived-init rule
998    /// (`init.rs`).
999    ///
1000    /// Because the checker materializes the typl §4.4 default `[0..256]` into
1001    /// `len_min`/`len_max`, every string and bytes type is non-vacuous. In
1002    /// practice this reduces to `boolean`, and `integer`/`float` with no declared
1003    /// range.
1004    pub fn constraint_is_vacuous(constraint: Option<&Constraint>) -> bool {
1005        let Some(c) = constraint else { return true };
1006        c.min.is_none()
1007            && c.max.is_none()
1008            && c.step.is_none()
1009            && c.len_min.is_none()
1010            && c.len_max.is_none()
1011            && c.pattern.is_none()
1012            && c.pattern_const.is_none()
1013    }
1014}
1015
1016pub mod catalog_hash;
1017pub mod codegen;
1018pub mod name;
1019pub mod projection;
1020pub mod rules;
1021pub mod zero;
1022
1023#[cfg(test)]
1024mod v2_round_trip {
1025    use crate::v2;
1026
1027    /// Wraps an interaction kind in the shared `Decl` envelope. Visibility
1028    /// and `is_error` stay unset on interactions (ridl §14.1); the ordinal is
1029    /// the 1-based declaration order across all interactions of the
1030    /// enclosing interface (ridl §11).
1031    fn interaction(name: &str, ordinal: u32, kind: v2::decl::Kind) -> v2::Decl {
1032        v2::Decl {
1033            name: name.to_string(),
1034            visibility: v2::Visibility::Unspecified as i32,
1035            is_error: false,
1036            doc: String::new(),
1037            labels: Vec::new(),
1038            deprecated: None,
1039            ordinal,
1040            kind: Some(kind),
1041            links: Vec::new(),
1042            see: Vec::new(),
1043            since: Vec::new(),
1044        }
1045    }
1046
1047    fn named_type(name: &str) -> v2::FieldType {
1048        v2::FieldType {
1049            optional: false,
1050            kind: Some(v2::field_type::Kind::Named(name.to_string())),
1051        }
1052    }
1053
1054    fn stream_of(element: v2::stream_type::Element) -> v2::FieldType {
1055        v2::FieldType {
1056            optional: false,
1057            kind: Some(v2::field_type::Kind::Stream(v2::StreamType {
1058                element: Some(element),
1059            })),
1060        }
1061    }
1062
1063    /// A representative ridl package: one interface holding all five
1064    /// interaction kinds plus a reserved tombstone (ordinals 1–6, the
1065    /// tombstone counted, ridl §11), a strict-periodic and a defaulted
1066    /// range timing, a fallible query, and two services — a named
1067    /// reference and an inline shape holding a stream query.
1068    fn fixture() -> v2::Package {
1069        // signal speed : Speed @10ms — strict periodic stores the period
1070        // in both bounds (ADR-0008 decision 12).
1071        let speed = v2::SignalDef {
1072            payload: "Speed".to_string(),
1073            declared_init: None,
1074            init: Some(v2::InitValue {
1075                derivable: true,
1076                value: Some("0.0".to_string()),
1077            }),
1078            timing: Some(v2::Timing {
1079                mode: v2::TimingMode::StrictPeriodic as i32,
1080                min_us: Some("10000".to_string()),
1081                max_us: Some("10000".to_string()),
1082                default_applied: false,
1083            }),
1084        };
1085
1086        // event doorOpened : DoorEvent — untimed in source, so the
1087        // configured default range is resolved at compile time (ridl §9.1).
1088        let door_opened = v2::EventDef {
1089            payload: "DoorEvent".to_string(),
1090            timing: Some(v2::Timing {
1091                mode: v2::TimingMode::Range as i32,
1092                min_us: Some("20000".to_string()),
1093                max_us: Some("500000".to_string()),
1094                default_applied: true,
1095            }),
1096        };
1097
1098        // command setTarget(target : Speed) [ require target >= speed ]
1099        let set_target = v2::CommandDef {
1100            params: vec![v2::Param {
1101                name: "target".to_string(),
1102                r#type: Some(named_type("Speed")),
1103                doc: String::new(),
1104                links: Vec::new(),
1105                see: Vec::new(),
1106                since: Vec::new(),
1107            }],
1108            contracts: vec![v2::Contract {
1109                kind: v2::ContractKind::Require as i32,
1110                source: "target >= speed".to_string(),
1111                signal_refs: vec!["speed".to_string()],
1112                param_refs: vec!["target".to_string()],
1113                uses_result: false,
1114                observer_id: "VehicleStatus.setTarget.require[0]".to_string(),
1115            }],
1116            timing: None,
1117        };
1118
1119        // query fetchFaults(page : PageSpec) : FaultPage | DiagError
1120        //   [ ensure result.count <= page.limit ]
1121        let fetch_faults = v2::QueryDef {
1122            params: vec![v2::Param {
1123                name: "page".to_string(),
1124                r#type: Some(named_type("PageSpec")),
1125                doc: String::new(),
1126                links: Vec::new(),
1127                see: Vec::new(),
1128                since: Vec::new(),
1129            }],
1130            return_type: Some(v2::ReturnType {
1131                kind: Some(v2::return_type::Kind::Fallible(v2::FallibleType {
1132                    ok: "FaultPage".to_string(),
1133                    err: "DiagError".to_string(),
1134                })),
1135            }),
1136            contracts: vec![v2::Contract {
1137                kind: v2::ContractKind::Ensure as i32,
1138                source: "result.count <= page.limit".to_string(),
1139                signal_refs: Vec::new(),
1140                param_refs: vec!["page".to_string()],
1141                uses_result: true,
1142                observer_id: "VehicleStatus.fetchFaults.ensure[0]".to_string(),
1143            }],
1144            timing: None,
1145        };
1146
1147        // fixed vin : Vin
1148        let vin = v2::FixedDef {
1149            payload: Some(named_type("Vin")),
1150        };
1151
1152        let vehicle_status = v2::Interface {
1153            name: "VehicleStatus".to_string(),
1154            visibility: v2::Visibility::Public as i32,
1155            doc: "Vehicle status contract".to_string(),
1156            labels: Vec::new(),
1157            deprecated: None,
1158            interactions: vec![
1159                interaction("speed", 1, v2::decl::Kind::SignalDef(speed)),
1160                interaction("doorOpened", 2, v2::decl::Kind::EventDef(door_opened)),
1161                // reserved legacyMode — the tombstone keeps ordinal 3
1162                // occupied in the one interaction sequence (ridl §11).
1163                v2::Decl {
1164                    ordinal: 3,
1165                    kind: Some(v2::decl::Kind::ReservedSlot(v2::Reserved {
1166                        ordinal: 3,
1167                        name: Some("legacyMode".to_string()),
1168                        value: None,
1169                    })),
1170                    ..interaction("", 3, v2::decl::Kind::ReservedSlot(v2::Reserved::default()))
1171                },
1172                interaction("setTarget", 4, v2::decl::Kind::CommandDef(set_target)),
1173                interaction("fetchFaults", 5, v2::decl::Kind::QueryDef(fetch_faults)),
1174                interaction("vin", 6, v2::decl::Kind::FixedDef(vin)),
1175            ],
1176            number: 0,
1177            provisional: false,
1178            links: Vec::new(),
1179            see: Vec::new(),
1180            since: Vec::new(),
1181        };
1182
1183        // query tailLogs(pattern : <string>) : <LogLine> — a stream param
1184        // and a stream return (ridl §12), inside the inline service shape.
1185        let tail_logs = v2::QueryDef {
1186            params: vec![v2::Param {
1187                name: "pattern".to_string(),
1188                r#type: Some(stream_of(v2::stream_type::Element::Primitive(
1189                    v2::PrimitiveType::String as i32,
1190                ))),
1191                doc: String::new(),
1192                links: Vec::new(),
1193                see: Vec::new(),
1194                since: Vec::new(),
1195            }],
1196            return_type: Some(v2::ReturnType {
1197                kind: Some(v2::return_type::Kind::Value(stream_of(
1198                    v2::stream_type::Element::Named("LogLine".to_string()),
1199                ))),
1200            }),
1201            contracts: Vec::new(),
1202            timing: None,
1203        };
1204
1205        // service veh.adas.status : VehicleStatus — one named reference in
1206        // the service's set (ADR-0015 decision 12).
1207        let status_service = v2::Service {
1208            name: "veh.adas.status".to_string(),
1209            visibility: v2::Visibility::Public as i32,
1210            doc: String::new(),
1211            labels: Vec::new(),
1212            deprecated: None,
1213            shapes: vec![v2::ServiceShape {
1214                kind: Some(v2::service_shape::Kind::InterfaceRef(
1215                    "VehicleStatus".to_string(),
1216                )),
1217            }],
1218            links: Vec::new(),
1219            see: Vec::new(),
1220            since: Vec::new(),
1221        };
1222        // service veh.adas.logs { … } — the inline shape as the one entry,
1223        // Interface.name == "" (ridl §14.5).
1224        let logs_service = v2::Service {
1225            name: "veh.adas.logs".to_string(),
1226            visibility: v2::Visibility::Public as i32,
1227            doc: String::new(),
1228            labels: Vec::new(),
1229            deprecated: None,
1230            shapes: vec![v2::ServiceShape {
1231                kind: Some(v2::service_shape::Kind::Inline(v2::Interface {
1232                    name: String::new(),
1233                    visibility: v2::Visibility::Unspecified as i32,
1234                    doc: String::new(),
1235                    labels: Vec::new(),
1236                    deprecated: None,
1237                    interactions: vec![interaction(
1238                        "tailLogs",
1239                        1,
1240                        v2::decl::Kind::QueryDef(tail_logs),
1241                    )],
1242                    number: 0,
1243                    provisional: false,
1244                    links: Vec::new(),
1245                    see: Vec::new(),
1246                    since: Vec::new(),
1247                })),
1248            }],
1249            links: Vec::new(),
1250            see: Vec::new(),
1251            since: Vec::new(),
1252        };
1253
1254        v2::Package {
1255            name: "veh.adas".to_string(),
1256            // One typl declaration proves the verbatim v1 surface rides
1257            // along unchanged in v2; package-level declarations carry
1258            // ordinal 0.
1259            decls: vec![v2::Decl {
1260                name: "Speed".to_string(),
1261                visibility: v2::Visibility::Public as i32,
1262                is_error: false,
1263                doc: String::new(),
1264                labels: Vec::new(),
1265                deprecated: None,
1266                ordinal: 0,
1267                kind: Some(v2::decl::Kind::TypeDef(v2::TypeDef {
1268                    backing: Some(v2::Backing {
1269                        kind: Some(v2::backing::Kind::Unit("km/h".to_string())),
1270                    }),
1271                    constraint: None,
1272                    declared_init: None,
1273                    init: None,
1274                    width: Some(v2::type_def::Width::FloatWidth(v2::FloatWidth::F32 as i32)),
1275                })),
1276                links: Vec::new(),
1277                see: Vec::new(),
1278                since: Vec::new(),
1279            }],
1280            interfaces: vec![vehicle_status],
1281            services: vec![status_service, logs_service],
1282            retired: Vec::new(),
1283        }
1284    }
1285
1286    /// The typl vocabulary surface the interaction fixture does not reach:
1287    /// the boxed `inlineScalar` oneof member, genuine 64-bit integer fields
1288    /// (array and map bounds, length bounds, `Reserved.value`,
1289    /// `EnumValue.value`), a tuple, a map, a union, an enum set, a constant,
1290    /// and a set `deprecated`. A second fixture, so each stays readable; the
1291    /// same round-trip tests drive both.
1292    fn vocabulary_fixture() -> v2::Package {
1293        fn decl(name: &str, kind: v2::decl::Kind) -> v2::Decl {
1294            v2::Decl {
1295                name: name.to_string(),
1296                visibility: v2::Visibility::Public as i32,
1297                is_error: false,
1298                doc: String::new(),
1299                labels: Vec::new(),
1300                deprecated: None,
1301                ordinal: 0,
1302                kind: Some(kind),
1303                links: Vec::new(),
1304                see: Vec::new(),
1305                since: Vec::new(),
1306            }
1307        }
1308
1309        fn field(name: &str, ordinal: u32, field_type: v2::FieldType) -> v2::Field {
1310            v2::Field {
1311                name: name.to_string(),
1312                ordinal,
1313                r#type: Some(field_type),
1314                declared_init: None,
1315                init: None,
1316                doc: String::new(),
1317                labels: Vec::new(),
1318                deprecated: None,
1319                links: Vec::new(),
1320                see: Vec::new(),
1321                since: Vec::new(),
1322            }
1323        }
1324
1325        // const MAX_RETRY : integer = 24
1326        let max_retry = v2::ConstDef {
1327            type_ref: Some("integer".to_string()),
1328            value: "24".to_string(),
1329            regex: None,
1330        };
1331
1332        // enum Gear { PARK = 1  DRIVE = 2  reserved 7 } — the tombstone
1333        // retires the integer value, a genuine int64 field.
1334        let gear = v2::EnumDef {
1335            values: vec![
1336                v2::EnumValue {
1337                    name: "PARK".to_string(),
1338                    value: 1,
1339                    doc: String::new(),
1340                    links: Vec::new(),
1341                    see: Vec::new(),
1342                    since: Vec::new(),
1343                },
1344                v2::EnumValue {
1345                    name: "DRIVE".to_string(),
1346                    value: 2,
1347                    doc: String::new(),
1348                    links: Vec::new(),
1349                    see: Vec::new(),
1350                    since: Vec::new(),
1351                },
1352            ],
1353            reserved: vec![v2::Reserved {
1354                ordinal: 0,
1355                name: None,
1356                value: Some(7),
1357            }],
1358        };
1359
1360        // enumset Warnings { LOW_FUEL = 0  ICE_RISK = 33 } — the standalone
1361        // form; bit 33 forces the u64 width and is a genuine int64 value.
1362        let warnings = v2::EnumSetDef {
1363            backing_enum: None,
1364            bits: vec![
1365                v2::EnumValue {
1366                    name: "LOW_FUEL".to_string(),
1367                    value: 0,
1368                    doc: String::new(),
1369                    links: Vec::new(),
1370                    see: Vec::new(),
1371                    since: Vec::new(),
1372                },
1373                v2::EnumValue {
1374                    name: "ICE_RISK".to_string(),
1375                    value: 33,
1376                    doc: String::new(),
1377                    links: Vec::new(),
1378                    see: Vec::new(),
1379                    since: Vec::new(),
1380                },
1381            ],
1382            width: v2::IntWidth::U64 as i32,
1383        };
1384
1385        // type PlateText : string [1..86] — character length bounds, two
1386        // genuine uint64 fields behind proto3 `optional`.
1387        let plate_text = v2::TypeDef {
1388            backing: Some(v2::Backing {
1389                kind: Some(v2::backing::Kind::Primitive(
1390                    v2::PrimitiveType::String as i32,
1391                )),
1392            }),
1393            constraint: Some(v2::Constraint {
1394                min: None,
1395                max: None,
1396                step: None,
1397                len_min: Some(1),
1398                len_max: Some(86),
1399                pattern: None,
1400                pattern_const: None,
1401            }),
1402            declared_init: None,
1403            init: None,
1404            width: None,
1405        };
1406
1407        // union Sample { speed : Speed  gear : Gear }
1408        let sample = v2::UnionDef {
1409            arms: vec![
1410                v2::UnionArm {
1411                    name: "speed".to_string(),
1412                    ordinal: 1,
1413                    type_ref: "Speed".to_string(),
1414                    doc: String::new(),
1415                    links: Vec::new(),
1416                    see: Vec::new(),
1417                    since: Vec::new(),
1418                },
1419                v2::UnionArm {
1420                    name: "gear".to_string(),
1421                    ordinal: 2,
1422                    type_ref: "Gear".to_string(),
1423                    doc: String::new(),
1424                    links: Vec::new(),
1425                    see: Vec::new(),
1426                    since: Vec::new(),
1427                },
1428            ],
1429            is_result: false,
1430            reserved: Vec::new(),
1431        };
1432
1433        // retries : integer [0..24] = 3 — the boxed `inlineScalar` oneof
1434        // member: the committed regression guard for ADR-0014 Open item 2,
1435        // which established that the Rust-side `Box` is invisible to the
1436        // reflection path. The enclosing field carries the init; the nested
1437        // TypeDef's stays unset.
1438        let retries = v2::Field {
1439            declared_init: Some("3".to_string()),
1440            init: Some(v2::InitValue {
1441                derivable: true,
1442                value: Some("3".to_string()),
1443            }),
1444            ..field(
1445                "retries",
1446                1,
1447                v2::FieldType {
1448                    optional: false,
1449                    kind: Some(v2::field_type::Kind::InlineScalar(Box::new(v2::TypeDef {
1450                        backing: Some(v2::Backing {
1451                            kind: Some(v2::backing::Kind::Primitive(
1452                                v2::PrimitiveType::Integer as i32,
1453                            )),
1454                        }),
1455                        constraint: Some(v2::Constraint {
1456                            min: Some("0".to_string()),
1457                            max: Some("24".to_string()),
1458                            step: None,
1459                            len_min: None,
1460                            len_max: None,
1461                            pattern: None,
1462                            pattern_const: None,
1463                        }),
1464                        declared_init: None,
1465                        init: None,
1466                        width: Some(v2::type_def::Width::IntWidth(v2::IntWidth::U8 as i32)),
1467                    }))),
1468                },
1469            )
1470        };
1471
1472        // position : (x : Speed, y : Speed) — an anonymous named-field
1473        // composite (typl §11).
1474        let position = field(
1475            "position",
1476            2,
1477            v2::FieldType {
1478                optional: false,
1479                kind: Some(v2::field_type::Kind::Tuple(v2::TupleType {
1480                    fields: vec![
1481                        v2::TupleField {
1482                            name: "x".to_string(),
1483                            r#type: Some(named_type("Speed")),
1484                        },
1485                        v2::TupleField {
1486                            name: "y".to_string(),
1487                            r#type: Some(named_type("Speed")),
1488                        },
1489                    ],
1490                })),
1491            },
1492        );
1493
1494        // gears : [Gear; 1..4096] — array bounds are genuine uint64 fields.
1495        let gears = field(
1496            "gears",
1497            3,
1498            v2::FieldType {
1499                optional: false,
1500                kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
1501                    element: Some(Box::new(named_type("Gear"))),
1502                    min: 1,
1503                    max: 4096,
1504                }))),
1505            },
1506        );
1507
1508        // plates : { PlateText -> Gear } [0..53] — map bounds are genuine
1509        // uint64 fields. The field is deprecated, covering the optional
1510        // string on the Field envelope.
1511        let plates = v2::Field {
1512            deprecated: Some("superseded by gears".to_string()),
1513            ..field(
1514                "plates",
1515                4,
1516                v2::FieldType {
1517                    optional: false,
1518                    kind: Some(v2::field_type::Kind::Map(Box::new(v2::MapType {
1519                        key: Some(Box::new(named_type("PlateText"))),
1520                        value: Some(Box::new(named_type("Gear"))),
1521                        min: 0,
1522                        max: 53,
1523                    }))),
1524                },
1525            )
1526        };
1527
1528        let snapshot = v2::StructDef {
1529            members: [retries, position, gears, plates]
1530                .into_iter()
1531                .map(|field| v2::StructMember {
1532                    member: Some(v2::struct_member::Member::Field(Box::new(field))),
1533                })
1534                .collect(),
1535            fixed_layout: false,
1536        };
1537
1538        v2::Package {
1539            name: "veh.vocab".to_string(),
1540            decls: vec![
1541                decl("MAX_RETRY", v2::decl::Kind::ConstDef(max_retry)),
1542                decl("Gear", v2::decl::Kind::EnumDef(gear)),
1543                decl("Warnings", v2::decl::Kind::EnumSetDef(warnings)),
1544                decl("PlateText", v2::decl::Kind::TypeDef(plate_text)),
1545                // The union is deprecated — the optional string on the Decl
1546                // envelope.
1547                v2::Decl {
1548                    deprecated: Some("use Snapshot".to_string()),
1549                    ..decl("Sample", v2::decl::Kind::UnionDef(sample))
1550                },
1551                decl("Snapshot", v2::decl::Kind::StructDef(snapshot)),
1552            ],
1553            interfaces: Vec::new(),
1554            services: Vec::new(),
1555            retired: Vec::new(),
1556        }
1557    }
1558
1559    #[test]
1560    fn protobuf_round_trip_preserves_package() {
1561        let package = fixture();
1562
1563        let buf = v2::to_binary(&package);
1564        let decoded = v2::from_binary(buf.as_slice()).expect("decode must succeed");
1565
1566        assert_eq!(package, decoded);
1567
1568        // The vocabulary fixture rides the same round trip.
1569        let vocabulary = vocabulary_fixture();
1570        let decoded_vocabulary =
1571            v2::from_binary(v2::to_binary(&vocabulary).as_slice()).expect("decode must succeed");
1572        assert_eq!(vocabulary, decoded_vocabulary);
1573
1574        let interface = &decoded.interfaces[0];
1575        let ordinals: Vec<u32> = interface.interactions.iter().map(|d| d.ordinal).collect();
1576        assert_eq!(
1577            ordinals,
1578            [1, 2, 3, 4, 5, 6],
1579            "one ordinal sequence, tombstone counted (ridl §11)"
1580        );
1581        let Some(v2::decl::Kind::ReservedSlot(tombstone)) = &interface.interactions[2].kind else {
1582            panic!("ordinal 3 must decode as a reserved tombstone");
1583        };
1584        assert_eq!(tombstone.name.as_deref(), Some("legacyMode"));
1585        let Some(v2::service_shape::Kind::Inline(inline)) = decoded.services[1]
1586            .shapes
1587            .first()
1588            .and_then(|slot| slot.kind.as_ref())
1589        else {
1590            panic!("veh.adas.logs must decode as an inline shape");
1591        };
1592        assert_eq!(inline.name, "", "an inline shape carries no name");
1593        let references: Vec<&str> = decoded.services[0]
1594            .shapes
1595            .iter()
1596            .filter_map(|slot| match &slot.kind {
1597                Some(v2::service_shape::Kind::InterfaceRef(reference)) => Some(reference.as_str()),
1598                _ => None,
1599            })
1600            .collect();
1601        assert_eq!(
1602            references,
1603            ["VehicleStatus"],
1604            "a service's set carries its references and nothing else"
1605        );
1606    }
1607
1608    #[test]
1609    fn json_round_trip_preserves_package() {
1610        for package in [fixture(), vocabulary_fixture()] {
1611            let json = v2::to_json_pretty(&package).expect("the fixture serializes as IR JSON");
1612            let decoded = v2::from_json(&json).expect("json deserialization must succeed");
1613
1614            assert_eq!(package, decoded);
1615        }
1616    }
1617
1618    /// The interface identity fields the lock design §9 adds — `number` and
1619    /// `provisional` on every `Interface`, an inline shape included, and the
1620    /// package's `retired` list — ride all three encodings unchanged, and the
1621    /// JSON writes them under their canonical names even when they hold their
1622    /// defaults (ADR-0014 decision 2), so a reader can tell `number` 0 from an
1623    /// absent field only by the schema, never by the text.
1624    #[test]
1625    fn number_provisional_and_retired_round_trip_through_json_text_and_binary() {
1626        let mut package = fixture();
1627        package.interfaces[0].number = 4;
1628        package.interfaces[0].provisional = true;
1629        let Some(v2::service_shape::Kind::Inline(inline)) =
1630            package.services[1].shapes[0].kind.as_mut()
1631        else {
1632            panic!("veh.adas.logs holds an inline shape in slot 1");
1633        };
1634        inline.number = 5;
1635        package.retired = vec![
1636            v2::RetiredInterface {
1637                name: "LaneAssist".to_string(),
1638                number: 2,
1639            },
1640            v2::RetiredInterface {
1641                name: "service:veh.hvac.cabin".to_string(),
1642                number: 3,
1643            },
1644        ];
1645
1646        let json = v2::to_json_pretty(&package).expect("the package serializes as IR JSON");
1647        assert_eq!(v2::from_json(&json).expect("the JSON parses back"), package);
1648        let text = v2::to_text_format(&package).expect("the package serializes as prototext");
1649        assert_eq!(
1650            v2::from_text_format(&text).expect("the prototext parses back"),
1651            package
1652        );
1653        assert_eq!(
1654            v2::from_binary(v2::to_binary(&package).as_slice()).expect("the binary decodes"),
1655            package
1656        );
1657
1658        for needle in [
1659            r#""number": 4"#,
1660            r#""provisional": true"#,
1661            r#""number": 5"#,
1662            r#""name": "LaneAssist""#,
1663            r#""name": "service:veh.hvac.cabin""#,
1664        ] {
1665            assert!(
1666                json.contains(needle),
1667                "the JSON must carry {needle}, got: {json}"
1668            );
1669        }
1670
1671        // A default holds its place in the text (decision 2): an interface
1672        // that was never numbered writes `0` and `false`, and a package with
1673        // nothing retired writes an empty list.
1674        let unnumbered = v2::to_json_pretty(&fixture()).expect("the fixture serializes as IR JSON");
1675        for needle in [
1676            r#""number": 0"#,
1677            r#""provisional": false"#,
1678            r#""retired": []"#,
1679        ] {
1680            assert!(
1681                unnumbered.contains(needle),
1682                "a default field must still be written, expected {needle} in: {unnumbered}"
1683            );
1684        }
1685    }
1686
1687    /// A baseline published before the lock existed carries no `number`, no
1688    /// `provisional` and no `retired` field. It still loads — a missing field
1689    /// reads as its default, which is the `number` 0 the lock design §7 names
1690    /// as the one transition case — while an unknown field is still rejected
1691    /// (`json_parse_rejects_an_unknown_field`).
1692    #[test]
1693    fn a_snapshot_lacking_the_number_fields_still_loads() {
1694        let package = v2::from_json(
1695            r#"{"name": "veh.x", "interfaces": [{"name": "LaneKeeping"}], "services": [{"name": "veh.x.s", "shapes": [{"inline": {"name": ""}}]}]}"#,
1696        )
1697        .expect("a pre-lock snapshot loads");
1698
1699        assert_eq!(package.interfaces[0].number, 0);
1700        assert!(!package.interfaces[0].provisional);
1701        let Some(v2::service_shape::Kind::Inline(inline)) =
1702            package.services[0].shapes[0].kind.as_ref()
1703        else {
1704            panic!("the service holds an inline shape");
1705        };
1706        assert_eq!(inline.number, 0);
1707        assert!(!inline.provisional);
1708        assert_eq!(package.retired, Vec::new());
1709    }
1710
1711    /// The prototext read path (ADR-0014 decision 7): both fixtures survive
1712    /// `to_text_format` then `from_text_format` unchanged. With the binary
1713    /// and JSON round trips above, this is what proves all three encodings
1714    /// carry the same IR.
1715    #[test]
1716    fn text_format_round_trip_preserves_package() {
1717        for package in [fixture(), vocabulary_fixture()] {
1718            let text = v2::to_text_format(&package).expect("the fixture serializes as prototext");
1719            let decoded = v2::from_text_format(&text).expect("prototext parsing must succeed");
1720
1721            assert_eq!(package, decoded);
1722        }
1723    }
1724
1725    /// The prototext options ADR-0014 decision 8 fixes — `pretty`,
1726    /// `skip_default_fields(false)`, `print_message_fields_in_index_order`.
1727    /// Any option set round-trips, which is why the round-trip test above
1728    /// cannot guard them.
1729    ///
1730    /// The first two are asserted through a visible consequence. The third is
1731    /// **not guarded here and cannot be on this schema**: every message in
1732    /// `ir.proto` declares its fields in ascending field-number order, and
1733    /// field-number order is also `prost-reflect`'s default, so index order
1734    /// and default order coincide everywhere and dropping the option would
1735    /// change no output. It is set because the schema's ordering is a
1736    /// property of the schema rather than a guarantee, and a message whose
1737    /// declaration order departs from its numbering would otherwise reorder
1738    /// every artifact it appears in.
1739    #[test]
1740    fn text_format_is_pretty_with_defaults_in_index_order() {
1741        let text = v2::to_text_format(&fixture()).expect("the fixture serializes as prototext");
1742
1743        // pretty: nested messages are indented, one field per line.
1744        assert!(
1745            text.contains("\n  "),
1746            "pretty printing must indent nested fields, got: {text}"
1747        );
1748        // skip_default_fields(false): a field holding its default is present
1749        // (decision 2 — `ordinal: 0` is read, not inferred from absence).
1750        assert!(
1751            text.contains("is_error: false"),
1752            "a field holding its default must be emitted, got: {text}"
1753        );
1754        // print_message_fields_in_index_order: `name` is field 1 of
1755        // `Package`, so it opens the output.
1756        assert!(
1757            text.starts_with("name:"),
1758            "fields must print in schema index order, got: {text}"
1759        );
1760    }
1761
1762    /// Parses emitted JSON the way ADR-0014 decision 11's conformance test
1763    /// requires: unknown fields rejected, trailing input rejected. Since
1764    /// decision 14 the strict parser is the pbjson-generated `Deserialize`
1765    /// impl, whose default already rejects unknown fields
1766    /// (`ignore_unknown_fields()` stays unset in `build.rs`), so the
1767    /// strictness needs no option to opt into.
1768    fn strict_parse(json: &str) -> v2::Package {
1769        let mut deserializer = serde_json::Deserializer::from_str(json);
1770        let package = <v2::Package as serde::Deserialize>::deserialize(&mut deserializer)
1771            .expect("a strict conformant parser must accept the emitted JSON");
1772        deserializer.end().expect("no trailing input");
1773        package
1774    }
1775
1776    /// The conformance claim of ADR-0014 decision 11: a conformant protobuf
1777    /// JSON parser configured to reject unknown fields accepts the emitted
1778    /// JSON. Re-reading tests that claim itself; asserting on the rendered
1779    /// text would only restate the serializer's behaviour back to itself.
1780    #[test]
1781    fn emitted_json_survives_a_strict_conformant_parse() {
1782        for package in [fixture(), vocabulary_fixture()] {
1783            let json = v2::to_json_pretty(&package).expect("the fixture serializes as IR JSON");
1784            assert_eq!(package, strict_parse(&json));
1785        }
1786    }
1787
1788    #[test]
1789    fn json_renders_timing_bounds_and_fallible_arms_exactly() {
1790        let json = v2::to_json_pretty(&fixture()).expect("the fixture serializes as IR JSON");
1791
1792        // Exactness is visible: timing bounds are exact-decimal microsecond
1793        // strings, never floating-point numbers (ADR-0008 decision 12) —
1794        // under the canonical lowerCamelCase field name (ADR-0014 decision 1).
1795        assert!(
1796            json.contains(r#""minUs": "10000""#),
1797            "the timing bound must be a JSON string, got: {json}"
1798        );
1799        // Both arms of the inline T | E return are visible by name.
1800        assert!(
1801            json.contains(r#""ok": "FaultPage""#),
1802            "the ok arm must render, got: {json}"
1803        );
1804        assert!(
1805            json.contains(r#""err": "DiagError""#),
1806            "the err arm must render, got: {json}"
1807        );
1808    }
1809
1810    /// ADR-0014 decision 8's stringification, tested on genuine 64-bit
1811    /// fields. The timing assertion above proves nothing about it —
1812    /// `Timing.min_us` is `optional string` in the schema — so the claim
1813    /// needs fields whose wire type actually is `uint64` or `int64`.
1814    #[test]
1815    fn json_renders_64_bit_integer_fields_as_strings() {
1816        let json = v2::to_json_pretty(&vocabulary_fixture())
1817            .expect("the vocabulary fixture serializes as IR JSON");
1818
1819        // uint64: the array's upper bound.
1820        assert!(
1821            json.contains(r#""max": "4096""#),
1822            "an array bound must be a JSON string, got: {json}"
1823        );
1824        // uint64 behind proto3 `optional`: the character length bound.
1825        assert!(
1826            json.contains(r#""lenMax": "86""#),
1827            "a length bound must be a JSON string, got: {json}"
1828        );
1829        // int64: the retired enum value and the enum-set bit position.
1830        assert!(
1831            json.contains(r#""value": "7""#),
1832            "a retired enum value must be a JSON string, got: {json}"
1833        );
1834        assert!(
1835            json.contains(r#""value": "33""#),
1836            "an enum-set bit position must be a JSON string, got: {json}"
1837        );
1838    }
1839
1840    #[test]
1841    fn fallible_transport_identity_follows_the_derivation_rule() {
1842        // The ADR-0008 decision 4 rule: interface + interaction ordinal +
1843        // both arm references, in that order.
1844        let fallible = v2::FallibleType {
1845            ok: "FaultPage".to_string(),
1846            err: "DiagError".to_string(),
1847        };
1848        assert_eq!(
1849            v2::fallible_transport_identity("VehicleStatus", 9, &fallible),
1850            "VehicleStatus#9:FaultPage|DiagError"
1851        );
1852
1853        // Derived from the fixture: the fallible query sits at ordinal 5.
1854        let package = fixture();
1855        let interface = &package.interfaces[0];
1856        let query_decl = &interface.interactions[4];
1857        let Some(v2::decl::Kind::QueryDef(query)) = &query_decl.kind else {
1858            panic!("ordinal 5 must be the fallible query");
1859        };
1860        let Some(v2::return_type::Kind::Fallible(arms)) = &query.return_type.as_ref().unwrap().kind
1861        else {
1862            panic!("fetchFaults must return a fallible type");
1863        };
1864        assert_eq!(
1865            v2::fallible_transport_identity(&interface.name, query_decl.ordinal, arms),
1866            "VehicleStatus#5:FaultPage|DiagError"
1867        );
1868    }
1869
1870    /// `Package::shapes` yields the named interfaces first, then the inline
1871    /// shapes of the services — and each shape carries the name it is known by
1872    /// OUTSIDE the package. The fixture's inline shape has `Interface.name ==
1873    /// ""` by construction, so a walk that yielded the interface bare would
1874    /// hand every consumer the empty string; two of the six E2 defects were
1875    /// exactly that.
1876    #[test]
1877    fn shapes_walks_named_interfaces_and_inline_service_shapes() {
1878        let package = fixture();
1879        let walk: Vec<(&str, bool, usize)> = package
1880            .shapes()
1881            .map(|shape| {
1882                (
1883                    shape.name,
1884                    shape.is_inline(),
1885                    shape.interface.interactions.len(),
1886                )
1887            })
1888            .collect();
1889        assert_eq!(
1890            walk,
1891            [("VehicleStatus", false, 6), ("veh.adas.logs", true, 1)],
1892            "the named interface, then the inline shape under the service's \
1893             dotted name",
1894        );
1895
1896        // The fixture's third shape-bearing declaration is `service
1897        // veh.adas.status : VehicleStatus`, which names an interface already in
1898        // the walk. Yielding it too would visit `VehicleStatus` twice.
1899        assert_eq!(package.services.len(), 2, "one reference form, one inline");
1900        assert!(
1901            !package
1902                .shapes()
1903                .any(|shape| shape.name == "veh.adas.status"),
1904            "a service naming an interface contributes no shape of its own",
1905        );
1906    }
1907
1908    /// The owning service is carried because `Service.visibility` is the
1909    /// authoritative one: an inline shape's own field is
1910    /// `VISIBILITY_UNSPECIFIED` by construction, which is not "internal" and
1911    /// not "public".
1912    #[test]
1913    fn shape_visibility_reads_the_owning_service_for_an_inline_shape() {
1914        let package = fixture();
1915        let shapes: Vec<v2::InterfaceShape<'_>> = package.shapes().collect();
1916
1917        let named = shapes[0];
1918        assert!(named.service.is_none());
1919        assert_eq!(named.visibility(), v2::Visibility::Public as i32);
1920        assert_eq!(named.visibility(), named.interface.visibility);
1921
1922        let inline = shapes[1];
1923        assert_eq!(
1924            inline.interface.visibility,
1925            v2::Visibility::Unspecified as i32,
1926            "the trap: an inline shape's own visibility field is unset",
1927        );
1928        assert_eq!(
1929            inline.service.expect("an inline shape has an owner").name,
1930            "veh.adas.logs",
1931        );
1932        assert_eq!(
1933            inline.visibility(),
1934            v2::Visibility::Public as i32,
1935            "the accessor reads the owning service's, never the unset field",
1936        );
1937    }
1938
1939    /// A package with no service at all still walks its interfaces, and a
1940    /// package with neither yields nothing — the emptiness both backends test
1941    /// for before emitting any interaction vocabulary.
1942    #[test]
1943    fn shapes_is_empty_only_when_the_package_declares_no_shape() {
1944        let mut package = fixture();
1945        package.services.clear();
1946        assert_eq!(package.shapes().count(), 1);
1947
1948        package.interfaces.clear();
1949        assert_eq!(package.shapes().count(), 0);
1950    }
1951
1952    /// A dotted reference contributes its qualifier; a bare one contributes
1953    /// nothing. Every recursive path through `walk_field_type` — array
1954    /// element, tuple field, map key, map value, stream element — carries a
1955    /// distinct qualifier, so no path's absence can hide behind another
1956    /// path's presence: deleting any one arm's body changes the expected set
1957    /// this test compares against, rather than leaving it unchanged.
1958    #[test]
1959    fn referenced_packages_finds_qualifiers_at_depth() {
1960        fn named(reference: &str) -> v2::FieldType {
1961            v2::FieldType {
1962                kind: Some(v2::field_type::Kind::Named(reference.to_string())),
1963                ..Default::default()
1964            }
1965        }
1966
1967        fn fixed(payload: v2::FieldType) -> v2::decl::Kind {
1968            v2::decl::Kind::FixedDef(v2::FixedDef {
1969                payload: Some(payload),
1970            })
1971        }
1972
1973        let package = v2::Package {
1974            name: "veh.cluster".to_string(),
1975            decls: vec![
1976                v2::Decl {
1977                    name: "Local".to_string(),
1978                    kind: Some(v2::decl::Kind::SignalDef(v2::SignalDef {
1979                        payload: "Speed".to_string(),
1980                        ..Default::default()
1981                    })),
1982                    ..Default::default()
1983                },
1984                v2::Decl {
1985                    name: "Stamped".to_string(),
1986                    kind: Some(v2::decl::Kind::SignalDef(v2::SignalDef {
1987                        payload: "ridl.std.Timestamp".to_string(),
1988                        ..Default::default()
1989                    })),
1990                    ..Default::default()
1991                },
1992                v2::Decl {
1993                    name: "ArrLabels".to_string(),
1994                    kind: Some(fixed(v2::FieldType {
1995                        kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
1996                            element: Some(Box::new(named("veh.arr.Label"))),
1997                            min: 0,
1998                            max: 32,
1999                        }))),
2000                        ..Default::default()
2001                    })),
2002                    ..Default::default()
2003                },
2004                v2::Decl {
2005                    name: "TupThing".to_string(),
2006                    kind: Some(fixed(v2::FieldType {
2007                        kind: Some(v2::field_type::Kind::Tuple(v2::TupleType {
2008                            fields: vec![v2::TupleField {
2009                                name: "x".to_string(),
2010                                r#type: Some(named("veh.tup.X")),
2011                            }],
2012                        })),
2013                        ..Default::default()
2014                    })),
2015                    ..Default::default()
2016                },
2017                v2::Decl {
2018                    name: "MapThing".to_string(),
2019                    kind: Some(fixed(v2::FieldType {
2020                        kind: Some(v2::field_type::Kind::Map(Box::new(v2::MapType {
2021                            key: Some(Box::new(named("veh.key.X"))),
2022                            value: Some(Box::new(named("veh.val.X"))),
2023                            min: 0,
2024                            max: 8,
2025                        }))),
2026                        ..Default::default()
2027                    })),
2028                    ..Default::default()
2029                },
2030                v2::Decl {
2031                    name: "StreamThing".to_string(),
2032                    kind: Some(fixed(v2::FieldType {
2033                        kind: Some(v2::field_type::Kind::Stream(v2::StreamType {
2034                            element: Some(v2::stream_type::Element::Named(
2035                                "veh.strm.X".to_string(),
2036                            )),
2037                        })),
2038                        ..Default::default()
2039                    })),
2040                    ..Default::default()
2041                },
2042            ],
2043            ..Default::default()
2044        };
2045
2046        let found = v2::referenced_packages(&package);
2047        let expected: std::collections::BTreeSet<String> = [
2048            "ridl.std", "veh.arr", "veh.tup", "veh.key", "veh.val", "veh.strm",
2049        ]
2050        .into_iter()
2051        .map(str::to_string)
2052        .collect();
2053        assert_eq!(
2054            found, expected,
2055            "each recursive path must contribute its own distinct qualifier"
2056        );
2057        assert!(
2058            !found.contains("Speed") && !found.contains("veh.cluster"),
2059            "a bare reference contributes no package: {found:?}",
2060        );
2061    }
2062
2063    /// An empty package references nothing — the negative case the emit rule in
2064    /// `ridlc` depends on.
2065    #[test]
2066    fn referenced_packages_is_empty_without_references() {
2067        let package = v2::Package {
2068            name: "veh.solo".to_string(),
2069            ..Default::default()
2070        };
2071        assert!(v2::referenced_packages(&package).is_empty());
2072    }
2073
2074    /// Below prost's recursion limit at two message levels per nesting level
2075    /// — the depth ADR-0014 decision 12 measured as round-tripping correctly.
2076    /// Since decision 14 these two constants bound the prototext transcode
2077    /// alone: JSON no longer transcodes and carries its own read-side
2078    /// ceiling, tested separately below.
2079    const NESTING_BELOW_LIMIT: usize = 45;
2080    /// Past the limit today. The tests assert the outcome — an error, never a
2081    /// panic — not the exact threshold, so a prost release that moves the
2082    /// limit moves these constants, not the assertions.
2083    const NESTING_PAST_LIMIT: usize = 60;
2084
2085    /// One declaration whose payload nests `depth` levels of inline arrays —
2086    /// each level costs two message levels on the wire (`FieldType` plus
2087    /// `ArrayType`), the arithmetic ADR-0014 decision 12 records against
2088    /// prost's recursion limit.
2089    fn nested_package(depth: usize) -> v2::Package {
2090        let mut payload = v2::FieldType {
2091            optional: false,
2092            kind: Some(v2::field_type::Kind::Primitive(
2093                v2::PrimitiveType::Integer as i32,
2094            )),
2095        };
2096        for _ in 0..depth {
2097            payload = v2::FieldType {
2098                optional: false,
2099                kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
2100                    element: Some(Box::new(payload)),
2101                    min: 1,
2102                    max: 1,
2103                }))),
2104            };
2105        }
2106        v2::Package {
2107            name: "veh.deep".to_string(),
2108            decls: vec![v2::Decl {
2109                name: "deep".to_string(),
2110                kind: Some(v2::decl::Kind::FixedDef(v2::FixedDef {
2111                    payload: Some(payload),
2112                })),
2113                ..Default::default()
2114            }],
2115            ..Default::default()
2116        }
2117    }
2118
2119    /// One package whose nesting sits exactly `levels` message levels below
2120    /// the `Package` root — the unit the derived binary encoding's bound is
2121    /// stated in (the IR specification, "The derived encodings").
2122    ///
2123    /// The chain under a `FixedDef` costs three levels before any nesting
2124    /// (`Decl`, `FixedDef`, the outermost `FieldType`) and two per array level
2125    /// (`ArrayType`, `FieldType`), so an array-only chain reaches the odd
2126    /// depths alone. One tuple level costs three (`FieldType`, `TupleType`,
2127    /// `TupleField`, then the `FieldType` the next level counts), which is what
2128    /// reaches the even depths. Both shapes are what the front end lowers, so
2129    /// neither is a construction the schema would not otherwise see.
2130    fn package_at_message_depth(levels: usize) -> v2::Package {
2131        assert!(levels >= 3, "the chain costs three levels before nesting");
2132        let (arrays, tuple) = if levels % 2 == 1 {
2133            ((levels - 3) / 2, false)
2134        } else {
2135            assert!(levels >= 6, "one tuple level costs three");
2136            ((levels - 6) / 2, true)
2137        };
2138
2139        let mut payload = v2::FieldType {
2140            optional: false,
2141            kind: Some(v2::field_type::Kind::Primitive(
2142                v2::PrimitiveType::Integer as i32,
2143            )),
2144        };
2145        for _ in 0..arrays {
2146            payload = v2::FieldType {
2147                optional: false,
2148                kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
2149                    element: Some(Box::new(payload)),
2150                    min: 1,
2151                    max: 1,
2152                }))),
2153            };
2154        }
2155        if tuple {
2156            payload = v2::FieldType {
2157                optional: false,
2158                kind: Some(v2::field_type::Kind::Tuple(v2::TupleType {
2159                    fields: vec![v2::TupleField {
2160                        name: "f0".to_string(),
2161                        r#type: Some(payload),
2162                    }],
2163                })),
2164            };
2165        }
2166        v2::Package {
2167            name: "veh.deep".to_string(),
2168            decls: vec![v2::Decl {
2169                name: "deep".to_string(),
2170                kind: Some(v2::decl::Kind::FixedDef(v2::FixedDef {
2171                    payload: Some(payload),
2172                })),
2173                ..Default::default()
2174            }],
2175            ..Default::default()
2176        }
2177    }
2178
2179    /// The nesting of JSON objects in a canonical artifact, which in the
2180    /// protobuf JSON mapping is the nesting of messages: every message is an
2181    /// object, a repeated field is an array of them, and the schema declares no
2182    /// `map<>` field. The root `Package` object is included, so a caller
2183    /// counting levels *below* the root subtracts one. Brackets inside a string
2184    /// literal do not count.
2185    fn message_nesting(json: &str) -> usize {
2186        let (mut depth, mut max) = (0usize, 0usize);
2187        let (mut in_string, mut escaped) = (false, false);
2188        for byte in json.bytes() {
2189            if in_string {
2190                if escaped {
2191                    escaped = false;
2192                } else if byte == b'\\' {
2193                    escaped = true;
2194                } else if byte == b'"' {
2195                    in_string = false;
2196                }
2197                continue;
2198            }
2199            match byte {
2200                b'"' => in_string = true,
2201                b'{' => {
2202                    depth += 1;
2203                    max = max.max(depth);
2204                }
2205                b'}' => depth = depth.saturating_sub(1),
2206                _ => {}
2207            }
2208        }
2209        max
2210    }
2211
2212    /// [`package_at_message_depth`] builds what it claims, on both parities.
2213    /// Without this the two bound tests below would pin a depth nobody
2214    /// measured.
2215    #[test]
2216    fn package_at_message_depth_builds_the_depth_it_names() {
2217        with_sized_stack(|| {
2218            for levels in [3, 6, 7, 99, 100, 101] {
2219                let json = v2::to_json_pretty(&package_at_message_depth(levels))
2220                    .expect("the writer is unrestricted at these depths");
2221                assert_eq!(
2222                    message_nesting(&json) - 1,
2223                    levels,
2224                    "the chain must nest {levels} message levels below the root"
2225                );
2226            }
2227        });
2228    }
2229
2230    /// The derived binary encoding's bound, stated by the IR specification and
2231    /// pinned here: 100 message levels below the root round-trip.
2232    ///
2233    /// `to_binary` writes any depth; it is `from_binary` that stops, at prost's
2234    /// `RECURSION_LIMIT` of 100, decremented once per nested message on decode
2235    /// and not consulted on encode. The test asserts the outcome, not prost's
2236    /// constant: a prost release that moves the limit moves these two tests and
2237    /// the specification's paragraph together.
2238    #[test]
2239    fn binary_round_trip_at_100_message_levels_succeeds() {
2240        let package = package_at_message_depth(100);
2241        let bytes = v2::to_binary(&package);
2242        let decoded = v2::from_binary(&bytes).expect("100 message levels decode");
2243        assert_eq!(package, decoded);
2244    }
2245
2246    /// One level past that bound the binary reader refuses — an error, never a
2247    /// panic — while the canonical encoding carries the same package. This is
2248    /// the asymmetry the specification states as the reason binary is derived
2249    /// rather than canonical (driftsys/ridl#231).
2250    #[test]
2251    fn binary_decode_at_101_message_levels_returns_an_error() {
2252        let package = package_at_message_depth(101);
2253        let bytes = v2::to_binary(&package);
2254        let error = v2::from_binary(&bytes).expect_err("101 message levels must fail, not panic");
2255        assert!(
2256            error.to_string().contains("recursion limit reached"),
2257            "the error must name the limit, got: {error}"
2258        );
2259
2260        let json = v2::to_json_pretty(&package).expect("the canonical encoding has no such bound");
2261        assert_eq!(
2262            package,
2263            v2::from_json(&json).expect("the canonical encoding round-trips the same package")
2264        );
2265    }
2266
2267    /// The write side after ADR-0014 decision 14: the pbjson-generated
2268    /// writer recurses the typed message directly — no transcode, so no
2269    /// message-level recursion limit — and 400 levels of array nesting,
2270    /// roughly eight times the ceiling decision 12 recorded, serialize and
2271    /// round-trip. Run on an explicitly sized stack: the writer recurses on
2272    /// the caller's stack, and debug-build frames at this depth overflow the
2273    /// default test-thread stack (the reader sizes its own thread inside
2274    /// `from_json`).
2275    #[test]
2276    fn json_round_trip_at_400_nested_levels_succeeds() {
2277        with_sized_stack(|| {
2278            let package = nested_package(400);
2279            let json = v2::to_json_pretty(&package).expect("the writer has no message-level limit");
2280            let decoded = v2::from_json(&json).expect("the reader parses within its ceiling");
2281            assert_eq!(package, decoded);
2282        });
2283    }
2284
2285    /// The one error path on the JSON write side (ADR-0014 decision 14),
2286    /// and it is new with the generated impl, not a survivor of the
2287    /// transcode's: an `i32` enum field holding a discriminant outside the
2288    /// schema — data, not depth — which the retired reflection path
2289    /// serialized successfully as its bare number. The checker never
2290    /// produces one, so there is no CLI route to this failure; it is pinned
2291    /// here at the crate surface.
2292    #[test]
2293    fn json_serialization_of_an_out_of_schema_discriminant_returns_an_error() {
2294        let mut package = fixture();
2295        package.decls[0].visibility = 999;
2296        let err = v2::to_json_pretty(&package)
2297            .expect_err("an out-of-schema discriminant must fail, not panic");
2298        let message = err.to_string();
2299        assert!(
2300            message.contains("canonical protobuf JSON"),
2301            "the error must name the encoding that failed, got: {message}"
2302        );
2303        assert!(
2304            message.contains("discriminant outside the schema"),
2305            "the error must name the known cause, got: {message}"
2306        );
2307    }
2308
2309    /// The strictness ADR-0014 decision 11 relies on is the generated
2310    /// deserializer's default: `ignore_unknown_fields()` is the opt-out and
2311    /// stays unset, so a field the schema does not declare is an error,
2312    /// never silently dropped.
2313    #[test]
2314    fn json_parse_rejects_an_unknown_field() {
2315        let error = v2::from_json(r#"{"name": "veh.deep", "notAField": 1}"#)
2316            .expect_err("an unknown field must be rejected");
2317        assert!(
2318            error.to_string().contains("unknown field"),
2319            "the error must name the defect, got: {error}"
2320        );
2321    }
2322
2323    /// A reader narrowing ADR-0014 decision 14 records: the proto3 JSON
2324    /// mapping expects parsers to accept numeric enum values, and the
2325    /// generated deserializer does — within the schema's range. A
2326    /// discriminant outside it (`"visibility": 77`) is rejected, where the
2327    /// retired reflection reader accepted it — and the retired *writer*
2328    /// emitted exactly such a number for an out-of-schema discriminant.
2329    /// Pinned so the narrowing stays a decision rather than an accident: a
2330    /// future mechanism change must confront this test.
2331    #[test]
2332    fn json_parse_rejects_an_out_of_range_numeric_enum_value() {
2333        let with_visibility =
2334            |value: &str| format!(r#"{{"name": "veh.x", "decls": [{{"visibility": {value}}}]}}"#);
2335        v2::from_json(&with_visibility("1"))
2336            .expect("an in-range numeric enum value parses, as the mapping expects");
2337        let error = v2::from_json(&with_visibility("77"))
2338            .expect_err("an out-of-range numeric enum value must be rejected");
2339        assert!(
2340            error.to_string().contains("invalid value: integer `77`"),
2341            "the error must name the value, got: {error}"
2342        );
2343    }
2344
2345    /// A reader narrowing ADR-0014 decision 14 records: the mapping accepts
2346    /// float and exponent notation for integer fields (`"min": 1.0`), and
2347    /// the retired reflection reader did; the generated deserializer
2348    /// rejects both. Pinned for the same reason as the numeric-enum case
2349    /// above.
2350    #[test]
2351    fn json_parse_rejects_a_float_form_integer() {
2352        for spelling in ["1.0", "1e0"] {
2353            let error = v2::from_json(&format!(
2354                r#"{{"name": "veh.x", "decls": [{{"fixedDef": {{"payload": {{"array": {{"min": {spelling}}}}}}}}}]}}"#,
2355            ))
2356            .expect_err("a float-form integer must be rejected");
2357            assert!(
2358                error.to_string().contains("did not match any variant"),
2359                "the integer field's deserializer must be the one refusing `{spelling}`, \
2360                 got: {error}"
2361            );
2362        }
2363    }
2364
2365    /// A reader narrowing ADR-0014 decision 14 records: `null` for a
2366    /// repeated field (`"decls": null`), which the retired reflection
2367    /// reader read as empty, is rejected. `null` for an optional scalar or
2368    /// message field is still accepted — parity with the retired reader,
2369    /// asserted alongside so the narrowing's edge is pinned from both
2370    /// sides.
2371    #[test]
2372    fn json_parse_rejects_null_for_a_repeated_field() {
2373        let error = v2::from_json(r#"{"name": "veh.x", "decls": null}"#)
2374            .expect_err("null for a repeated field must be rejected");
2375        assert!(
2376            error.to_string().contains("invalid type: null"),
2377            "the error must name the null, got: {error}"
2378        );
2379        v2::from_json(r#"{"name": "veh.x", "decls": [{"deprecated": null}]}"#)
2380            .expect("null for an optional scalar field still parses");
2381    }
2382
2383    /// A reader narrowing ADR-0014 decision 14 records: a duplicate JSON
2384    /// key, which the retired reflection reader resolved last-wins, is
2385    /// rejected.
2386    #[test]
2387    fn json_parse_rejects_a_duplicate_key() {
2388        let error = v2::from_json(r#"{"name": "a", "name": "b"}"#)
2389            .expect_err("a duplicate key must be rejected");
2390        assert!(
2391            error.to_string().contains("duplicate field `name`"),
2392            "the error must name the duplicated field, got: {error}"
2393        );
2394    }
2395
2396    /// The read-side ceiling (ADR-0014 decision 14): nesting past 1,000
2397    /// bracket levels returns an error before the parse begins — a
2398    /// diagnostic, where unbounded recursion would eventually abort on a
2399    /// stack overflow no caller can catch. The input is real writer output:
2400    /// past the ceiling the asymmetry is deliberate — the writer is
2401    /// unrestricted, the reader is not.
2402    #[test]
2403    fn json_parse_past_the_nesting_ceiling_returns_an_error() {
2404        with_sized_stack(|| {
2405            let json = v2::to_json_pretty(&nested_package(500))
2406                .expect("the writer is unrestricted at this depth");
2407            let error = v2::from_json(&json).expect_err("the reader must refuse past its ceiling");
2408            assert!(
2409                error.to_string().contains("1000 JSON levels"),
2410                "the error must name the ceiling, got: {error}"
2411            );
2412        });
2413    }
2414
2415    /// The ceiling is exact: 1,000 open brackets pass the scan and reach the
2416    /// parser — which then rejects the input as not a package — and 1,001 do
2417    /// not. The scan runs before the parse, so the over-ceiling probe needs
2418    /// no valid JSON behind its brackets.
2419    #[test]
2420    fn json_nesting_ceiling_binds_exactly_at_1000() {
2421        let at = v2::from_json(&"[".repeat(1_000)).expect_err("an array is not a package");
2422        assert!(
2423            !at.to_string().contains("JSON levels"),
2424            "at the ceiling the parser, not the scan, must be the one refusing, got: {at}"
2425        );
2426
2427        let past = v2::from_json(&"[".repeat(1_001)).expect_err("past the ceiling, the scan");
2428        assert!(
2429            past.to_string().contains("1000 JSON levels"),
2430            "past the ceiling the error must name it, got: {past}"
2431        );
2432    }
2433
2434    /// The nesting scan behind the ceiling: brackets count only outside
2435    /// string literals, an escaped quote does not end a literal, an escaped
2436    /// backslash does not disarm the real closing quote after it, and a
2437    /// stray closer never underflows the running depth.
2438    #[test]
2439    fn nesting_scan_counts_brackets_outside_string_literals_only() {
2440        // Plain structural nesting counts every open bracket.
2441        assert_eq!(v2::max_json_nesting(r#"{"a": [{"b": []}]}"#), 4);
2442        // Brackets inside a string literal do not count.
2443        assert_eq!(v2::max_json_nesting(r#"{"doc": "{[[[{"}"#), 1);
2444        // An escaped quote does not end the literal, so the brackets after
2445        // it are still inside it.
2446        assert_eq!(v2::max_json_nesting(r#"{"doc": "a\"[[[", "x": []}"#), 2);
2447        // An escaped backslash does not escape the closing quote: the
2448        // literal ends, and the brackets after it count.
2449        assert_eq!(v2::max_json_nesting(r#"{"doc": "a\\", "x": [[]]}"#), 3);
2450        // A stray closer saturates at zero rather than underflowing.
2451        assert_eq!(v2::max_json_nesting("]]]{"), 1);
2452    }
2453
2454    /// The prototext form of [`nested_package`], built by hand for the same
2455    /// reason [`nested_json`] is: past the limit the serializer rejects the
2456    /// package, so its prototext cannot come from [`v2::to_text_format`].
2457    fn nested_text(depth: usize) -> String {
2458        let mut payload = "primitive: PRIMITIVE_TYPE_INTEGER".to_string();
2459        for _ in 0..depth {
2460            payload = format!("array {{ element {{ {payload} }} min: 1 max: 1 }}");
2461        }
2462        format!(
2463            r#"name: "veh.deep" decls {{ name: "deep" fixed_def {{ payload {{ {payload} }} }} }}"#
2464        )
2465    }
2466
2467    /// The prototext write path carries the same recursion-limit failure mode
2468    /// as JSON — both go through the one transcode (ADR-0014 decision 12) —
2469    /// and reports it as an error naming its own encoding, never a panic.
2470    #[test]
2471    fn text_serialization_past_the_nesting_limit_returns_an_error() {
2472        let err = v2::to_text_format(&nested_package(NESTING_PAST_LIMIT))
2473            .expect_err("serialization past the recursion limit must fail, not panic");
2474        let message = err.to_string();
2475        assert!(
2476            message.contains("recursion limit"),
2477            "the error must name the nesting limit as the known cause, got: {message}"
2478        );
2479        assert!(
2480            message.contains("prototext"),
2481            "the error must name the encoding that failed, got: {message}"
2482        );
2483    }
2484
2485    /// Runs `test` on a thread whose stack fits the recursion the test
2486    /// drives on its own thread. Two groups need one. The prototext parser
2487    /// recurses once per message level with debug-build frames large enough
2488    /// that the default 2 MiB test-thread stack overflows near 45 array
2489    /// levels — under prost's own recursion limit, so the depths
2490    /// [`NESTING_BELOW_LIMIT`] and [`NESTING_PAST_LIMIT`] pin are
2491    /// unreachable on that stack; the production paths are unaffected, since
2492    /// the toolchain writes prototext and never parses it (`ridl diff` and
2493    /// the baselines stay `.ir.json`, ADR-0014 decision 5). And the deep
2494    /// JSON tests drive the pbjson-generated writer, which recurses on the
2495    /// caller's stack (ADR-0014 decision 14 — only the reader sizes a
2496    /// thread of its own, inside `from_json`).
2497    fn with_sized_stack(test: impl FnOnce() + Send + 'static) {
2498        let outcome = std::thread::Builder::new()
2499            .stack_size(16 * 1024 * 1024)
2500            .spawn(test)
2501            .expect("spawn the large-stack test thread")
2502            .join();
2503        if let Err(payload) = outcome {
2504            std::panic::resume_unwind(payload);
2505        }
2506    }
2507
2508    /// The read direction: the text-format parser itself has no depth limit,
2509    /// so the failure is the transcode out of the dynamic message, mapped
2510    /// into the error return instead of expected on (ADR-0014 decision 12).
2511    #[test]
2512    fn text_parse_past_the_nesting_limit_returns_an_error() {
2513        with_sized_stack(|| {
2514            let error = v2::from_text_format(&nested_text(NESTING_PAST_LIMIT))
2515                .expect_err("parsing past the recursion limit must fail, not panic");
2516
2517            // Assert *which* stage failed: prost's transcoding decoder says
2518            // "recursion limit reached", and a parse-stage failure would
2519            // render through the `Parse` variant instead.
2520            let message = error.to_string();
2521            assert!(
2522                message.contains("recursion limit reached"),
2523                "the transcode out of the dynamic message must be the failing \
2524                 stage, got: {message}"
2525            );
2526        });
2527    }
2528
2529    /// The prototext bound must not tighten silently either: below the limit
2530    /// the package still serializes and round-trips.
2531    #[test]
2532    fn text_round_trip_below_the_nesting_limit_succeeds() {
2533        with_sized_stack(|| {
2534            let package = nested_package(NESTING_BELOW_LIMIT);
2535            let text = v2::to_text_format(&package)
2536                .expect("below the recursion limit, serialization succeeds");
2537            let decoded =
2538                v2::from_text_format(&text).expect("below the recursion limit, parsing succeeds");
2539            assert_eq!(package, decoded);
2540        });
2541    }
2542}
2543
2544#[cfg(test)]
2545mod vacuous_constraint {
2546    use crate::v2;
2547
2548    /// A constraint with every field absent. Each test sets only the field it
2549    /// is about, so no assertion can pass through a neighbouring field.
2550    fn constraint() -> v2::Constraint {
2551        v2::Constraint {
2552            min: None,
2553            max: None,
2554            step: None,
2555            len_min: None,
2556            len_max: None,
2557            pattern: None,
2558            pattern_const: None,
2559        }
2560    }
2561
2562    #[test]
2563    fn an_absent_or_empty_constraint_is_vacuous() {
2564        assert!(v2::constraint_is_vacuous(None));
2565        assert!(v2::constraint_is_vacuous(Some(&constraint())));
2566    }
2567
2568    #[test]
2569    fn a_step_constraint_is_non_vacuous() {
2570        let stepped = v2::Constraint {
2571            step: Some("0.5".to_string()),
2572            ..constraint()
2573        };
2574        assert!(!v2::constraint_is_vacuous(Some(&stepped)));
2575    }
2576
2577    /// Every constrained field on its own. A fixture setting a pair — `min`
2578    /// with `max`, or `len_min` with `len_max` — cannot tell a predicate that
2579    /// reads both from one that reads either, so each bound here is one-sided.
2580    /// The paired shapes are pinned separately by
2581    /// [`a_bound_pair_set_together_is_non_vacuous`], which a one-sided fixture
2582    /// cannot do.
2583    #[test]
2584    fn any_single_constrained_field_is_non_vacuous() {
2585        let cases = [
2586            (
2587                "min",
2588                v2::Constraint {
2589                    min: Some("0.0".to_string()),
2590                    ..constraint()
2591                },
2592            ),
2593            (
2594                "max",
2595                v2::Constraint {
2596                    max: Some("250.0".to_string()),
2597                    ..constraint()
2598                },
2599            ),
2600            (
2601                "len_min",
2602                v2::Constraint {
2603                    len_min: Some(1),
2604                    ..constraint()
2605                },
2606            ),
2607            (
2608                "len_max",
2609                v2::Constraint {
2610                    len_max: Some(256),
2611                    ..constraint()
2612                },
2613            ),
2614            (
2615                "pattern",
2616                v2::Constraint {
2617                    pattern: Some("^[a-z]+$".to_string()),
2618                    ..constraint()
2619                },
2620            ),
2621            (
2622                "pattern_const",
2623                v2::Constraint {
2624                    pattern_const: Some("NAME_PATTERN".to_string()),
2625                    ..constraint()
2626                },
2627            ),
2628        ];
2629        for (field, case) in cases {
2630            assert!(
2631                !v2::constraint_is_vacuous(Some(&case)),
2632                "`{field}` alone must be non-vacuous"
2633            );
2634        }
2635    }
2636
2637    /// The two shapes the checker actually emits: a declared range, and the
2638    /// typl §4.4 default `[0..256]` every string and bytes type carries.
2639    ///
2640    /// A one-sided fixture cannot pin these. A predicate reading each bound as
2641    /// a pair — `(c.min.is_none() == c.max.is_none())` and the same for the
2642    /// length bounds — passes every one-sided case and still reports both
2643    /// shapes below as vacuous, which would drop the range check from every
2644    /// bounded number and every string.
2645    #[test]
2646    fn a_bound_pair_set_together_is_non_vacuous() {
2647        let ranged = v2::Constraint {
2648            min: Some("0.0".to_string()),
2649            max: Some("250.0".to_string()),
2650            ..constraint()
2651        };
2652        assert!(!v2::constraint_is_vacuous(Some(&ranged)));
2653
2654        let default_length = v2::Constraint {
2655            len_min: Some(0),
2656            len_max: Some(256),
2657            ..constraint()
2658        };
2659        assert!(!v2::constraint_is_vacuous(Some(&default_length)));
2660    }
2661}
2662
2663#[cfg(test)]
2664mod system_round_trip {
2665    use crate::v2;
2666
2667    fn attribute(namespace: &str, key: &str, value: Option<v2::AttributeValue>) -> v2::Attribute {
2668        v2::Attribute {
2669            namespace: namespace.to_string(),
2670            key: key.to_string(),
2671            value,
2672        }
2673    }
2674
2675    fn scalar(text: &str) -> v2::AttributeValue {
2676        v2::AttributeValue {
2677            kind: Some(v2::attribute_value::Kind::Scalar(text.to_string())),
2678        }
2679    }
2680
2681    fn list(items: Vec<v2::AttributeValue>) -> v2::AttributeValue {
2682        v2::AttributeValue {
2683            kind: Some(v2::attribute_value::Kind::List(v2::AttributeList { items })),
2684        }
2685    }
2686
2687    fn interface(catalog: &str, name: &str, inline: bool) -> Option<v2::InterfaceRef> {
2688        Some(v2::InterfaceRef {
2689            catalog: catalog.to_string(),
2690            name: name.to_string(),
2691            inline,
2692        })
2693    }
2694
2695    fn endpoint(component: &str, instance: &str, machine: &str) -> v2::Endpoint {
2696        v2::Endpoint {
2697            component: component.to_string(),
2698            instance: instance.to_string(),
2699            machine: machine.to_string(),
2700        }
2701    }
2702
2703    /// A reduced rsdl reference Appendix A: `Cruise` with two instances
2704    /// offering `veh.adas.cruise` and requiring `LaneAssist`, the implicit
2705    /// component of `veh.diag.access`, one distribution and one deployment.
2706    /// Every message of `system.proto` appears at least once, with every
2707    /// scalar set to a value other than its default — a flag and a nested-list
2708    /// attribute value included — so a round trip that drops a field is
2709    /// caught.
2710    fn doc_link() -> v2::DocLink {
2711        v2::DocLink {
2712            text: "Cruise".to_string(),
2713            offset: 16,
2714            len: 6,
2715            target: "veh.topology.Cruise".to_string(),
2716        }
2717    }
2718
2719    fn fixture() -> v2::System {
2720        let link = v2::Link {
2721            interface: interface("veh.diag", "veh.diag.access", true),
2722            service: "veh.diag.access".to_string(),
2723            consumer: Some(endpoint("veh.topology.Backend", "Unit", "Cloud")),
2724            producer: Some(endpoint("veh.diag.access", "Unit", "AdasHpc")),
2725            crossing: v2::Crossing::OffBoard as i32,
2726        };
2727        v2::System {
2728            name: "Vehicle".to_string(),
2729            package: "veh.topology".to_string(),
2730            labels: vec!["ASIL_B".to_string()],
2731            attributes: vec![attribute("rust", "crate", Some(scalar("\"vehicle\"")))],
2732            members: vec![
2733                v2::MemberLine {
2734                    component: "veh.topology.Cruise".to_string(),
2735                    attributes: vec![attribute("linux", "pinned", None)],
2736                    doc: "Documented, see [Cruise].".to_string(),
2737                    links: vec![doc_link()],
2738                    see: vec![doc_link()],
2739                    since: vec!["1.2".to_string()],
2740                },
2741                v2::MemberLine {
2742                    component: "veh.diag.access".to_string(),
2743                    attributes: vec![],
2744                    doc: "Documented, see [Cruise].".to_string(),
2745                    links: vec![doc_link()],
2746                    see: vec![doc_link()],
2747                    since: vec!["1.2".to_string()],
2748                },
2749            ],
2750            components: vec![
2751                v2::Component {
2752                    name: "Cruise".to_string(),
2753                    package: "veh.topology".to_string(),
2754                    implicit: false,
2755                    external: true,
2756                    instances: vec!["primary".to_string(), "backup".to_string()],
2757                    offers: vec![v2::Offer {
2758                        service: "veh.adas.cruise".to_string(),
2759                        attributes: vec![attribute("someip", "serviceId", Some(scalar("4097")))],
2760                        doc: "Documented, see [Cruise].".to_string(),
2761                        links: vec![doc_link()],
2762                        see: vec![doc_link()],
2763                        since: vec!["1.2".to_string()],
2764                    }],
2765                    requires: vec![v2::Require {
2766                        interface: interface("veh.adas", "LaneAssist", false),
2767                        service: "veh.adas.lane".to_string(),
2768                        producer: "veh.topology.Lane".to_string(),
2769                        attributes: vec![attribute(
2770                            "linux",
2771                            "cpuset",
2772                            Some(list(vec![scalar("2"), list(vec![scalar("3")])])),
2773                        )],
2774                        doc: "Documented, see [Cruise].".to_string(),
2775                        links: vec![doc_link()],
2776                        see: vec![doc_link()],
2777                        since: vec!["1.2".to_string()],
2778                    }],
2779                    labels: vec!["ASIL_B".to_string()],
2780                    attributes: vec![attribute("rust", "crate", None)],
2781                    doc: "Documented, see [Cruise].".to_string(),
2782                    links: vec![doc_link()],
2783                    see: vec![doc_link()],
2784                    since: vec!["1.2".to_string()],
2785                },
2786                v2::Component {
2787                    name: "veh.diag.access".to_string(),
2788                    package: String::new(),
2789                    implicit: true,
2790                    external: false,
2791                    instances: vec!["Unit".to_string()],
2792                    offers: vec![v2::Offer {
2793                        service: "veh.diag.access".to_string(),
2794                        attributes: vec![],
2795                        doc: "Documented, see [Cruise].".to_string(),
2796                        links: vec![doc_link()],
2797                        see: vec![doc_link()],
2798                        since: vec!["1.2".to_string()],
2799                    }],
2800                    requires: vec![],
2801                    labels: vec![],
2802                    attributes: vec![],
2803                    doc: "Documented, see [Cruise].".to_string(),
2804                    links: vec![doc_link()],
2805                    see: vec![doc_link()],
2806                    since: vec!["1.2".to_string()],
2807                },
2808            ],
2809            producers: vec![v2::Producer {
2810                service: "veh.adas.cruise".to_string(),
2811                component: "veh.topology.Cruise".to_string(),
2812                instances: vec!["primary".to_string(), "backup".to_string()],
2813                not_yet_realizable: true,
2814            }],
2815            grants: vec![v2::Grant {
2816                component: "veh.topology.Backend".to_string(),
2817                external: true,
2818                regions: vec!["veh.adas".to_string(), "veh.diag".to_string()],
2819            }],
2820            regions: vec![v2::Region {
2821                catalog: "veh.diag".to_string(),
2822                hash: vec![0xab; 32],
2823                interfaces: vec![v2::RegionInterface {
2824                    name: "veh.diag.access".to_string(),
2825                    inline: true,
2826                    number: 2,
2827                    provisional: true,
2828                    service: "veh.diag.access".to_string(),
2829                }],
2830            }],
2831            distributions: vec![v2::Distribution {
2832                name: "Adas".to_string(),
2833                package: "veh.topology".to_string(),
2834                members: vec![v2::MemberLine {
2835                    component: "veh.topology.Cruise".to_string(),
2836                    attributes: vec![],
2837                    doc: "Documented, see [Cruise].".to_string(),
2838                    links: vec![doc_link()],
2839                    see: vec![doc_link()],
2840                    since: vec!["1.2".to_string()],
2841                }],
2842                depends_on: vec!["veh.topology.Base".to_string()],
2843                labels: vec!["PLATFORM_BUNDLE".to_string()],
2844                attributes: vec![attribute("deb", "section", Some(scalar("net")))],
2845                doc: "Documented, see [Cruise].".to_string(),
2846                links: vec![doc_link()],
2847                see: vec![doc_link()],
2848                since: vec!["1.2".to_string()],
2849            }],
2850            deployments: vec![v2::Deployment {
2851                name: "Production".to_string(),
2852                package: "veh.topology".to_string(),
2853                labels: vec!["FLEET".to_string()],
2854                attributes: vec![attribute("ota", "channel", Some(scalar("stable")))],
2855                machines: vec![v2::Machine {
2856                    name: "Cloud".to_string(),
2857                    external: true,
2858                    labels: vec!["OFF_BOARD".to_string()],
2859                    attributes: vec![attribute("net", "zone", Some(scalar("wan")))],
2860                    doc: "Documented, see [Cruise].".to_string(),
2861                    links: vec![doc_link()],
2862                    see: vec![doc_link()],
2863                    since: vec!["1.2".to_string()],
2864                }],
2865                placements: vec![v2::Placement {
2866                    component: "veh.topology.Cruise".to_string(),
2867                    instance: "backup".to_string(),
2868                    machine: "Cockpit".to_string(),
2869                    attributes: vec![attribute("linux", "cpuset", Some(list(vec![])))],
2870                    sizing: Some(v2::Sizing {
2871                        depth: Some(4_294_967_295),
2872                        slots: Some(65_536),
2873                        budget: Some(18_446_744_073_709_551_615),
2874                    }),
2875                }],
2876                links: vec![link.clone()],
2877                routes: vec![v2::Route {
2878                    catalog: "veh.adas".to_string(),
2879                    interface_number: 2,
2880                    member_ordinal: 1,
2881                    interface: "LaneAssist".to_string(),
2882                    member: "active".to_string(),
2883                    service: "veh.adas.lane".to_string(),
2884                    producers: vec![endpoint("veh.topology.Lane", "Unit", "AdasHpc")],
2885                }],
2886                surface: vec![v2::Surface {
2887                    link: Some(link),
2888                    direction: v2::SurfaceDirection::ExternalConsumes as i32,
2889                }],
2890                installations: vec![v2::Installation {
2891                    distribution: "veh.topology.Adas".to_string(),
2892                    machines: vec!["AdasHpc".to_string(), "Cockpit".to_string()],
2893                }],
2894                doc: "Documented, see [Cruise].".to_string(),
2895                doc_links: vec![doc_link()],
2896                see: vec![doc_link()],
2897                since: vec!["1.2".to_string()],
2898                sizing: Some(v2::Sizing {
2899                    depth: Some(3),
2900                    slots: Some(8),
2901                    budget: Some(4096),
2902                }),
2903            }],
2904            doc: "Documented, see [Cruise].".to_string(),
2905            see: vec![doc_link()],
2906            since: vec!["1.2".to_string()],
2907            links: vec![doc_link()],
2908        }
2909    }
2910
2911    #[test]
2912    fn system_binary_round_trip_preserves_system() {
2913        let system = fixture();
2914        let decoded = v2::system_from_binary(v2::system_to_binary(&system).as_slice())
2915            .expect("decode must succeed");
2916        assert_eq!(system, decoded);
2917    }
2918
2919    /// The canonical JSON of the system artifact re-reads through the same
2920    /// strict pbjson-generated impl the package uses (ADR-0014 decisions 11
2921    /// and 14): unknown fields rejected, the catalog hash as base64, enums by
2922    /// name, the nested attribute list intact.
2923    #[test]
2924    fn system_json_round_trip_preserves_system() {
2925        let system = fixture();
2926        let json = v2::system_to_json_pretty(&system).expect("the fixture serializes as JSON");
2927        assert!(
2928            json.contains("\"hash\": \"q6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6s=\""),
2929            "bytes render as base64, got:\n{json}"
2930        );
2931        assert!(
2932            json.contains("\"crossing\": \"CROSSING_OFF_BOARD\""),
2933            "enums render by name, got:\n{json}"
2934        );
2935        assert!(
2936            json.contains("\"notYetRealizable\": true"),
2937            "fields render in lowerCamelCase, got:\n{json}"
2938        );
2939        assert!(
2940            json.contains("\"budget\": \"18446744073709551615\""),
2941            "a 64-bit value renders as a string, got:\n{json}"
2942        );
2943        assert_eq!(
2944            system,
2945            v2::system_from_json(&json).expect("the JSON parses")
2946        );
2947        assert!(
2948            v2::system_from_json(&json.replacen("\"name\"", "\"nam\"", 1)).is_err(),
2949            "an unknown field is rejected"
2950        );
2951    }
2952
2953    /// An absent `sizing` and an empty one are different on the wire: a plugin
2954    /// reads "nothing declared" and "declared nothing" apart, in the binary and
2955    /// the JSON encodings.
2956    #[test]
2957    fn an_absent_sizing_and_an_empty_one_stay_distinct_through_the_encodings() {
2958        let mut absent = fixture();
2959        absent.deployments[0].sizing = None;
2960        absent.deployments[0].placements[0].sizing = None;
2961        let mut empty = absent.clone();
2962        empty.deployments[0].sizing = Some(v2::Sizing::default());
2963        empty.deployments[0].placements[0].sizing = Some(v2::Sizing::default());
2964        for system in [&absent, &empty] {
2965            let binary = v2::system_from_binary(v2::system_to_binary(system).as_slice())
2966                .expect("decode must succeed");
2967            assert_eq!(system, &binary);
2968            let json = v2::system_to_json_pretty(system).expect("serializes as JSON");
2969            assert_eq!(
2970                system,
2971                &v2::system_from_json(&json).expect("the JSON parses")
2972            );
2973        }
2974        assert_ne!(
2975            v2::system_to_binary(&absent),
2976            v2::system_to_binary(&empty),
2977            "the binary encodings differ"
2978        );
2979    }
2980
2981    #[test]
2982    fn system_text_format_round_trip_preserves_system() {
2983        let system = fixture();
2984        let text = v2::system_to_text_format(&system).expect("the fixture serializes as prototext");
2985        assert!(
2986            text.starts_with("name:"),
2987            "fields print in schema index order, got: {text}"
2988        );
2989        assert_eq!(
2990            system,
2991            v2::system_from_text_format(&text).expect("prototext parsing must succeed")
2992        );
2993    }
2994
2995    /// `pkg.Name` for a declared name; the implicit component of a lone
2996    /// service, which no package declares, is its own qualified name.
2997    #[test]
2998    fn qualified_names_follow_the_one_derivation() {
2999        let system = fixture();
3000        assert_eq!(system.qualified_name(), "veh.topology.Vehicle");
3001        assert_eq!(system.components[0].qualified_name(), "veh.topology.Cruise");
3002        assert_eq!(system.components[1].qualified_name(), "veh.diag.access");
3003        assert_eq!(
3004            system.distributions[0].qualified_name(),
3005            "veh.topology.Adas"
3006        );
3007    }
3008}