Skip to main content

ridl_ir/
lib.rs

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