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