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            open: false,
1513        };
1514
1515        // enumset Warnings { LOW_FUEL = 0  ICE_RISK = 33 } — the standalone
1516        // form; bit 33 forces the u64 width and is a genuine int64 value.
1517        let warnings = v2::EnumSetDef {
1518            backing_enum: None,
1519            bits: vec![
1520                v2::EnumValue {
1521                    name: "LOW_FUEL".to_string(),
1522                    value: 0,
1523                    doc: String::new(),
1524                    links: Vec::new(),
1525                    see: Vec::new(),
1526                    since: Vec::new(),
1527                },
1528                v2::EnumValue {
1529                    name: "ICE_RISK".to_string(),
1530                    value: 33,
1531                    doc: String::new(),
1532                    links: Vec::new(),
1533                    see: Vec::new(),
1534                    since: Vec::new(),
1535                },
1536            ],
1537            width: v2::IntWidth::U64 as i32,
1538            open: false,
1539        };
1540
1541        // type PlateText : string [1..86] — character length bounds, two
1542        // genuine uint64 fields behind proto3 `optional`.
1543        let plate_text = v2::TypeDef {
1544            backing: Some(v2::Backing {
1545                kind: Some(v2::backing::Kind::Primitive(
1546                    v2::PrimitiveType::String as i32,
1547                )),
1548            }),
1549            constraint: Some(v2::Constraint {
1550                min: None,
1551                max: None,
1552                step: None,
1553                len_min: Some(1),
1554                len_max: Some(86),
1555                pattern: None,
1556                pattern_const: None,
1557            }),
1558            declared_init: None,
1559            init: None,
1560            width: None,
1561        };
1562
1563        // union Sample { speed : Speed  gear : Gear }
1564        let sample = v2::UnionDef {
1565            arms: vec![
1566                v2::UnionArm {
1567                    name: "speed".to_string(),
1568                    ordinal: 1,
1569                    type_ref: "Speed".to_string(),
1570                    doc: String::new(),
1571                    links: Vec::new(),
1572                    see: Vec::new(),
1573                    since: Vec::new(),
1574                },
1575                v2::UnionArm {
1576                    name: "gear".to_string(),
1577                    ordinal: 2,
1578                    type_ref: "Gear".to_string(),
1579                    doc: String::new(),
1580                    links: Vec::new(),
1581                    see: Vec::new(),
1582                    since: Vec::new(),
1583                },
1584            ],
1585            is_result: false,
1586            reserved: Vec::new(),
1587        };
1588
1589        // retries : integer [0..24] = 3 — the boxed `inlineScalar` oneof
1590        // member: the committed regression guard for ADR-0014 Open item 2,
1591        // which established that the Rust-side `Box` is invisible to the
1592        // reflection path. The enclosing field carries the init; the nested
1593        // TypeDef's stays unset.
1594        let retries = v2::Field {
1595            declared_init: Some("3".to_string()),
1596            init: Some(v2::InitValue {
1597                derivable: true,
1598                value: Some("3".to_string()),
1599            }),
1600            ..field(
1601                "retries",
1602                1,
1603                v2::FieldType {
1604                    optional: false,
1605                    kind: Some(v2::field_type::Kind::InlineScalar(Box::new(v2::TypeDef {
1606                        backing: Some(v2::Backing {
1607                            kind: Some(v2::backing::Kind::Primitive(
1608                                v2::PrimitiveType::Integer as i32,
1609                            )),
1610                        }),
1611                        constraint: Some(v2::Constraint {
1612                            min: Some("0".to_string()),
1613                            max: Some("24".to_string()),
1614                            step: None,
1615                            len_min: None,
1616                            len_max: None,
1617                            pattern: None,
1618                            pattern_const: None,
1619                        }),
1620                        declared_init: None,
1621                        init: None,
1622                        width: Some(v2::type_def::Width::IntWidth(v2::IntWidth::U8 as i32)),
1623                    }))),
1624                },
1625            )
1626        };
1627
1628        // position : (x : Speed, y : Speed) — an anonymous named-field
1629        // composite (typl §11).
1630        let position = field(
1631            "position",
1632            2,
1633            v2::FieldType {
1634                optional: false,
1635                kind: Some(v2::field_type::Kind::Tuple(v2::TupleType {
1636                    fields: vec![
1637                        v2::TupleField {
1638                            name: "x".to_string(),
1639                            r#type: Some(named_type("Speed")),
1640                        },
1641                        v2::TupleField {
1642                            name: "y".to_string(),
1643                            r#type: Some(named_type("Speed")),
1644                        },
1645                    ],
1646                })),
1647            },
1648        );
1649
1650        // gears : [Gear; 1..4096] — array bounds are genuine uint64 fields.
1651        let gears = field(
1652            "gears",
1653            3,
1654            v2::FieldType {
1655                optional: false,
1656                kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
1657                    element: Some(Box::new(named_type("Gear"))),
1658                    min: 1,
1659                    max: 4096,
1660                }))),
1661            },
1662        );
1663
1664        // plates : { PlateText -> Gear } [0..53] — map bounds are genuine
1665        // uint64 fields. The field is deprecated, covering the optional
1666        // string on the Field envelope.
1667        let plates = v2::Field {
1668            deprecated: Some("superseded by gears".to_string()),
1669            ..field(
1670                "plates",
1671                4,
1672                v2::FieldType {
1673                    optional: false,
1674                    kind: Some(v2::field_type::Kind::Map(Box::new(v2::MapType {
1675                        key: Some(Box::new(named_type("PlateText"))),
1676                        value: Some(Box::new(named_type("Gear"))),
1677                        min: 0,
1678                        max: 53,
1679                    }))),
1680                },
1681            )
1682        };
1683
1684        let snapshot = v2::StructDef {
1685            members: [retries, position, gears, plates]
1686                .into_iter()
1687                .map(|field| v2::StructMember {
1688                    member: Some(v2::struct_member::Member::Field(Box::new(field))),
1689                })
1690                .collect(),
1691            fixed_layout: false,
1692        };
1693
1694        v2::Package {
1695            name: "veh.vocab".to_string(),
1696            decls: vec![
1697                decl("MAX_RETRY", v2::decl::Kind::ConstDef(max_retry)),
1698                decl("Gear", v2::decl::Kind::EnumDef(gear)),
1699                decl("Warnings", v2::decl::Kind::EnumSetDef(warnings)),
1700                decl("PlateText", v2::decl::Kind::TypeDef(plate_text)),
1701                // The union is deprecated — the optional string on the Decl
1702                // envelope.
1703                v2::Decl {
1704                    deprecated: Some("use Snapshot".to_string()),
1705                    ..decl("Sample", v2::decl::Kind::UnionDef(sample))
1706                },
1707                decl("Snapshot", v2::decl::Kind::StructDef(snapshot)),
1708            ],
1709            interfaces: Vec::new(),
1710            services: Vec::new(),
1711            retired: Vec::new(),
1712            unit: String::new(),
1713        }
1714    }
1715
1716    #[test]
1717    fn protobuf_round_trip_preserves_package() {
1718        let package = fixture();
1719
1720        let buf = v2::to_binary(&package);
1721        let decoded = v2::from_binary(buf.as_slice()).expect("decode must succeed");
1722
1723        assert_eq!(package, decoded);
1724
1725        // The vocabulary fixture rides the same round trip.
1726        let vocabulary = vocabulary_fixture();
1727        let decoded_vocabulary =
1728            v2::from_binary(v2::to_binary(&vocabulary).as_slice()).expect("decode must succeed");
1729        assert_eq!(vocabulary, decoded_vocabulary);
1730
1731        let interface = &decoded.interfaces[0];
1732        let ordinals: Vec<u32> = interface.interactions.iter().map(|d| d.ordinal).collect();
1733        assert_eq!(
1734            ordinals,
1735            [1, 2, 3, 4, 5, 6],
1736            "one ordinal sequence, tombstone counted (ridl §11)"
1737        );
1738        let Some(v2::decl::Kind::ReservedSlot(tombstone)) = &interface.interactions[2].kind else {
1739            panic!("ordinal 3 must decode as a reserved tombstone");
1740        };
1741        assert_eq!(tombstone.name.as_deref(), Some("legacyMode"));
1742        let Some(v2::service_shape::Kind::Inline(inline)) = decoded.services[1]
1743            .shapes
1744            .first()
1745            .and_then(|slot| slot.kind.as_ref())
1746        else {
1747            panic!("veh.adas.logs must decode as an inline shape");
1748        };
1749        assert_eq!(inline.name, "", "an inline shape carries no name");
1750        let references: Vec<&str> = decoded.services[0]
1751            .shapes
1752            .iter()
1753            .filter_map(|slot| match &slot.kind {
1754                Some(v2::service_shape::Kind::InterfaceRef(reference)) => Some(reference.as_str()),
1755                _ => None,
1756            })
1757            .collect();
1758        assert_eq!(
1759            references,
1760            ["VehicleStatus"],
1761            "a service's set carries its references and nothing else"
1762        );
1763    }
1764
1765    /// A package with one interface `I` holding one command `c` of the given
1766    /// family and shape.
1767    fn package_with_command(family: v2::Family, shape: v2::Shape) -> v2::Package {
1768        v2::Package {
1769            name: "app".to_string(),
1770            interfaces: vec![v2::Interface {
1771                name: "I".to_string(),
1772                visibility: v2::Visibility::Public as i32,
1773                interactions: vec![v2::Decl {
1774                    name: "c".to_string(),
1775                    visibility: v2::Visibility::Public as i32,
1776                    ordinal: 1,
1777                    kind: Some(v2::decl::Kind::CommandDef(v2::CommandDef {
1778                        family: family as i32,
1779                        shape: shape as i32,
1780                        ..Default::default()
1781                    })),
1782                    ..Default::default()
1783                }],
1784                ..Default::default()
1785            }],
1786            ..Default::default()
1787        }
1788    }
1789
1790    /// `(command, intent, none)` is refused where the IR is read, by both
1791    /// readers: ADR-0012 decision 5 admits no generic intent command.
1792    #[test]
1793    fn the_readers_reject_an_invalid_interaction_class() {
1794        let package = package_with_command(v2::Family::Intent, v2::Shape::None);
1795        let json = v2::render_json(&package).expect("the raw writer renders it");
1796        let err = v2::from_json(&json).expect_err("the JSON reader must refuse it");
1797        assert!(
1798            err.to_string().contains("invalid interaction class"),
1799            "{err}"
1800        );
1801        let bytes = prost::Message::encode_to_vec(&package);
1802        let err = v2::from_binary(&bytes).expect_err("the binary reader must refuse it");
1803        assert!(
1804            err.to_string().contains("invalid interaction class"),
1805            "{err}"
1806        );
1807    }
1808
1809    #[test]
1810    fn the_json_writer_refuses_an_invalid_interaction_class() {
1811        let package = package_with_command(v2::Family::Intent, v2::Shape::None);
1812        let err = v2::to_json_pretty(&package).expect_err("the writer must refuse it");
1813        assert!(matches!(err, v2::SerializeError::Class(_)), "{err}");
1814    }
1815
1816    #[test]
1817    fn a_valid_class_round_trips_through_both_readers() {
1818        let package = package_with_command(v2::Family::Intent, v2::Shape::Toggle);
1819        let json = v2::to_json_pretty(&package).expect("the writer renders it");
1820        assert_eq!(v2::from_json(&json).expect("the JSON parses"), package);
1821        assert_eq!(
1822            v2::from_binary(&v2::to_binary(&package)).expect("the binary decodes"),
1823            package
1824        );
1825    }
1826
1827    /// An IR written before `family` existed has no such key and reads as
1828    /// dispatch.
1829    #[test]
1830    fn a_snapshot_without_family_reads_as_dispatch() {
1831        let package = package_with_command(v2::Family::Dispatch, v2::Shape::None);
1832        let json = v2::to_json_pretty(&package).expect("the writer renders it");
1833        let mut value: serde_json::Value =
1834            serde_json::from_str(&json).expect("the rendering is JSON");
1835        let command = value["interfaces"][0]["interactions"][0]["commandDef"]
1836            .as_object_mut()
1837            .expect("the command object");
1838        assert!(command.remove("family").is_some(), "`family` was rendered");
1839        assert!(command.remove("shape").is_some(), "`shape` was rendered");
1840        let stripped = serde_json::to_string(&value).expect("the stripped value renders");
1841        let read = v2::from_json(&stripped).expect("a 0.9 snapshot reads");
1842        assert_eq!(read, package);
1843    }
1844
1845    #[test]
1846    fn json_round_trip_preserves_package() {
1847        for package in [fixture(), vocabulary_fixture()] {
1848            let json = v2::to_json_pretty(&package).expect("the fixture serializes as IR JSON");
1849            let decoded = v2::from_json(&json).expect("json deserialization must succeed");
1850
1851            assert_eq!(package, decoded);
1852        }
1853    }
1854
1855    /// The interface identity fields the lock design §9 adds — `number` and
1856    /// `provisional` on every `Interface`, an inline shape included, and the
1857    /// package's `retired` list — ride all three encodings unchanged, and the
1858    /// JSON writes them under their canonical names even when they hold their
1859    /// defaults (ADR-0014 decision 2), so a reader can tell `number` 0 from an
1860    /// absent field only by the schema, never by the text.
1861    #[test]
1862    fn number_provisional_and_retired_round_trip_through_json_text_and_binary() {
1863        let mut package = fixture();
1864        package.interfaces[0].number = 4;
1865        package.interfaces[0].provisional = true;
1866        let Some(v2::service_shape::Kind::Inline(inline)) =
1867            package.services[1].shapes[0].kind.as_mut()
1868        else {
1869            panic!("veh.adas.logs holds an inline shape in slot 1");
1870        };
1871        inline.number = 5;
1872        package.retired = vec![
1873            v2::RetiredInterface {
1874                name: "LaneAssist".to_string(),
1875                number: 2,
1876            },
1877            v2::RetiredInterface {
1878                name: "service:veh.hvac.cabin".to_string(),
1879                number: 3,
1880            },
1881        ];
1882
1883        let json = v2::to_json_pretty(&package).expect("the package serializes as IR JSON");
1884        assert_eq!(v2::from_json(&json).expect("the JSON parses back"), package);
1885        let text = v2::to_text_format(&package).expect("the package serializes as prototext");
1886        assert_eq!(
1887            v2::from_text_format(&text).expect("the prototext parses back"),
1888            package
1889        );
1890        assert_eq!(
1891            v2::from_binary(v2::to_binary(&package).as_slice()).expect("the binary decodes"),
1892            package
1893        );
1894
1895        for needle in [
1896            r#""number": 4"#,
1897            r#""provisional": true"#,
1898            r#""number": 5"#,
1899            r#""name": "LaneAssist""#,
1900            r#""name": "service:veh.hvac.cabin""#,
1901        ] {
1902            assert!(
1903                json.contains(needle),
1904                "the JSON must carry {needle}, got: {json}"
1905            );
1906        }
1907
1908        // A default holds its place in the text (decision 2): an interface
1909        // that was never numbered writes `0` and `false`, and a package with
1910        // nothing retired writes an empty list.
1911        let unnumbered = v2::to_json_pretty(&fixture()).expect("the fixture serializes as IR JSON");
1912        for needle in [
1913            r#""number": 0"#,
1914            r#""provisional": false"#,
1915            r#""retired": []"#,
1916        ] {
1917            assert!(
1918                unnumbered.contains(needle),
1919                "a default field must still be written, expected {needle} in: {unnumbered}"
1920            );
1921        }
1922    }
1923
1924    /// A baseline published before the lock existed carries no `number`, no
1925    /// `provisional` and no `retired` field. It still loads — a missing field
1926    /// reads as its default, which is the `number` 0 the lock design §7 names
1927    /// as the one transition case — while an unknown field is still rejected
1928    /// (`json_parse_rejects_an_unknown_field`).
1929    #[test]
1930    fn a_snapshot_lacking_the_number_fields_still_loads() {
1931        let package = v2::from_json(
1932            r#"{"name": "veh.x", "interfaces": [{"name": "LaneKeeping"}], "services": [{"name": "veh.x.s", "shapes": [{"inline": {"name": ""}}]}]}"#,
1933        )
1934        .expect("a pre-lock snapshot loads");
1935
1936        assert_eq!(package.interfaces[0].number, 0);
1937        assert!(!package.interfaces[0].provisional);
1938        let Some(v2::service_shape::Kind::Inline(inline)) =
1939            package.services[0].shapes[0].kind.as_ref()
1940        else {
1941            panic!("the service holds an inline shape");
1942        };
1943        assert_eq!(inline.number, 0);
1944        assert!(!inline.provisional);
1945        assert_eq!(package.retired, Vec::new());
1946    }
1947
1948    /// The prototext read path (ADR-0014 decision 7): both fixtures survive
1949    /// `to_text_format` then `from_text_format` unchanged. With the binary
1950    /// and JSON round trips above, this is what proves all three encodings
1951    /// carry the same IR.
1952    #[test]
1953    fn text_format_round_trip_preserves_package() {
1954        for package in [fixture(), vocabulary_fixture()] {
1955            let text = v2::to_text_format(&package).expect("the fixture serializes as prototext");
1956            let decoded = v2::from_text_format(&text).expect("prototext parsing must succeed");
1957
1958            assert_eq!(package, decoded);
1959        }
1960    }
1961
1962    /// The prototext options ADR-0014 decision 8 fixes — `pretty`,
1963    /// `skip_default_fields(false)`, `print_message_fields_in_index_order`.
1964    /// Any option set round-trips, which is why the round-trip test above
1965    /// cannot guard them.
1966    ///
1967    /// The first two are asserted through a visible consequence. The third is
1968    /// **not guarded here and cannot be on this schema**: every message in
1969    /// `ir.proto` declares its fields in ascending field-number order, and
1970    /// field-number order is also `prost-reflect`'s default, so index order
1971    /// and default order coincide everywhere and dropping the option would
1972    /// change no output. It is set because the schema's ordering is a
1973    /// property of the schema rather than a guarantee, and a message whose
1974    /// declaration order departs from its numbering would otherwise reorder
1975    /// every artifact it appears in.
1976    #[test]
1977    fn text_format_is_pretty_with_defaults_in_index_order() {
1978        let text = v2::to_text_format(&fixture()).expect("the fixture serializes as prototext");
1979
1980        // pretty: nested messages are indented, one field per line.
1981        assert!(
1982            text.contains("\n  "),
1983            "pretty printing must indent nested fields, got: {text}"
1984        );
1985        // skip_default_fields(false): a field holding its default is present
1986        // (decision 2 — `ordinal: 0` is read, not inferred from absence).
1987        assert!(
1988            text.contains("is_error: false"),
1989            "a field holding its default must be emitted, got: {text}"
1990        );
1991        // print_message_fields_in_index_order: `name` is field 1 of
1992        // `Package`, so it opens the output.
1993        assert!(
1994            text.starts_with("name:"),
1995            "fields must print in schema index order, got: {text}"
1996        );
1997    }
1998
1999    /// Parses emitted JSON the way ADR-0014 decision 11's conformance test
2000    /// requires: unknown fields rejected, trailing input rejected. Since
2001    /// decision 14 the strict parser is the pbjson-generated `Deserialize`
2002    /// impl, whose default already rejects unknown fields
2003    /// (`ignore_unknown_fields()` stays unset in `build.rs`), so the
2004    /// strictness needs no option to opt into.
2005    fn strict_parse(json: &str) -> v2::Package {
2006        let mut deserializer = serde_json::Deserializer::from_str(json);
2007        let package = <v2::Package as serde::Deserialize>::deserialize(&mut deserializer)
2008            .expect("a strict conformant parser must accept the emitted JSON");
2009        deserializer.end().expect("no trailing input");
2010        package
2011    }
2012
2013    /// The conformance claim of ADR-0014 decision 11: a conformant protobuf
2014    /// JSON parser configured to reject unknown fields accepts the emitted
2015    /// JSON. Re-reading tests that claim itself; asserting on the rendered
2016    /// text would only restate the serializer's behaviour back to itself.
2017    #[test]
2018    fn emitted_json_survives_a_strict_conformant_parse() {
2019        for package in [fixture(), vocabulary_fixture()] {
2020            let json = v2::to_json_pretty(&package).expect("the fixture serializes as IR JSON");
2021            assert_eq!(package, strict_parse(&json));
2022        }
2023    }
2024
2025    #[test]
2026    fn json_renders_timing_bounds_and_fallible_arms_exactly() {
2027        let json = v2::to_json_pretty(&fixture()).expect("the fixture serializes as IR JSON");
2028
2029        // Exactness is visible: timing bounds are exact-decimal microsecond
2030        // strings, never floating-point numbers (ADR-0008 decision 12) —
2031        // under the canonical lowerCamelCase field name (ADR-0014 decision 1).
2032        assert!(
2033            json.contains(r#""minUs": "10000""#),
2034            "the timing bound must be a JSON string, got: {json}"
2035        );
2036        // Both arms of the inline T | E return are visible by name.
2037        assert!(
2038            json.contains(r#""ok": "FaultPage""#),
2039            "the ok arm must render, got: {json}"
2040        );
2041        assert!(
2042            json.contains(r#""err": "DiagError""#),
2043            "the err arm must render, got: {json}"
2044        );
2045    }
2046
2047    /// ADR-0014 decision 8's stringification, tested on genuine 64-bit
2048    /// fields. The timing assertion above proves nothing about it —
2049    /// `Timing.min_us` is `optional string` in the schema — so the claim
2050    /// needs fields whose wire type actually is `uint64` or `int64`.
2051    #[test]
2052    fn json_renders_64_bit_integer_fields_as_strings() {
2053        let json = v2::to_json_pretty(&vocabulary_fixture())
2054            .expect("the vocabulary fixture serializes as IR JSON");
2055
2056        // uint64: the array's upper bound.
2057        assert!(
2058            json.contains(r#""max": "4096""#),
2059            "an array bound must be a JSON string, got: {json}"
2060        );
2061        // uint64 behind proto3 `optional`: the character length bound.
2062        assert!(
2063            json.contains(r#""lenMax": "86""#),
2064            "a length bound must be a JSON string, got: {json}"
2065        );
2066        // int64: the retired enum value and the enum-set bit position.
2067        assert!(
2068            json.contains(r#""value": "7""#),
2069            "a retired enum value must be a JSON string, got: {json}"
2070        );
2071        assert!(
2072            json.contains(r#""value": "33""#),
2073            "an enum-set bit position must be a JSON string, got: {json}"
2074        );
2075    }
2076
2077    #[test]
2078    fn fallible_transport_identity_follows_the_derivation_rule() {
2079        // The ADR-0008 decision 4 rule: interface + interaction ordinal +
2080        // both arm references, in that order.
2081        let fallible = v2::FallibleType {
2082            ok: "FaultPage".to_string(),
2083            err: "DiagError".to_string(),
2084        };
2085        assert_eq!(
2086            v2::fallible_transport_identity("VehicleStatus", 9, &fallible),
2087            "VehicleStatus#9:FaultPage|DiagError"
2088        );
2089
2090        // Derived from the fixture: the fallible query sits at ordinal 5.
2091        let package = fixture();
2092        let interface = &package.interfaces[0];
2093        let query_decl = &interface.interactions[4];
2094        let Some(v2::decl::Kind::QueryDef(query)) = &query_decl.kind else {
2095            panic!("ordinal 5 must be the fallible query");
2096        };
2097        let Some(v2::return_type::Kind::Fallible(arms)) = &query.return_type.as_ref().unwrap().kind
2098        else {
2099            panic!("fetchFaults must return a fallible type");
2100        };
2101        assert_eq!(
2102            v2::fallible_transport_identity(&interface.name, query_decl.ordinal, arms),
2103            "VehicleStatus#5:FaultPage|DiagError"
2104        );
2105    }
2106
2107    /// `Package::shapes` yields the named interfaces first, then the inline
2108    /// shapes of the services — and each shape carries the name it is known by
2109    /// OUTSIDE the package. The fixture's inline shape has `Interface.name ==
2110    /// ""` by construction, so a walk that yielded the interface bare would
2111    /// hand every consumer the empty string; two of the six E2 defects were
2112    /// exactly that.
2113    #[test]
2114    fn shapes_walks_named_interfaces_and_inline_service_shapes() {
2115        let package = fixture();
2116        let walk: Vec<(&str, bool, usize)> = package
2117            .shapes()
2118            .map(|shape| {
2119                (
2120                    shape.name,
2121                    shape.is_inline(),
2122                    shape.interface.interactions.len(),
2123                )
2124            })
2125            .collect();
2126        assert_eq!(
2127            walk,
2128            [("VehicleStatus", false, 6), ("veh.adas.logs", true, 1)],
2129            "the named interface, then the inline shape under the service's \
2130             dotted name",
2131        );
2132
2133        // The fixture's third shape-bearing declaration is `service
2134        // veh.adas.status : VehicleStatus`, which names an interface already in
2135        // the walk. Yielding it too would visit `VehicleStatus` twice.
2136        assert_eq!(package.services.len(), 2, "one reference form, one inline");
2137        assert!(
2138            !package
2139                .shapes()
2140                .any(|shape| shape.name == "veh.adas.status"),
2141            "a service naming an interface contributes no shape of its own",
2142        );
2143    }
2144
2145    #[test]
2146    fn relative_name_strips_the_unit_prefix() {
2147        assert_eq!(v2::relative_name("u", "u", "Session"), "Session");
2148        assert_eq!(
2149            v2::relative_name("u", "u.cluster", "Speed"),
2150            "cluster.Speed"
2151        );
2152        assert_eq!(
2153            v2::relative_name("com.example.hmi", "com.example.hmi.cluster.front", "A"),
2154            "cluster.front.A"
2155        );
2156    }
2157
2158    /// A package that is not inside the unit keeps its full name: no slice
2159    /// past the unit's length, whatever the snapshot says.
2160    #[test]
2161    fn relative_name_keeps_the_full_name_of_a_package_outside_the_unit() {
2162        assert_eq!(
2163            v2::relative_name("u", "w.cluster", "Speed"),
2164            "w.cluster.Speed"
2165        );
2166        assert_eq!(v2::relative_name("u", "ux", "A"), "ux.A");
2167        assert_eq!(v2::relative_name("u.cluster", "u", "Session"), "u.Session");
2168    }
2169
2170    /// A package named twice in the slice contributes the retired entries
2171    /// of its first occurrence only: two distinct values with the same name
2172    /// are not merged, and the later one is not read.
2173    #[test]
2174    fn unit_retired_reads_a_package_named_twice_once() {
2175        let first = v2::Package {
2176            name: "u.cluster".to_string(),
2177            unit: "u".to_string(),
2178            retired: vec![v2::RetiredInterface {
2179                name: "cluster.Old".to_string(),
2180                number: 3,
2181            }],
2182            ..Default::default()
2183        };
2184        let second = v2::Package {
2185            retired: vec![v2::RetiredInterface {
2186                name: "cluster.Older".to_string(),
2187                number: 4,
2188            }],
2189            ..first.clone()
2190        };
2191        assert_eq!(
2192            v2::unit_retired("u", &[&first, &second]),
2193            first.retired,
2194            "the first package of a name is the one read"
2195        );
2196    }
2197
2198    #[test]
2199    fn an_inline_shape_keeps_its_global_name() {
2200        let mut package = fixture();
2201        package.name = "u.cluster".to_string();
2202        package.unit = "u".to_string();
2203        let inline = package
2204            .shapes()
2205            .find(|shape| shape.is_inline())
2206            .expect("the fixture has an inline shape");
2207        assert_eq!(package.catalog_name(&inline), "veh.adas.logs");
2208        let named = package
2209            .shapes()
2210            .find(|shape| !shape.is_inline())
2211            .expect("the fixture has a declared interface");
2212        assert_eq!(package.catalog_name(&named), "cluster.VehicleStatus");
2213    }
2214
2215    #[test]
2216    fn catalog_name_treats_an_empty_unit_as_the_package_name() {
2217        let mut package = fixture();
2218        package.unit = String::new();
2219        let named = package
2220            .shapes()
2221            .find(|shape| !shape.is_inline())
2222            .expect("the fixture has a declared interface");
2223        assert_eq!(package.catalog_name(&named), "VehicleStatus");
2224    }
2225
2226    #[test]
2227    fn unit_of_falls_back_to_the_name() {
2228        let bare = v2::Package {
2229            name: "p".to_string(),
2230            ..Default::default()
2231        };
2232        assert_eq!(v2::unit_of(&bare), "p");
2233        let member = v2::Package {
2234            name: "u.a".to_string(),
2235            unit: "u".to_string(),
2236            ..Default::default()
2237        };
2238        assert_eq!(v2::unit_of(&member), "u");
2239    }
2240
2241    #[test]
2242    fn packages_of_unit_selects_by_unit_of() {
2243        let package = |name: &str, unit: &str| v2::Package {
2244            name: name.to_string(),
2245            unit: unit.to_string(),
2246            ..Default::default()
2247        };
2248        let packages = [
2249            package("u", "u"),
2250            package("u.a", "u"),
2251            package("w", ""),
2252            package("other.b", "other"),
2253        ];
2254        let names: Vec<&str> = v2::packages_of_unit("u", &packages)
2255            .map(|p| p.name.as_str())
2256            .collect();
2257        assert_eq!(names, ["u", "u.a"]);
2258        let legacy: Vec<&str> = v2::packages_of_unit("w", &packages)
2259            .map(|p| p.name.as_str())
2260            .collect();
2261        assert_eq!(legacy, ["w"]);
2262    }
2263
2264    /// The owning service is carried because `Service.visibility` is the
2265    /// authoritative one: an inline shape's own field is
2266    /// `VISIBILITY_UNSPECIFIED` by construction, which is not "internal" and
2267    /// not "public".
2268    #[test]
2269    fn shape_visibility_reads_the_owning_service_for_an_inline_shape() {
2270        let package = fixture();
2271        let shapes: Vec<v2::InterfaceShape<'_>> = package.shapes().collect();
2272
2273        let named = shapes[0];
2274        assert!(named.service.is_none());
2275        assert_eq!(named.visibility(), v2::Visibility::Public as i32);
2276        assert_eq!(named.visibility(), named.interface.visibility);
2277
2278        let inline = shapes[1];
2279        assert_eq!(
2280            inline.interface.visibility,
2281            v2::Visibility::Unspecified as i32,
2282            "the trap: an inline shape's own visibility field is unset",
2283        );
2284        assert_eq!(
2285            inline.service.expect("an inline shape has an owner").name,
2286            "veh.adas.logs",
2287        );
2288        assert_eq!(
2289            inline.visibility(),
2290            v2::Visibility::Public as i32,
2291            "the accessor reads the owning service's, never the unset field",
2292        );
2293    }
2294
2295    /// A package with no service at all still walks its interfaces, and a
2296    /// package with neither yields nothing — the emptiness both backends test
2297    /// for before emitting any interaction vocabulary.
2298    #[test]
2299    fn shapes_is_empty_only_when_the_package_declares_no_shape() {
2300        let mut package = fixture();
2301        package.services.clear();
2302        assert_eq!(package.shapes().count(), 1);
2303
2304        package.interfaces.clear();
2305        assert_eq!(package.shapes().count(), 0);
2306    }
2307
2308    /// A dotted reference contributes its qualifier; a bare one contributes
2309    /// nothing. Every recursive path through `walk_field_type` — array
2310    /// element, tuple field, map key, map value, stream element — carries a
2311    /// distinct qualifier, so no path's absence can hide behind another
2312    /// path's presence: deleting any one arm's body changes the expected set
2313    /// this test compares against, rather than leaving it unchanged.
2314    #[test]
2315    fn referenced_packages_finds_qualifiers_at_depth() {
2316        fn named(reference: &str) -> v2::FieldType {
2317            v2::FieldType {
2318                kind: Some(v2::field_type::Kind::Named(reference.to_string())),
2319                ..Default::default()
2320            }
2321        }
2322
2323        fn fixed(payload: v2::FieldType) -> v2::decl::Kind {
2324            v2::decl::Kind::FixedDef(v2::FixedDef {
2325                payload: Some(payload),
2326            })
2327        }
2328
2329        let package = v2::Package {
2330            name: "veh.cluster".to_string(),
2331            decls: vec![
2332                v2::Decl {
2333                    name: "Local".to_string(),
2334                    kind: Some(v2::decl::Kind::SignalDef(v2::SignalDef {
2335                        payload: "Speed".to_string(),
2336                        ..Default::default()
2337                    })),
2338                    ..Default::default()
2339                },
2340                v2::Decl {
2341                    name: "Stamped".to_string(),
2342                    kind: Some(v2::decl::Kind::SignalDef(v2::SignalDef {
2343                        payload: "ridl.std.Timestamp".to_string(),
2344                        ..Default::default()
2345                    })),
2346                    ..Default::default()
2347                },
2348                v2::Decl {
2349                    name: "ArrLabels".to_string(),
2350                    kind: Some(fixed(v2::FieldType {
2351                        kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
2352                            element: Some(Box::new(named("veh.arr.Label"))),
2353                            min: 0,
2354                            max: 32,
2355                        }))),
2356                        ..Default::default()
2357                    })),
2358                    ..Default::default()
2359                },
2360                v2::Decl {
2361                    name: "TupThing".to_string(),
2362                    kind: Some(fixed(v2::FieldType {
2363                        kind: Some(v2::field_type::Kind::Tuple(v2::TupleType {
2364                            fields: vec![v2::TupleField {
2365                                name: "x".to_string(),
2366                                r#type: Some(named("veh.tup.X")),
2367                            }],
2368                        })),
2369                        ..Default::default()
2370                    })),
2371                    ..Default::default()
2372                },
2373                v2::Decl {
2374                    name: "MapThing".to_string(),
2375                    kind: Some(fixed(v2::FieldType {
2376                        kind: Some(v2::field_type::Kind::Map(Box::new(v2::MapType {
2377                            key: Some(Box::new(named("veh.key.X"))),
2378                            value: Some(Box::new(named("veh.val.X"))),
2379                            min: 0,
2380                            max: 8,
2381                        }))),
2382                        ..Default::default()
2383                    })),
2384                    ..Default::default()
2385                },
2386                v2::Decl {
2387                    name: "StreamThing".to_string(),
2388                    kind: Some(fixed(v2::FieldType {
2389                        kind: Some(v2::field_type::Kind::Stream(v2::StreamType {
2390                            element: Some(v2::stream_type::Element::Named(
2391                                "veh.strm.X".to_string(),
2392                            )),
2393                        })),
2394                        ..Default::default()
2395                    })),
2396                    ..Default::default()
2397                },
2398            ],
2399            ..Default::default()
2400        };
2401
2402        let found = v2::referenced_packages(&package);
2403        let expected: std::collections::BTreeSet<String> = [
2404            "ridl.std", "veh.arr", "veh.tup", "veh.key", "veh.val", "veh.strm",
2405        ]
2406        .into_iter()
2407        .map(str::to_string)
2408        .collect();
2409        assert_eq!(
2410            found, expected,
2411            "each recursive path must contribute its own distinct qualifier"
2412        );
2413        assert!(
2414            !found.contains("Speed") && !found.contains("veh.cluster"),
2415            "a bare reference contributes no package: {found:?}",
2416        );
2417    }
2418
2419    /// An empty package references nothing — the negative case the emit rule in
2420    /// `ridlc` depends on.
2421    #[test]
2422    fn referenced_packages_is_empty_without_references() {
2423        let package = v2::Package {
2424            name: "veh.solo".to_string(),
2425            ..Default::default()
2426        };
2427        assert!(v2::referenced_packages(&package).is_empty());
2428    }
2429
2430    /// Below prost's recursion limit at two message levels per nesting level
2431    /// — the depth ADR-0014 decision 12 measured as round-tripping correctly.
2432    /// Since decision 14 these two constants bound the prototext transcode
2433    /// alone: JSON no longer transcodes and carries its own read-side
2434    /// ceiling, tested separately below.
2435    const NESTING_BELOW_LIMIT: usize = 45;
2436    /// Past the limit today. The tests assert the outcome — an error, never a
2437    /// panic — not the exact threshold, so a prost release that moves the
2438    /// limit moves these constants, not the assertions.
2439    const NESTING_PAST_LIMIT: usize = 60;
2440
2441    /// One declaration whose payload nests `depth` levels of inline arrays —
2442    /// each level costs two message levels on the wire (`FieldType` plus
2443    /// `ArrayType`), the arithmetic ADR-0014 decision 12 records against
2444    /// prost's recursion limit.
2445    fn nested_package(depth: usize) -> v2::Package {
2446        let mut payload = v2::FieldType {
2447            optional: false,
2448            kind: Some(v2::field_type::Kind::Primitive(
2449                v2::PrimitiveType::Integer as i32,
2450            )),
2451        };
2452        for _ in 0..depth {
2453            payload = v2::FieldType {
2454                optional: false,
2455                kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
2456                    element: Some(Box::new(payload)),
2457                    min: 1,
2458                    max: 1,
2459                }))),
2460            };
2461        }
2462        v2::Package {
2463            name: "veh.deep".to_string(),
2464            decls: vec![v2::Decl {
2465                name: "deep".to_string(),
2466                kind: Some(v2::decl::Kind::FixedDef(v2::FixedDef {
2467                    payload: Some(payload),
2468                })),
2469                ..Default::default()
2470            }],
2471            ..Default::default()
2472        }
2473    }
2474
2475    /// One package whose nesting sits exactly `levels` message levels below
2476    /// the `Package` root — the unit the derived binary encoding's bound is
2477    /// stated in (the IR specification, "The derived encodings").
2478    ///
2479    /// The chain under a `FixedDef` costs three levels before any nesting
2480    /// (`Decl`, `FixedDef`, the outermost `FieldType`) and two per array level
2481    /// (`ArrayType`, `FieldType`), so an array-only chain reaches the odd
2482    /// depths alone. One tuple level costs three (`FieldType`, `TupleType`,
2483    /// `TupleField`, then the `FieldType` the next level counts), which is what
2484    /// reaches the even depths. Both shapes are what the front end lowers, so
2485    /// neither is a construction the schema would not otherwise see.
2486    fn package_at_message_depth(levels: usize) -> v2::Package {
2487        assert!(levels >= 3, "the chain costs three levels before nesting");
2488        let (arrays, tuple) = if levels % 2 == 1 {
2489            ((levels - 3) / 2, false)
2490        } else {
2491            assert!(levels >= 6, "one tuple level costs three");
2492            ((levels - 6) / 2, true)
2493        };
2494
2495        let mut payload = v2::FieldType {
2496            optional: false,
2497            kind: Some(v2::field_type::Kind::Primitive(
2498                v2::PrimitiveType::Integer as i32,
2499            )),
2500        };
2501        for _ in 0..arrays {
2502            payload = v2::FieldType {
2503                optional: false,
2504                kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
2505                    element: Some(Box::new(payload)),
2506                    min: 1,
2507                    max: 1,
2508                }))),
2509            };
2510        }
2511        if tuple {
2512            payload = v2::FieldType {
2513                optional: false,
2514                kind: Some(v2::field_type::Kind::Tuple(v2::TupleType {
2515                    fields: vec![v2::TupleField {
2516                        name: "f0".to_string(),
2517                        r#type: Some(payload),
2518                    }],
2519                })),
2520            };
2521        }
2522        v2::Package {
2523            name: "veh.deep".to_string(),
2524            decls: vec![v2::Decl {
2525                name: "deep".to_string(),
2526                kind: Some(v2::decl::Kind::FixedDef(v2::FixedDef {
2527                    payload: Some(payload),
2528                })),
2529                ..Default::default()
2530            }],
2531            ..Default::default()
2532        }
2533    }
2534
2535    /// The nesting of JSON objects in a canonical artifact, which in the
2536    /// protobuf JSON mapping is the nesting of messages: every message is an
2537    /// object, a repeated field is an array of them, and the schema declares no
2538    /// `map<>` field. The root `Package` object is included, so a caller
2539    /// counting levels *below* the root subtracts one. Brackets inside a string
2540    /// literal do not count.
2541    fn message_nesting(json: &str) -> usize {
2542        let (mut depth, mut max) = (0usize, 0usize);
2543        let (mut in_string, mut escaped) = (false, false);
2544        for byte in json.bytes() {
2545            if in_string {
2546                if escaped {
2547                    escaped = false;
2548                } else if byte == b'\\' {
2549                    escaped = true;
2550                } else if byte == b'"' {
2551                    in_string = false;
2552                }
2553                continue;
2554            }
2555            match byte {
2556                b'"' => in_string = true,
2557                b'{' => {
2558                    depth += 1;
2559                    max = max.max(depth);
2560                }
2561                b'}' => depth = depth.saturating_sub(1),
2562                _ => {}
2563            }
2564        }
2565        max
2566    }
2567
2568    /// [`package_at_message_depth`] builds what it claims, on both parities.
2569    /// Without this the two bound tests below would pin a depth nobody
2570    /// measured.
2571    #[test]
2572    fn package_at_message_depth_builds_the_depth_it_names() {
2573        with_sized_stack(|| {
2574            for levels in [3, 6, 7, 99, 100, 101] {
2575                let json = v2::to_json_pretty(&package_at_message_depth(levels))
2576                    .expect("the writer is unrestricted at these depths");
2577                assert_eq!(
2578                    message_nesting(&json) - 1,
2579                    levels,
2580                    "the chain must nest {levels} message levels below the root"
2581                );
2582            }
2583        });
2584    }
2585
2586    /// The derived binary encoding's bound, stated by the IR specification and
2587    /// pinned here: 100 message levels below the root round-trip.
2588    ///
2589    /// `to_binary` writes any depth; it is `from_binary` that stops, at prost's
2590    /// `RECURSION_LIMIT` of 100, decremented once per nested message on decode
2591    /// and not consulted on encode. The test asserts the outcome, not prost's
2592    /// constant: a prost release that moves the limit moves these two tests and
2593    /// the specification's paragraph together.
2594    #[test]
2595    fn binary_round_trip_at_100_message_levels_succeeds() {
2596        let package = package_at_message_depth(100);
2597        let bytes = v2::to_binary(&package);
2598        let decoded = v2::from_binary(&bytes).expect("100 message levels decode");
2599        assert_eq!(package, decoded);
2600    }
2601
2602    /// One level past that bound the binary reader refuses — an error, never a
2603    /// panic — while the canonical encoding carries the same package. This is
2604    /// the asymmetry the specification states as the reason binary is derived
2605    /// rather than canonical (driftsys/ridl#231).
2606    #[test]
2607    fn binary_decode_at_101_message_levels_returns_an_error() {
2608        let package = package_at_message_depth(101);
2609        let bytes = v2::to_binary(&package);
2610        let error = v2::from_binary(&bytes).expect_err("101 message levels must fail, not panic");
2611        assert!(
2612            error.to_string().contains("recursion limit reached"),
2613            "the error must name the limit, got: {error}"
2614        );
2615
2616        let json = v2::to_json_pretty(&package).expect("the canonical encoding has no such bound");
2617        assert_eq!(
2618            package,
2619            v2::from_json(&json).expect("the canonical encoding round-trips the same package")
2620        );
2621    }
2622
2623    /// The write side after ADR-0014 decision 14: the pbjson-generated
2624    /// writer recurses the typed message directly — no transcode, so no
2625    /// message-level recursion limit — and 400 levels of array nesting,
2626    /// roughly eight times the ceiling decision 12 recorded, serialize and
2627    /// round-trip. Run on an explicitly sized stack: the writer recurses on
2628    /// the caller's stack, and debug-build frames at this depth overflow the
2629    /// default test-thread stack (the reader sizes its own thread inside
2630    /// `from_json`).
2631    #[test]
2632    fn json_round_trip_at_400_nested_levels_succeeds() {
2633        with_sized_stack(|| {
2634            let package = nested_package(400);
2635            let json = v2::to_json_pretty(&package).expect("the writer has no message-level limit");
2636            let decoded = v2::from_json(&json).expect("the reader parses within its ceiling");
2637            assert_eq!(package, decoded);
2638        });
2639    }
2640
2641    /// The one error path on the JSON write side (ADR-0014 decision 14),
2642    /// and it is new with the generated impl, not a survivor of the
2643    /// transcode's: an `i32` enum field holding a discriminant outside the
2644    /// schema — data, not depth — which the retired reflection path
2645    /// serialized successfully as its bare number. The checker never
2646    /// produces one, so there is no CLI route to this failure; it is pinned
2647    /// here at the crate surface.
2648    #[test]
2649    fn json_serialization_of_an_out_of_schema_discriminant_returns_an_error() {
2650        let mut package = fixture();
2651        package.decls[0].visibility = 999;
2652        let err = v2::to_json_pretty(&package)
2653            .expect_err("an out-of-schema discriminant must fail, not panic");
2654        let message = err.to_string();
2655        assert!(
2656            message.contains("canonical protobuf JSON"),
2657            "the error must name the encoding that failed, got: {message}"
2658        );
2659        assert!(
2660            message.contains("discriminant outside the schema"),
2661            "the error must name the known cause, got: {message}"
2662        );
2663    }
2664
2665    /// The strictness ADR-0014 decision 11 relies on is the generated
2666    /// deserializer's default: `ignore_unknown_fields()` is the opt-out and
2667    /// stays unset, so a field the schema does not declare is an error,
2668    /// never silently dropped.
2669    #[test]
2670    fn json_parse_rejects_an_unknown_field() {
2671        let error = v2::from_json(r#"{"name": "veh.deep", "notAField": 1}"#)
2672            .expect_err("an unknown field must be rejected");
2673        assert!(
2674            error.to_string().contains("unknown field"),
2675            "the error must name the defect, got: {error}"
2676        );
2677    }
2678
2679    /// A reader narrowing ADR-0014 decision 14 records: the proto3 JSON
2680    /// mapping expects parsers to accept numeric enum values, and the
2681    /// generated deserializer does — within the schema's range. A
2682    /// discriminant outside it (`"visibility": 77`) is rejected, where the
2683    /// retired reflection reader accepted it — and the retired *writer*
2684    /// emitted exactly such a number for an out-of-schema discriminant.
2685    /// Pinned so the narrowing stays a decision rather than an accident: a
2686    /// future mechanism change must confront this test.
2687    #[test]
2688    fn json_parse_rejects_an_out_of_range_numeric_enum_value() {
2689        let with_visibility =
2690            |value: &str| format!(r#"{{"name": "veh.x", "decls": [{{"visibility": {value}}}]}}"#);
2691        v2::from_json(&with_visibility("1"))
2692            .expect("an in-range numeric enum value parses, as the mapping expects");
2693        let error = v2::from_json(&with_visibility("77"))
2694            .expect_err("an out-of-range numeric enum value must be rejected");
2695        assert!(
2696            error.to_string().contains("invalid value: integer `77`"),
2697            "the error must name the value, got: {error}"
2698        );
2699    }
2700
2701    /// A reader narrowing ADR-0014 decision 14 records: the mapping accepts
2702    /// float and exponent notation for integer fields (`"min": 1.0`), and
2703    /// the retired reflection reader did; the generated deserializer
2704    /// rejects both. Pinned for the same reason as the numeric-enum case
2705    /// above.
2706    #[test]
2707    fn json_parse_rejects_a_float_form_integer() {
2708        for spelling in ["1.0", "1e0"] {
2709            let error = v2::from_json(&format!(
2710                r#"{{"name": "veh.x", "decls": [{{"fixedDef": {{"payload": {{"array": {{"min": {spelling}}}}}}}}}]}}"#,
2711            ))
2712            .expect_err("a float-form integer must be rejected");
2713            assert!(
2714                error.to_string().contains("did not match any variant"),
2715                "the integer field's deserializer must be the one refusing `{spelling}`, \
2716                 got: {error}"
2717            );
2718        }
2719    }
2720
2721    /// A reader narrowing ADR-0014 decision 14 records: `null` for a
2722    /// repeated field (`"decls": null`), which the retired reflection
2723    /// reader read as empty, is rejected. `null` for an optional scalar or
2724    /// message field is still accepted — parity with the retired reader,
2725    /// asserted alongside so the narrowing's edge is pinned from both
2726    /// sides.
2727    #[test]
2728    fn json_parse_rejects_null_for_a_repeated_field() {
2729        let error = v2::from_json(r#"{"name": "veh.x", "decls": null}"#)
2730            .expect_err("null for a repeated field must be rejected");
2731        assert!(
2732            error.to_string().contains("invalid type: null"),
2733            "the error must name the null, got: {error}"
2734        );
2735        v2::from_json(r#"{"name": "veh.x", "decls": [{"deprecated": null}]}"#)
2736            .expect("null for an optional scalar field still parses");
2737    }
2738
2739    /// A reader narrowing ADR-0014 decision 14 records: a duplicate JSON
2740    /// key, which the retired reflection reader resolved last-wins, is
2741    /// rejected.
2742    #[test]
2743    fn json_parse_rejects_a_duplicate_key() {
2744        let error = v2::from_json(r#"{"name": "a", "name": "b"}"#)
2745            .expect_err("a duplicate key must be rejected");
2746        assert!(
2747            error.to_string().contains("duplicate field `name`"),
2748            "the error must name the duplicated field, got: {error}"
2749        );
2750    }
2751
2752    /// The read-side ceiling (ADR-0014 decision 14): nesting past 1,000
2753    /// bracket levels returns an error before the parse begins — a
2754    /// diagnostic, where unbounded recursion would eventually abort on a
2755    /// stack overflow no caller can catch. The input is real writer output:
2756    /// past the ceiling the asymmetry is deliberate — the writer is
2757    /// unrestricted, the reader is not.
2758    #[test]
2759    fn json_parse_past_the_nesting_ceiling_returns_an_error() {
2760        with_sized_stack(|| {
2761            let json = v2::to_json_pretty(&nested_package(500))
2762                .expect("the writer is unrestricted at this depth");
2763            let error = v2::from_json(&json).expect_err("the reader must refuse past its ceiling");
2764            assert!(
2765                error.to_string().contains("1000 JSON levels"),
2766                "the error must name the ceiling, got: {error}"
2767            );
2768        });
2769    }
2770
2771    /// The ceiling is exact: 1,000 open brackets pass the scan and reach the
2772    /// parser — which then rejects the input as not a package — and 1,001 do
2773    /// not. The scan runs before the parse, so the over-ceiling probe needs
2774    /// no valid JSON behind its brackets.
2775    #[test]
2776    fn json_nesting_ceiling_binds_exactly_at_1000() {
2777        let at = v2::from_json(&"[".repeat(1_000)).expect_err("an array is not a package");
2778        assert!(
2779            !at.to_string().contains("JSON levels"),
2780            "at the ceiling the parser, not the scan, must be the one refusing, got: {at}"
2781        );
2782
2783        let past = v2::from_json(&"[".repeat(1_001)).expect_err("past the ceiling, the scan");
2784        assert!(
2785            past.to_string().contains("1000 JSON levels"),
2786            "past the ceiling the error must name it, got: {past}"
2787        );
2788    }
2789
2790    /// The nesting scan behind the ceiling: brackets count only outside
2791    /// string literals, an escaped quote does not end a literal, an escaped
2792    /// backslash does not disarm the real closing quote after it, and a
2793    /// stray closer never underflows the running depth.
2794    #[test]
2795    fn nesting_scan_counts_brackets_outside_string_literals_only() {
2796        // Plain structural nesting counts every open bracket.
2797        assert_eq!(v2::max_json_nesting(r#"{"a": [{"b": []}]}"#), 4);
2798        // Brackets inside a string literal do not count.
2799        assert_eq!(v2::max_json_nesting(r#"{"doc": "{[[[{"}"#), 1);
2800        // An escaped quote does not end the literal, so the brackets after
2801        // it are still inside it.
2802        assert_eq!(v2::max_json_nesting(r#"{"doc": "a\"[[[", "x": []}"#), 2);
2803        // An escaped backslash does not escape the closing quote: the
2804        // literal ends, and the brackets after it count.
2805        assert_eq!(v2::max_json_nesting(r#"{"doc": "a\\", "x": [[]]}"#), 3);
2806        // A stray closer saturates at zero rather than underflowing.
2807        assert_eq!(v2::max_json_nesting("]]]{"), 1);
2808    }
2809
2810    /// The prototext form of [`nested_package`], built by hand for the same
2811    /// reason [`nested_json`] is: past the limit the serializer rejects the
2812    /// package, so its prototext cannot come from [`v2::to_text_format`].
2813    fn nested_text(depth: usize) -> String {
2814        let mut payload = "primitive: PRIMITIVE_TYPE_INTEGER".to_string();
2815        for _ in 0..depth {
2816            payload = format!("array {{ element {{ {payload} }} min: 1 max: 1 }}");
2817        }
2818        format!(
2819            r#"name: "veh.deep" decls {{ name: "deep" fixed_def {{ payload {{ {payload} }} }} }}"#
2820        )
2821    }
2822
2823    /// The prototext write path carries the same recursion-limit failure mode
2824    /// as JSON — both go through the one transcode (ADR-0014 decision 12) —
2825    /// and reports it as an error naming its own encoding, never a panic.
2826    #[test]
2827    fn text_serialization_past_the_nesting_limit_returns_an_error() {
2828        let err = v2::to_text_format(&nested_package(NESTING_PAST_LIMIT))
2829            .expect_err("serialization past the recursion limit must fail, not panic");
2830        let message = err.to_string();
2831        assert!(
2832            message.contains("recursion limit"),
2833            "the error must name the nesting limit as the known cause, got: {message}"
2834        );
2835        assert!(
2836            message.contains("prototext"),
2837            "the error must name the encoding that failed, got: {message}"
2838        );
2839    }
2840
2841    /// Runs `test` on a thread whose stack fits the recursion the test
2842    /// drives on its own thread. Two groups need one. The prototext parser
2843    /// recurses once per message level with debug-build frames large enough
2844    /// that the default 2 MiB test-thread stack overflows near 45 array
2845    /// levels — under prost's own recursion limit, so the depths
2846    /// [`NESTING_BELOW_LIMIT`] and [`NESTING_PAST_LIMIT`] pin are
2847    /// unreachable on that stack; the production paths are unaffected, since
2848    /// the toolchain writes prototext and never parses it (`ridl diff` and
2849    /// the baselines stay `.ir.json`, ADR-0014 decision 5). And the deep
2850    /// JSON tests drive the pbjson-generated writer, which recurses on the
2851    /// caller's stack (ADR-0014 decision 14 — only the reader sizes a
2852    /// thread of its own, inside `from_json`).
2853    fn with_sized_stack(test: impl FnOnce() + Send + 'static) {
2854        let outcome = std::thread::Builder::new()
2855            .stack_size(16 * 1024 * 1024)
2856            .spawn(test)
2857            .expect("spawn the large-stack test thread")
2858            .join();
2859        if let Err(payload) = outcome {
2860            std::panic::resume_unwind(payload);
2861        }
2862    }
2863
2864    /// The read direction: the text-format parser itself has no depth limit,
2865    /// so the failure is the transcode out of the dynamic message, mapped
2866    /// into the error return instead of expected on (ADR-0014 decision 12).
2867    #[test]
2868    fn text_parse_past_the_nesting_limit_returns_an_error() {
2869        with_sized_stack(|| {
2870            let error = v2::from_text_format(&nested_text(NESTING_PAST_LIMIT))
2871                .expect_err("parsing past the recursion limit must fail, not panic");
2872
2873            // Assert *which* stage failed: prost's transcoding decoder says
2874            // "recursion limit reached", and a parse-stage failure would
2875            // render through the `Parse` variant instead.
2876            let message = error.to_string();
2877            assert!(
2878                message.contains("recursion limit reached"),
2879                "the transcode out of the dynamic message must be the failing \
2880                 stage, got: {message}"
2881            );
2882        });
2883    }
2884
2885    /// The prototext bound must not tighten silently either: below the limit
2886    /// the package still serializes and round-trips.
2887    #[test]
2888    fn text_round_trip_below_the_nesting_limit_succeeds() {
2889        with_sized_stack(|| {
2890            let package = nested_package(NESTING_BELOW_LIMIT);
2891            let text = v2::to_text_format(&package)
2892                .expect("below the recursion limit, serialization succeeds");
2893            let decoded =
2894                v2::from_text_format(&text).expect("below the recursion limit, parsing succeeds");
2895            assert_eq!(package, decoded);
2896        });
2897    }
2898}
2899
2900#[cfg(test)]
2901mod vacuous_constraint {
2902    use crate::v2;
2903
2904    /// A constraint with every field absent. Each test sets only the field it
2905    /// is about, so no assertion can pass through a neighbouring field.
2906    fn constraint() -> v2::Constraint {
2907        v2::Constraint {
2908            min: None,
2909            max: None,
2910            step: None,
2911            len_min: None,
2912            len_max: None,
2913            pattern: None,
2914            pattern_const: None,
2915        }
2916    }
2917
2918    #[test]
2919    fn an_absent_or_empty_constraint_is_vacuous() {
2920        assert!(v2::constraint_is_vacuous(None));
2921        assert!(v2::constraint_is_vacuous(Some(&constraint())));
2922    }
2923
2924    #[test]
2925    fn a_step_constraint_is_non_vacuous() {
2926        let stepped = v2::Constraint {
2927            step: Some("0.5".to_string()),
2928            ..constraint()
2929        };
2930        assert!(!v2::constraint_is_vacuous(Some(&stepped)));
2931    }
2932
2933    /// Every constrained field on its own. A fixture setting a pair — `min`
2934    /// with `max`, or `len_min` with `len_max` — cannot tell a predicate that
2935    /// reads both from one that reads either, so each bound here is one-sided.
2936    /// The paired shapes are pinned separately by
2937    /// [`a_bound_pair_set_together_is_non_vacuous`], which a one-sided fixture
2938    /// cannot do.
2939    #[test]
2940    fn any_single_constrained_field_is_non_vacuous() {
2941        let cases = [
2942            (
2943                "min",
2944                v2::Constraint {
2945                    min: Some("0.0".to_string()),
2946                    ..constraint()
2947                },
2948            ),
2949            (
2950                "max",
2951                v2::Constraint {
2952                    max: Some("250.0".to_string()),
2953                    ..constraint()
2954                },
2955            ),
2956            (
2957                "len_min",
2958                v2::Constraint {
2959                    len_min: Some(1),
2960                    ..constraint()
2961                },
2962            ),
2963            (
2964                "len_max",
2965                v2::Constraint {
2966                    len_max: Some(256),
2967                    ..constraint()
2968                },
2969            ),
2970            (
2971                "pattern",
2972                v2::Constraint {
2973                    pattern: Some("^[a-z]+$".to_string()),
2974                    ..constraint()
2975                },
2976            ),
2977            (
2978                "pattern_const",
2979                v2::Constraint {
2980                    pattern_const: Some("NAME_PATTERN".to_string()),
2981                    ..constraint()
2982                },
2983            ),
2984        ];
2985        for (field, case) in cases {
2986            assert!(
2987                !v2::constraint_is_vacuous(Some(&case)),
2988                "`{field}` alone must be non-vacuous"
2989            );
2990        }
2991    }
2992
2993    /// The two shapes the checker actually emits: a declared range, and the
2994    /// typl §4.4 default `[0..256]` every string and bytes type carries.
2995    ///
2996    /// A one-sided fixture cannot pin these. A predicate reading each bound as
2997    /// a pair — `(c.min.is_none() == c.max.is_none())` and the same for the
2998    /// length bounds — passes every one-sided case and still reports both
2999    /// shapes below as vacuous, which would drop the range check from every
3000    /// bounded number and every string.
3001    #[test]
3002    fn a_bound_pair_set_together_is_non_vacuous() {
3003        let ranged = v2::Constraint {
3004            min: Some("0.0".to_string()),
3005            max: Some("250.0".to_string()),
3006            ..constraint()
3007        };
3008        assert!(!v2::constraint_is_vacuous(Some(&ranged)));
3009
3010        let default_length = v2::Constraint {
3011            len_min: Some(0),
3012            len_max: Some(256),
3013            ..constraint()
3014        };
3015        assert!(!v2::constraint_is_vacuous(Some(&default_length)));
3016    }
3017}
3018
3019#[cfg(test)]
3020mod system_round_trip {
3021    use crate::v2;
3022
3023    fn attribute(namespace: &str, key: &str, value: Option<v2::AttributeValue>) -> v2::Attribute {
3024        v2::Attribute {
3025            namespace: namespace.to_string(),
3026            key: key.to_string(),
3027            value,
3028        }
3029    }
3030
3031    fn scalar(text: &str) -> v2::AttributeValue {
3032        v2::AttributeValue {
3033            kind: Some(v2::attribute_value::Kind::Scalar(text.to_string())),
3034        }
3035    }
3036
3037    fn list(items: Vec<v2::AttributeValue>) -> v2::AttributeValue {
3038        v2::AttributeValue {
3039            kind: Some(v2::attribute_value::Kind::List(v2::AttributeList { items })),
3040        }
3041    }
3042
3043    fn interface(catalog: &str, name: &str, inline: bool) -> Option<v2::InterfaceRef> {
3044        Some(v2::InterfaceRef {
3045            catalog: catalog.to_string(),
3046            name: name.to_string(),
3047            inline,
3048        })
3049    }
3050
3051    fn endpoint(component: &str, instance: &str, machine: &str) -> v2::Endpoint {
3052        v2::Endpoint {
3053            component: component.to_string(),
3054            instance: instance.to_string(),
3055            machine: machine.to_string(),
3056        }
3057    }
3058
3059    /// A reduced rsdl reference Appendix A: `Cruise` with two instances
3060    /// offering `veh.adas.cruise` and requiring `LaneAssist`, the implicit
3061    /// component of `veh.diag.access`, one distribution and one deployment.
3062    /// Every message of `system.proto` appears at least once, with every
3063    /// scalar set to a value other than its default — a flag and a nested-list
3064    /// attribute value included — so a round trip that drops a field is
3065    /// caught.
3066    fn doc_link() -> v2::DocLink {
3067        v2::DocLink {
3068            text: "Cruise".to_string(),
3069            offset: 16,
3070            len: 6,
3071            target: "veh.topology.Cruise".to_string(),
3072        }
3073    }
3074
3075    fn fixture() -> v2::System {
3076        let link = v2::Link {
3077            interface: interface("veh.diag", "veh.diag.access", true),
3078            service: "veh.diag.access".to_string(),
3079            consumer: Some(endpoint("veh.topology.Backend", "Unit", "Cloud")),
3080            producer: Some(endpoint("veh.diag.access", "Unit", "AdasHpc")),
3081            crossing: v2::Crossing::OffBoard as i32,
3082        };
3083        v2::System {
3084            name: "Vehicle".to_string(),
3085            package: "veh.topology".to_string(),
3086            labels: vec!["ASIL_B".to_string()],
3087            attributes: vec![attribute("rust", "crate", Some(scalar("\"vehicle\"")))],
3088            members: vec![
3089                v2::MemberLine {
3090                    component: "veh.topology.Cruise".to_string(),
3091                    attributes: vec![attribute("linux", "pinned", None)],
3092                    doc: "Documented, see [Cruise].".to_string(),
3093                    links: vec![doc_link()],
3094                    see: vec![doc_link()],
3095                    since: vec!["1.2".to_string()],
3096                },
3097                v2::MemberLine {
3098                    component: "veh.diag.access".to_string(),
3099                    attributes: vec![],
3100                    doc: "Documented, see [Cruise].".to_string(),
3101                    links: vec![doc_link()],
3102                    see: vec![doc_link()],
3103                    since: vec!["1.2".to_string()],
3104                },
3105            ],
3106            components: vec![
3107                v2::Component {
3108                    name: "Cruise".to_string(),
3109                    package: "veh.topology".to_string(),
3110                    implicit: false,
3111                    external: true,
3112                    instances: vec!["primary".to_string(), "backup".to_string()],
3113                    numbers: vec![
3114                        v2::InstanceNumber {
3115                            instance: "primary".to_string(),
3116                            number: 1,
3117                            provisional: false,
3118                        },
3119                        v2::InstanceNumber {
3120                            instance: "backup".to_string(),
3121                            number: 2,
3122                            provisional: true,
3123                        },
3124                    ],
3125                    offers: vec![v2::Offer {
3126                        service: "veh.adas.cruise".to_string(),
3127                        attributes: vec![attribute("someip", "serviceId", Some(scalar("4097")))],
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                    requires: vec![v2::Require {
3134                        interface: interface("veh.adas", "LaneAssist", false),
3135                        service: "veh.adas.lane".to_string(),
3136                        producer: "veh.topology.Lane".to_string(),
3137                        attributes: vec![attribute(
3138                            "linux",
3139                            "cpuset",
3140                            Some(list(vec![scalar("2"), list(vec![scalar("3")])])),
3141                        )],
3142                        doc: "Documented, see [Cruise].".to_string(),
3143                        links: vec![doc_link()],
3144                        see: vec![doc_link()],
3145                        since: vec!["1.2".to_string()],
3146                    }],
3147                    labels: vec!["ASIL_B".to_string()],
3148                    attributes: vec![attribute("rust", "crate", None)],
3149                    doc: "Documented, see [Cruise].".to_string(),
3150                    links: vec![doc_link()],
3151                    see: vec![doc_link()],
3152                    since: vec!["1.2".to_string()],
3153                    deprecated: Some("superseded".to_string()),
3154                },
3155                v2::Component {
3156                    name: "veh.diag.access".to_string(),
3157                    package: String::new(),
3158                    implicit: true,
3159                    external: false,
3160                    instances: vec!["Unit".to_string()],
3161                    numbers: vec![v2::InstanceNumber {
3162                        instance: "Unit".to_string(),
3163                        number: 3,
3164                        provisional: false,
3165                    }],
3166                    offers: vec![v2::Offer {
3167                        service: "veh.diag.access".to_string(),
3168                        attributes: vec![],
3169                        doc: "Documented, see [Cruise].".to_string(),
3170                        links: vec![doc_link()],
3171                        see: vec![doc_link()],
3172                        since: vec!["1.2".to_string()],
3173                    }],
3174                    requires: vec![],
3175                    labels: vec![],
3176                    attributes: vec![],
3177                    doc: "Documented, see [Cruise].".to_string(),
3178                    links: vec![doc_link()],
3179                    see: vec![doc_link()],
3180                    since: vec!["1.2".to_string()],
3181                    deprecated: Some("superseded".to_string()),
3182                },
3183            ],
3184            producers: vec![v2::Producer {
3185                service: "veh.adas.cruise".to_string(),
3186                component: "veh.topology.Cruise".to_string(),
3187                instances: vec!["primary".to_string(), "backup".to_string()],
3188                not_yet_realizable: true,
3189            }],
3190            grants: vec![v2::Grant {
3191                component: "veh.topology.Backend".to_string(),
3192                external: true,
3193                regions: vec!["veh.adas".to_string(), "veh.diag".to_string()],
3194            }],
3195            regions: vec![v2::Region {
3196                catalog: "veh.diag".to_string(),
3197                hash: vec![0xab; 32],
3198                interfaces: vec![v2::RegionInterface {
3199                    name: "veh.diag.access".to_string(),
3200                    inline: true,
3201                    number: 2,
3202                    provisional: true,
3203                    service: "veh.diag.access".to_string(),
3204                }],
3205            }],
3206            distributions: vec![v2::Distribution {
3207                name: "Adas".to_string(),
3208                package: "veh.topology".to_string(),
3209                members: vec![v2::MemberLine {
3210                    component: "veh.topology.Cruise".to_string(),
3211                    attributes: vec![],
3212                    doc: "Documented, see [Cruise].".to_string(),
3213                    links: vec![doc_link()],
3214                    see: vec![doc_link()],
3215                    since: vec!["1.2".to_string()],
3216                }],
3217                depends_on: vec!["veh.topology.Base".to_string()],
3218                labels: vec!["PLATFORM_BUNDLE".to_string()],
3219                attributes: vec![attribute("deb", "section", Some(scalar("net")))],
3220                doc: "Documented, see [Cruise].".to_string(),
3221                links: vec![doc_link()],
3222                see: vec![doc_link()],
3223                since: vec!["1.2".to_string()],
3224                tier: v2::Tier::Platform as i32,
3225                deprecated: Some("superseded".to_string()),
3226            }],
3227            deployments: vec![v2::Deployment {
3228                name: "Production".to_string(),
3229                package: "veh.topology".to_string(),
3230                labels: vec!["FLEET".to_string()],
3231                attributes: vec![attribute("ota", "channel", Some(scalar("stable")))],
3232                machines: vec![v2::Machine {
3233                    name: "Cloud".to_string(),
3234                    external: true,
3235                    labels: vec!["OFF_BOARD".to_string()],
3236                    attributes: vec![attribute("net", "zone", Some(scalar("wan")))],
3237                    doc: "Documented, see [Cruise].".to_string(),
3238                    links: vec![doc_link()],
3239                    see: vec![doc_link()],
3240                    since: vec!["1.2".to_string()],
3241                    deprecated: Some("superseded".to_string()),
3242                }],
3243                placements: vec![v2::Placement {
3244                    component: "veh.topology.Cruise".to_string(),
3245                    instance: "backup".to_string(),
3246                    machine: "Cockpit".to_string(),
3247                    attributes: vec![attribute("linux", "cpuset", Some(list(vec![])))],
3248                    sizing: Some(v2::Sizing {
3249                        depth: Some(4_294_967_295),
3250                        slots: Some(65_536),
3251                        budget: Some(18_446_744_073_709_551_615),
3252                    }),
3253                }],
3254                links: vec![link.clone()],
3255                routes: vec![v2::Route {
3256                    catalog: "veh.adas".to_string(),
3257                    interface_number: 2,
3258                    member_ordinal: 1,
3259                    interface: "LaneAssist".to_string(),
3260                    member: "active".to_string(),
3261                    service: "veh.adas.lane".to_string(),
3262                    producers: vec![endpoint("veh.topology.Lane", "Unit", "AdasHpc")],
3263                }],
3264                surface: vec![v2::Surface {
3265                    link: Some(link),
3266                    direction: v2::SurfaceDirection::ExternalConsumes as i32,
3267                }],
3268                installations: vec![v2::Installation {
3269                    distribution: "veh.topology.Adas".to_string(),
3270                    machines: vec!["AdasHpc".to_string(), "Cockpit".to_string()],
3271                }],
3272                doc: "Documented, see [Cruise].".to_string(),
3273                doc_links: vec![doc_link()],
3274                see: vec![doc_link()],
3275                since: vec!["1.2".to_string()],
3276                sizing: Some(v2::Sizing {
3277                    depth: Some(3),
3278                    slots: Some(8),
3279                    budget: Some(4096),
3280                }),
3281                deprecated: Some("superseded".to_string()),
3282            }],
3283            doc: "Documented, see [Cruise].".to_string(),
3284            see: vec![doc_link()],
3285            since: vec!["1.2".to_string()],
3286            links: vec![doc_link()],
3287            deprecated: Some("superseded".to_string()),
3288        }
3289    }
3290
3291    #[test]
3292    fn system_binary_round_trip_preserves_system() {
3293        let system = fixture();
3294        let decoded = v2::system_from_binary(v2::system_to_binary(&system).as_slice())
3295            .expect("decode must succeed");
3296        assert_eq!(system, decoded);
3297    }
3298
3299    /// The canonical JSON of the system artifact re-reads through the same
3300    /// strict pbjson-generated impl the package uses (ADR-0014 decisions 11
3301    /// and 14): unknown fields rejected, the catalog hash as base64, enums by
3302    /// name, the nested attribute list intact.
3303    #[test]
3304    fn system_json_round_trip_preserves_system() {
3305        let system = fixture();
3306        let json = v2::system_to_json_pretty(&system).expect("the fixture serializes as JSON");
3307        assert!(
3308            json.contains("\"hash\": \"q6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6s=\""),
3309            "bytes render as base64, got:\n{json}"
3310        );
3311        assert!(
3312            json.contains("\"crossing\": \"CROSSING_OFF_BOARD\""),
3313            "enums render by name, got:\n{json}"
3314        );
3315        assert!(
3316            json.contains("\"notYetRealizable\": true"),
3317            "fields render in lowerCamelCase, got:\n{json}"
3318        );
3319        assert!(
3320            json.contains("\"budget\": \"18446744073709551615\""),
3321            "a 64-bit value renders as a string, got:\n{json}"
3322        );
3323        assert_eq!(
3324            system,
3325            v2::system_from_json(&json).expect("the JSON parses")
3326        );
3327        assert!(
3328            v2::system_from_json(&json.replacen("\"name\"", "\"nam\"", 1)).is_err(),
3329            "an unknown field is rejected"
3330        );
3331    }
3332
3333    /// An absent `sizing` and an empty one are different on the wire: a plugin
3334    /// reads "nothing declared" and "declared nothing" apart, in the binary and
3335    /// the JSON encodings.
3336    #[test]
3337    fn an_absent_sizing_and_an_empty_one_stay_distinct_through_the_encodings() {
3338        let mut absent = fixture();
3339        absent.deployments[0].sizing = None;
3340        absent.deployments[0].placements[0].sizing = None;
3341        let mut empty = absent.clone();
3342        empty.deployments[0].sizing = Some(v2::Sizing::default());
3343        empty.deployments[0].placements[0].sizing = Some(v2::Sizing::default());
3344        for system in [&absent, &empty] {
3345            let binary = v2::system_from_binary(v2::system_to_binary(system).as_slice())
3346                .expect("decode must succeed");
3347            assert_eq!(system, &binary);
3348            let json = v2::system_to_json_pretty(system).expect("serializes as JSON");
3349            assert_eq!(
3350                system,
3351                &v2::system_from_json(&json).expect("the JSON parses")
3352            );
3353        }
3354        assert_ne!(
3355            v2::system_to_binary(&absent),
3356            v2::system_to_binary(&empty),
3357            "the binary encodings differ"
3358        );
3359    }
3360
3361    #[test]
3362    fn system_text_format_round_trip_preserves_system() {
3363        let system = fixture();
3364        let text = v2::system_to_text_format(&system).expect("the fixture serializes as prototext");
3365        assert!(
3366            text.starts_with("name:"),
3367            "fields print in schema index order, got: {text}"
3368        );
3369        assert_eq!(
3370            system,
3371            v2::system_from_text_format(&text).expect("prototext parsing must succeed")
3372        );
3373    }
3374
3375    /// `pkg.Name` for a declared name; the implicit component of a lone
3376    /// service, which no package declares, is its own qualified name.
3377    #[test]
3378    fn qualified_names_follow_the_one_derivation() {
3379        let system = fixture();
3380        assert_eq!(system.qualified_name(), "veh.topology.Vehicle");
3381        assert_eq!(system.components[0].qualified_name(), "veh.topology.Cruise");
3382        assert_eq!(system.components[1].qualified_name(), "veh.diag.access");
3383        assert_eq!(
3384            system.distributions[0].qualified_name(),
3385            "veh.topology.Adas"
3386        );
3387    }
3388}
3389
3390#[cfg(test)]
3391mod load_ir_json {
3392    use crate::v2::{self, LoadError, MAX_JSON_NESTING, load_ir_json};
3393
3394    /// A snapshot written by `to_json_pretty` loads back as the same package.
3395    #[test]
3396    fn a_snapshot_file_loads() {
3397        let dir = tempfile::tempdir().unwrap();
3398        let package = v2::Package {
3399            name: "veh.cabin".to_string(),
3400            ..v2::Package::default()
3401        };
3402        let path = dir.path().join("cabin.ir.json");
3403        std::fs::write(&path, v2::to_json_pretty(&package).unwrap()).unwrap();
3404        assert_eq!(load_ir_json(&path).unwrap(), package);
3405    }
3406
3407    /// A missing file is an `Io` error and a file that is not IR v2 JSON is a
3408    /// `Parse` error, each with the message `ridl diff` prints, inner error
3409    /// included.
3410    #[test]
3411    fn a_missing_file_is_io_and_bad_json_is_parse() {
3412        let dir = tempfile::tempdir().unwrap();
3413
3414        let missing = dir.path().join("missing.ir.json");
3415        let io = std::fs::read_to_string(&missing).unwrap_err();
3416        let error = load_ir_json(&missing).unwrap_err();
3417        assert!(matches!(error, LoadError::Io(_)), "{error:?}");
3418        assert_eq!(
3419            error.to_string(),
3420            format!("cannot read the IR snapshot: {io}")
3421        );
3422
3423        // The leading newline puts the error on line 2, so a loader that
3424        // trimmed the text first would report a different position.
3425        let bad = dir.path().join("bad.ir.json");
3426        std::fs::write(&bad, "\n{").unwrap();
3427        let parse = v2::from_json("\n{").unwrap_err();
3428        let error = load_ir_json(&bad).unwrap_err();
3429        assert!(matches!(error, LoadError::Parse(_)), "{error:?}");
3430        assert_eq!(
3431            error.to_string(),
3432            format!("the IR snapshot is not valid IR v2 JSON: {parse}")
3433        );
3434    }
3435
3436    /// The loader reads through `from_json`: input nested past its cap draws
3437    /// the cap's own message, which a plain `serde_json` parse would not.
3438    #[test]
3439    fn input_past_the_nesting_cap_draws_from_json_s_refusal() {
3440        let dir = tempfile::tempdir().unwrap();
3441        let deep = format!(
3442            "{}{}",
3443            "[".repeat(MAX_JSON_NESTING + 1),
3444            "]".repeat(MAX_JSON_NESTING + 1)
3445        );
3446        let path = dir.path().join("deep.ir.json");
3447        std::fs::write(&path, &deep).unwrap();
3448        let refusal = v2::from_json(&deep).unwrap_err().to_string();
3449        assert!(
3450            refusal.contains("the ceiling this reader enforces"),
3451            "{refusal}"
3452        );
3453        let error = load_ir_json(&path).unwrap_err();
3454        assert!(matches!(error, LoadError::Parse(_)), "{error:?}");
3455        assert_eq!(
3456            error.to_string(),
3457            format!("the IR snapshot is not valid IR v2 JSON: {refusal}")
3458        );
3459    }
3460}