Skip to main content

ridl_ir/
lib.rs

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