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