Skip to main content

ag_ui_a2ui/
validate.rs

1//! Semantic validation of a component tree.
2//!
3//! JSON Schema can say that `children` is an array of strings. It cannot say
4//! that every one of those strings names a component that exists, that the tree
5//! has a root, or that `a → b → a` is a loop the renderer will never finish
6//! drawing. That is what this module checks.
7//!
8//! Every failure is a [`ValidationError`] carrying a machine-readable
9//! [`ErrorCode`], a `path` locator into the components list, and a sentence
10//! written to be fed straight back to a model on retry. The validator collects
11//! *all* errors rather than stopping at the first, so one retry can fix
12//! everything at once.
13//!
14//! # Depth
15//!
16//! The component graph is walked iteratively, with an explicit worklist, in
17//! every case — cycle detection, reachability, and scope assignment. That is not
18//! a style preference: the graph is model-generated and its depth is bounded by
19//! nothing, so a recursive walk would abort the process rather than fail a
20//! request. [`MAX_DEPTH`] is therefore a *policy* about what a renderer will
21//! draw, not what keeps this crate standing, and it can be raised safely.
22//!
23//! # Full surfaces and incremental updates
24//!
25//! A payload that creates a surface is held to the full contract: a `root` must
26//! exist and every child reference must resolve within the payload. An
27//! incremental `updateComponents` is not — its components may legitimately
28//! reference ids the renderer already holds, and it need not include the root.
29//! [`ValidateOptions::incremental_update`] relaxes exactly those two rules;
30//! duplicate ids and cycles still fail, because those are broken either way.
31//!
32//! ```
33//! use ag_ui_a2ui::{catalog::Catalog, message::Component, validate::{ErrorCode, Validator}};
34//! use serde_json::json;
35//!
36//! let catalog = Catalog::basic();
37//! let report = Validator::new(&catalog).validate(&[
38//!     Component::new("root", "Card").with("child", json!("nope")),
39//! ]);
40//! assert_eq!(report.errors[0].code, ErrorCode::UnresolvedChild);
41//! assert_eq!(report.errors[0].path, "components[0].child");
42//! ```
43
44use std::collections::{BTreeMap, BTreeSet};
45use std::fmt;
46
47use serde::{Deserialize, Serialize};
48use serde_json::{Map, Value};
49
50use crate::binding::{Scope, collect_bindings};
51use crate::catalog::{Catalog, ComponentDef, PropType};
52use crate::constants::{PROTOCOL_VERSION, ROOT_ID};
53use crate::error::{Error, Result, ValidationErrors};
54use crate::message::{AgentMessage, Component};
55
56/// Deepest nesting accepted by default, for both the component graph and the
57/// raw JSON of a message.
58///
59/// Matches the limit every other A2UI toolkit enforces, so a payload one of them
60/// accepts is accepted here and vice versa. Nothing in this crate needs the cap
61/// to stay safe — every walk is iterative — but a renderer that recurses does,
62/// and the input is model-generated.
63pub const MAX_DEPTH: usize = 50;
64
65/// Deepest chain of nested function calls accepted by default.
66pub const MAX_FUNCTION_CALL_DEPTH: usize = 5;
67
68/// The complete set of semantic failures this validator reports.
69///
70/// Deliberately closed: these codes are the contract with callers that route on
71/// them (a recovery loop, a renderer's error channel), so adding one is a
72/// breaking change. Composition-constraint failures have their own codes and
73/// live in [`crate::catalog::CompositionCode`].
74#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76#[non_exhaustive]
77pub enum ErrorCode {
78    /// The payload declares a surface but carries no components.
79    EmptyComponents,
80    /// A component has no usable `id`.
81    MissingId,
82    /// A component has no usable `component` type name.
83    MissingComponentType,
84    /// Two components share an `id`.
85    DuplicateId,
86    /// No component has the root id, so the renderer has nothing to draw from.
87    NoRoot,
88    /// A component's type is not defined by the surface's catalog.
89    UnknownComponent,
90    /// A property the catalog marks required is missing.
91    MissingRequiredProp,
92    /// A field the protocol requires on a message envelope is missing.
93    ///
94    /// Distinct from [`ErrorCode::MissingRequiredProp`], which is about a
95    /// component property a *catalog* declares: this one is fixed by the wire
96    /// format and holds whatever catalog is in play.
97    MissingField,
98    /// A value has the right shape but is not one the protocol permits, such as
99    /// a `version` naming a protocol revision this crate does not speak.
100    InvalidValue,
101    /// A value is of the wrong JSON type — `"3"` where a number is required, or
102    /// a number where the catalog declares a string.
103    TypeMismatch,
104    /// A child reference names a component id that does not exist.
105    UnresolvedChild,
106    /// Following child references leads back to where it started.
107    ChildCycle,
108    /// A data binding cannot resolve against the surface's data model.
109    UnresolvedBinding,
110    /// Nesting runs deeper than the configured maximum.
111    ///
112    /// Distinct from [`ErrorCode::ChildCycle`]: a deep tree is finite and
113    /// acyclic, it is just deeper than anything a renderer will draw, and deep
114    /// enough to threaten a recursive consumer. Covers three kinds of nesting —
115    /// the component graph, the raw JSON, and chained function calls — because
116    /// all three are model-generated and all three are unbounded without a cap.
117    MaxDepthExceeded,
118}
119
120impl ErrorCode {
121    /// The wire string for this code.
122    pub fn as_str(self) -> &'static str {
123        match self {
124            ErrorCode::EmptyComponents => "empty_components",
125            ErrorCode::MissingId => "missing_id",
126            ErrorCode::MissingComponentType => "missing_component_type",
127            ErrorCode::DuplicateId => "duplicate_id",
128            ErrorCode::NoRoot => "no_root",
129            ErrorCode::UnknownComponent => "unknown_component",
130            ErrorCode::MissingRequiredProp => "missing_required_prop",
131            // These three are spelled the way the conformance suite spells
132            // them, so a caller routing on codes sees the same strings from
133            // every A2UI toolkit.
134            ErrorCode::MissingField => "missing_field",
135            ErrorCode::InvalidValue => "invalid_value",
136            ErrorCode::TypeMismatch => "type_mismatch",
137            ErrorCode::UnresolvedChild => "unresolved_child",
138            ErrorCode::ChildCycle => "child_cycle",
139            ErrorCode::UnresolvedBinding => "unresolved_binding",
140            ErrorCode::MaxDepthExceeded => "max_depth_exceeded",
141        }
142    }
143}
144
145impl fmt::Display for ErrorCode {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        f.write_str(self.as_str())
148    }
149}
150
151/// One semantic failure, located and explained.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct ValidationError {
154    /// What kind of failure this is.
155    pub code: ErrorCode,
156    /// Where it is, e.g. `components[2].component` or `components[0].children[1]`.
157    pub path: String,
158    /// A sentence a human or a model can act on.
159    pub message: String,
160}
161
162impl ValidationError {
163    /// Builds an error.
164    pub fn new(code: ErrorCode, path: impl Into<String>, message: impl Into<String>) -> Self {
165        Self {
166            code,
167            path: path.into(),
168            message: message.into(),
169        }
170    }
171}
172
173impl fmt::Display for ValidationError {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        write!(f, "[{}] {}: {}", self.code, self.path, self.message)
176    }
177}
178
179/// What the validator should demand of a payload.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct ValidateOptions {
182    /// Id the tree root must have. Defaults to [`ROOT_ID`].
183    pub root_id: String,
184    /// Whether a component with the root id must be present.
185    pub require_root: bool,
186    /// Whether child references may point outside this payload.
187    pub allow_dangling_children: bool,
188    /// Whether component types must exist in the catalog.
189    ///
190    /// Turned off automatically when the catalog defines no components at all,
191    /// since that means the caller has not supplied one.
192    pub check_component_types: bool,
193    /// Whether required properties are enforced.
194    pub check_required_props: bool,
195    /// Whether property values must match the JSON type the catalog declares.
196    ///
197    /// Only properties the catalog pins to one type are checked, and a value the
198    /// renderer resolves — a `{"path": …}` binding or a function call — is never
199    /// checked, because its type on the wire says nothing about the type it
200    /// will have. See [`PropType`].
201    pub check_prop_types: bool,
202    /// Whether message envelopes must satisfy the v0.9 wire contract.
203    ///
204    /// Applies only to the raw-message entry points
205    /// ([`Validator::validate_json_messages`] and
206    /// [`Validator::validate_messages`]); the component entry points are handed
207    /// components directly and have no envelope to check.
208    pub check_envelope: bool,
209    /// Whether data bindings are resolved against the data model, and whether
210    /// relative paths are required to sit inside a list template.
211    pub check_bindings: bool,
212    /// Whether absolute binding paths must be syntactically valid JSON Pointers.
213    ///
214    /// Separate from [`ValidateOptions::check_bindings`] because it needs no
215    /// data model and cannot produce a false positive: a malformed escape can
216    /// never resolve, whatever the data turns out to be.
217    pub check_binding_syntax: bool,
218    /// Deepest nesting accepted, for both the component graph and the raw JSON.
219    ///
220    /// Defaults to [`MAX_DEPTH`]. Raising it is safe here — every walk in this
221    /// crate is iterative — but a renderer on the other end may not be, and a
222    /// tree this deep is a generation failure rather than a design.
223    pub max_depth: usize,
224    /// Deepest chain of function calls accepted, counting nesting through `args`.
225    ///
226    /// Defaults to [`MAX_FUNCTION_CALL_DEPTH`].
227    pub max_function_call_depth: usize,
228}
229
230impl Default for ValidateOptions {
231    fn default() -> Self {
232        Self::full_surface()
233    }
234}
235
236impl ValidateOptions {
237    /// The full contract, for a payload that creates a surface.
238    pub fn full_surface() -> Self {
239        Self {
240            root_id: ROOT_ID.to_string(),
241            require_root: true,
242            allow_dangling_children: false,
243            check_component_types: true,
244            check_required_props: true,
245            check_prop_types: true,
246            check_envelope: true,
247            check_bindings: true,
248            check_binding_syntax: true,
249            max_depth: MAX_DEPTH,
250            max_function_call_depth: MAX_FUNCTION_CALL_DEPTH,
251        }
252    }
253
254    /// The relaxed contract, for a payload that updates an existing surface.
255    ///
256    /// The root and the referenced components may already live on the renderer,
257    /// so their absence from this payload is not an error.
258    pub fn incremental_update() -> Self {
259        Self {
260            require_root: false,
261            allow_dangling_children: true,
262            ..Self::full_surface()
263        }
264    }
265
266    /// Overrides the root component id.
267    #[must_use]
268    pub fn with_root_id(mut self, root_id: impl Into<String>) -> Self {
269        self.root_id = root_id.into();
270        self
271    }
272
273    /// Overrides the maximum nesting depth.
274    #[must_use]
275    pub fn with_max_depth(mut self, max_depth: usize) -> Self {
276        self.max_depth = max_depth;
277        self
278    }
279}
280
281/// What a validation run found.
282#[derive(Debug, Clone, Default, PartialEq, Eq)]
283pub struct ValidationReport {
284    /// Failures, in discovery order.
285    pub errors: Vec<ValidationError>,
286    /// Ids that exist but cannot be reached from the root.
287    ///
288    /// Not an error: the specification tells renderers to buffer components
289    /// until their parent shows up, so an unreachable component is usually a
290    /// half-streamed tree rather than a broken one. It is still worth telling a
291    /// generating model about, so it is reported separately.
292    pub unreachable: Vec<String>,
293}
294
295impl ValidationReport {
296    /// Whether the payload is free of errors.
297    pub fn is_valid(&self) -> bool {
298        self.errors.is_empty()
299    }
300
301    /// Turns the report into a `Result`, discarding warnings.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`Error::Validation`] when any error was reported.
306    pub fn into_result(self) -> Result<()> {
307        if self.errors.is_empty() {
308            Ok(())
309        } else {
310            Err(Error::Validation {
311                errors: ValidationErrors(self.errors),
312            })
313        }
314    }
315
316    /// The errors as a [`ValidationErrors`] list, for prompting or reporting.
317    pub fn errors(&self) -> ValidationErrors {
318        ValidationErrors(self.errors.clone())
319    }
320}
321
322/// Validates component trees against a catalog.
323#[derive(Clone, Debug)]
324pub struct Validator<'a> {
325    catalog: &'a Catalog,
326    options: ValidateOptions,
327}
328
329/// A component normalized for validation, from either typed or raw JSON input.
330struct Node<'a> {
331    index: usize,
332    id: Option<&'a str>,
333    kind: Option<&'a str>,
334    props: Option<&'a Map<String, Value>>,
335    /// Set when the caller handed us typed components.
336    borrowed: Option<&'a Component>,
337    /// Set when we rebuilt a typed component from raw JSON.
338    owned: Option<Component>,
339}
340
341impl<'a> Node<'a> {
342    fn from_component(index: usize, component: &'a Component) -> Self {
343        Self {
344            index,
345            id: (!component.id.is_empty()).then_some(component.id.as_str()),
346            kind: (!component.component.is_empty()).then_some(component.component.as_str()),
347            props: Some(&component.props),
348            borrowed: Some(component),
349            owned: None,
350        }
351    }
352
353    fn from_json(index: usize, value: &'a Value) -> Self {
354        let object = value.as_object();
355        let id = object
356            .and_then(|o| o.get("id"))
357            .and_then(Value::as_str)
358            .filter(|s| !s.is_empty());
359        let kind = object
360            .and_then(|o| o.get("component"))
361            .and_then(Value::as_str)
362            .filter(|s| !s.is_empty());
363        // Rebuild a typed component so catalog reference extraction works on
364        // raw LLM output too.
365        let owned = match (id, kind) {
366            (Some(id), Some(kind)) => {
367                let mut props = object.cloned().unwrap_or_default();
368                props.remove("id");
369                props.remove("component");
370                Some(Component {
371                    id: id.to_string(),
372                    component: kind.to_string(),
373                    props,
374                })
375            }
376            _ => None,
377        };
378        Self {
379            index,
380            id,
381            kind,
382            props: object,
383            borrowed: None,
384            owned,
385        }
386    }
387
388    fn component(&self) -> Option<&Component> {
389        self.borrowed.or(self.owned.as_ref())
390    }
391
392    fn locator(&self, suffix: &str) -> String {
393        if suffix.is_empty() {
394            format!("components[{}]", self.index)
395        } else {
396            format!("components[{}].{suffix}", self.index)
397        }
398    }
399}
400
401impl<'a> Validator<'a> {
402    /// A validator holding a payload to the full-surface contract.
403    pub fn new(catalog: &'a Catalog) -> Self {
404        Self {
405            catalog,
406            options: ValidateOptions::full_surface(),
407        }
408    }
409
410    /// A validator for an incremental `updateComponents` payload.
411    pub fn incremental(catalog: &'a Catalog) -> Self {
412        Self {
413            catalog,
414            options: ValidateOptions::incremental_update(),
415        }
416    }
417
418    /// A validator with explicit options.
419    pub fn with_options(catalog: &'a Catalog, options: ValidateOptions) -> Self {
420        Self { catalog, options }
421    }
422
423    /// Validates typed components, with no data model to bind against.
424    pub fn validate(&self, components: &[Component]) -> ValidationReport {
425        self.validate_surface(components, None)
426    }
427
428    /// Validates typed components against a surface data model.
429    pub fn validate_surface(
430        &self,
431        components: &[Component],
432        data_model: Option<&Value>,
433    ) -> ValidationReport {
434        let nodes: Vec<Node<'_>> = components
435            .iter()
436            .enumerate()
437            .map(|(i, c)| Node::from_component(i, c))
438            .collect();
439        self.run(&nodes, components, data_model)
440    }
441
442    /// Validates raw JSON components, as they arrive from a model.
443    ///
444    /// Unlike the typed entry points this can report [`ErrorCode::MissingId`]
445    /// and [`ErrorCode::MissingComponentType`], because raw objects are free to
446    /// omit them.
447    pub fn validate_json(
448        &self,
449        components: &[Value],
450        data_model: Option<&Value>,
451    ) -> ValidationReport {
452        let nodes: Vec<Node<'_>> = components
453            .iter()
454            .enumerate()
455            .map(|(i, v)| Node::from_json(i, v))
456            .collect();
457        let typed: Vec<Component> = nodes
458            .iter()
459            .filter_map(|n| n.component().cloned())
460            .collect();
461        self.run(&nodes, &typed, data_model)
462    }
463
464    /// Validates a whole operation stream.
465    ///
466    /// Components from every `createSurface` and `updateComponents` are folded
467    /// together, `updateDataModel` operations are replayed to reconstruct the
468    /// data model, and the contract is chosen automatically: a stream with no
469    /// `createSurface` is treated as an incremental update.
470    pub fn validate_messages(&self, messages: &[AgentMessage]) -> ValidationReport {
471        let raw: Vec<Value> = messages
472            .iter()
473            .filter_map(|message| serde_json::to_value(message).ok())
474            .collect();
475        self.validate_json_messages(&raw)
476    }
477
478    /// Validates raw protocol messages, as they arrive on the wire.
479    ///
480    /// The same folding as [`Validator::validate_messages`], plus the checks
481    /// that only make sense on the raw JSON: the message envelope, how deeply
482    /// the message nests, and how long a chain of function calls it carries.
483    /// None of the three survives deserialization into typed messages, because
484    /// all three are properties of the document rather than of any one
485    /// component.
486    pub fn validate_json_messages(&self, messages: &[Value]) -> ValidationReport {
487        let mut message_report = ValidationReport::default();
488        for (index, message) in messages.iter().enumerate() {
489            let locator = format!("messages[{index}]");
490            if self.options.check_envelope {
491                check_envelope(message, &locator, &mut message_report);
492            }
493            check_value_depth(
494                message,
495                &locator,
496                // The enclosing array is depth 0, so a message sits at 1.
497                1,
498                self.options.max_depth,
499                self.options.max_function_call_depth,
500                &mut message_report,
501            );
502        }
503
504        let mut components: Vec<Value> = Vec::new();
505        let mut data_model = Value::Null;
506        let mut has_create = false;
507
508        for message in messages {
509            if message.get("createSurface").is_some() {
510                has_create = true;
511            }
512            for key in ["createSurface", "updateComponents"] {
513                if let Some(Value::Array(list)) = message.pointer(&format!("/{key}/components")) {
514                    components.extend(list.iter().cloned());
515                }
516            }
517            if let Some(update) = message.get("updateDataModel") {
518                let path = update
519                    .get("path")
520                    .and_then(Value::as_str)
521                    .unwrap_or("/")
522                    .to_string();
523                let value = update.get("value").cloned().unwrap_or(Value::Null);
524                // A malformed pointer is reported by the data-model layer, not
525                // here; skip it and validate what we can.
526                let _ = crate::message::apply_data_model_update(&mut data_model, &path, &value);
527            }
528        }
529
530        let mut options = self.options.clone();
531        if !has_create {
532            options.require_root = false;
533            options.allow_dangling_children = true;
534        }
535        // A payload that is nothing but data still gets its depth checked.
536        if components.is_empty() {
537            return message_report;
538        }
539
540        let data = (!data_model.is_null()).then_some(&data_model);
541        let mut report =
542            Validator::with_options(self.catalog, options).validate_json(&components, data);
543        report.errors.splice(0..0, message_report.errors);
544        report.unreachable.extend(message_report.unreachable);
545        report
546    }
547
548    fn run(
549        &self,
550        nodes: &[Node<'_>],
551        typed: &[Component],
552        data_model: Option<&Value>,
553    ) -> ValidationReport {
554        let mut report = ValidationReport::default();
555
556        if nodes.is_empty() {
557            report.errors.push(ValidationError::new(
558                ErrorCode::EmptyComponents,
559                "components",
560                "The components list is empty. A surface needs at least a component with \
561                 id 'root'.",
562            ));
563            return report;
564        }
565
566        let ids = self.check_identity(nodes, &mut report);
567        self.check_types_and_props(nodes, &mut report);
568        self.check_component_depth(nodes, &mut report);
569
570        if self.options.require_root && !ids.contains_key(self.options.root_id.as_str()) {
571            report.errors.push(ValidationError::new(
572                ErrorCode::NoRoot,
573                "components",
574                format!(
575                    "No component has id '{}'. Exactly one component must use that id; it is \
576                     the root the renderer draws from.",
577                    self.options.root_id
578                ),
579            ));
580        }
581
582        let adjacency = self.build_adjacency(nodes, &ids, &mut report);
583        self.check_cycles(nodes, &adjacency, &mut report);
584
585        let reachable = reachable_from_root(&ids, &adjacency, &self.options.root_id);
586        if let Some(reachable) = &reachable {
587            for node in nodes {
588                if let Some(id) = node.id {
589                    if !reachable.contains(&node.index) {
590                        report.unreachable.push(id.to_string());
591                    }
592                }
593            }
594        }
595
596        if self.options.check_bindings || self.options.check_binding_syntax {
597            self.check_bindings(nodes, typed, &ids, &adjacency, data_model, &mut report);
598        }
599        report
600    }
601
602    /// Ids, types and duplicates. Returns id → node index for resolved ids.
603    fn check_identity<'n>(
604        &self,
605        nodes: &'n [Node<'n>],
606        report: &mut ValidationReport,
607    ) -> BTreeMap<&'n str, usize> {
608        let mut ids: BTreeMap<&str, usize> = BTreeMap::new();
609        for node in nodes {
610            let Some(id) = node.id else {
611                report.errors.push(ValidationError::new(
612                    ErrorCode::MissingId,
613                    node.locator("id"),
614                    "Every component needs a non-empty string 'id'; other components reference \
615                     it by that id.",
616                ));
617                continue;
618            };
619            if let Some(first) = ids.get(id) {
620                report.errors.push(ValidationError::new(
621                    ErrorCode::DuplicateId,
622                    node.locator("id"),
623                    format!(
624                        "Component id '{id}' is already used by components[{first}]. Ids must be \
625                         unique within a surface; rename this one."
626                    ),
627                ));
628                continue;
629            }
630            ids.insert(id, node.index);
631        }
632        ids
633    }
634
635    fn check_types_and_props(&self, nodes: &[Node<'_>], report: &mut ValidationReport) {
636        let catalog_is_usable = !self.catalog.components.is_empty();
637        for node in nodes {
638            let Some(kind) = node.kind else {
639                report.errors.push(ValidationError::new(
640                    ErrorCode::MissingComponentType,
641                    node.locator("component"),
642                    "Every component needs a 'component' field naming its type, e.g. \
643                     \"component\": \"Text\".",
644                ));
645                continue;
646            };
647            if !catalog_is_usable {
648                continue;
649            }
650            let Some(def) = self.catalog.component(kind) else {
651                // Whether an unfamiliar type is itself an error is the caller's
652                // choice; either way there is no definition to check against, so
653                // this component is done.
654                if self.options.check_component_types {
655                    report.errors.push(ValidationError::new(
656                        ErrorCode::UnknownComponent,
657                        node.locator("component"),
658                        format!(
659                            "Component type '{kind}' is not in catalog '{}'. Use one of: {}.",
660                            self.catalog.catalog_id,
661                            self.catalog
662                                .components_in_order()
663                                .map(|d| d.name.as_str())
664                                .collect::<Vec<_>>()
665                                .join(", ")
666                        ),
667                    ));
668                }
669                continue;
670            };
671            if self.options.check_required_props {
672                for required in &def.required {
673                    let present = node
674                        .props
675                        .is_some_and(|props| props.get(required).is_some_and(|v| !v.is_null()));
676                    if !present {
677                        report.errors.push(ValidationError::new(
678                            ErrorCode::MissingRequiredProp,
679                            node.locator(required),
680                            format!("'{kind}' requires the property '{required}'."),
681                        ));
682                    }
683                }
684            }
685            if self.options.check_prop_types {
686                self.check_prop_types(node, kind, def, report);
687            }
688        }
689    }
690
691    /// Property values against the JSON types the catalog declares.
692    ///
693    /// Walks the *definition* rather than the value, so a property the catalog
694    /// says nothing about costs nothing and is never rejected: an unknown
695    /// property is the catalog's business (or the schema's), not this check's.
696    fn check_prop_types(
697        &self,
698        node: &Node<'_>,
699        kind: &str,
700        def: &ComponentDef,
701        report: &mut ValidationReport,
702    ) {
703        let Some(props) = node.props else { return };
704        for prop in def.props.values() {
705            if prop.value_type == PropType::Unconstrained {
706                continue;
707            }
708            let Some(value) = props.get(&prop.name) else {
709                continue;
710            };
711            if resolves_at_render_time(value) || prop.value_type.accepts(value) {
712                continue;
713            }
714            report.errors.push(ValidationError::new(
715                ErrorCode::TypeMismatch,
716                node.locator(&prop.name),
717                format!(
718                    "'{kind}' expects '{}' to be {}, not {}. Write a literal of that type, or \
719                     bind it with {{\"path\": \"/...\"}}.",
720                    prop.name,
721                    prop.value_type.describe(),
722                    type_name(value)
723                ),
724            ));
725        }
726    }
727
728    /// How deeply each component nests inside itself, and how long a chain of
729    /// function calls it carries.
730    ///
731    /// Separate from the component *graph* depth checked in
732    /// [`Validator::check_cycles`]: a component can be shallow in the tree and
733    /// still carry a pathologically nested `action` or data binding.
734    fn check_component_depth(&self, nodes: &[Node<'_>], report: &mut ValidationReport) {
735        for node in nodes {
736            let Some(props) = node.props else { continue };
737            for (key, value) in props {
738                check_value_depth(
739                    value,
740                    &node.locator(key),
741                    1,
742                    self.options.max_depth,
743                    self.options.max_function_call_depth,
744                    report,
745                );
746            }
747        }
748    }
749
750    /// Child edges, reporting references that do not resolve.
751    fn build_adjacency(
752        &self,
753        nodes: &[Node<'_>],
754        ids: &BTreeMap<&str, usize>,
755        report: &mut ValidationReport,
756    ) -> Vec<Vec<Edge>> {
757        let mut adjacency: Vec<Vec<Edge>> = vec![Vec::new(); nodes.len()];
758        for node in nodes {
759            let Some(component) = node.component() else {
760                continue;
761            };
762            for reference in self.catalog.references(component) {
763                match ids.get(reference.id.as_str()) {
764                    Some(&target) => adjacency[node.index].push(Edge {
765                        target,
766                        location: reference.location,
767                    }),
768                    None if self.options.allow_dangling_children => {}
769                    None => report.errors.push(ValidationError::new(
770                        ErrorCode::UnresolvedChild,
771                        node.locator(&reference.location),
772                        format!(
773                            "Component '{}' references '{}', which is not defined in this \
774                             payload. Add a component with that id, or point at one that exists.",
775                            component.id, reference.id
776                        ),
777                    )),
778                }
779            }
780        }
781        adjacency
782    }
783
784    /// Iterative depth-first search reporting each distinct cycle once, and
785    /// flagging a component graph nested past [`ValidateOptions::max_depth`].
786    ///
787    /// Iterative rather than recursive because the input is model-generated and
788    /// may be arbitrarily deep — the very thing the depth limit reports on. A
789    /// back edge to a node still on the current path closes a cycle; a
790    /// self-reference is the one-node case of the same thing.
791    ///
792    /// Depth is measured along the search path, so it is the depth of the first
793    /// route the search finds to a node rather than the longest possible one.
794    /// That matches every other toolkit, and finding true longest paths in a
795    /// general graph is not something a validator should be doing.
796    fn check_cycles(
797        &self,
798        nodes: &[Node<'_>],
799        adjacency: &[Vec<Edge>],
800        report: &mut ValidationReport,
801    ) {
802        const WHITE: u8 = 0;
803        const GRAY: u8 = 1;
804        const BLACK: u8 = 2;
805
806        let mut color = vec![WHITE; nodes.len()];
807        let mut reported: BTreeSet<Vec<usize>> = BTreeSet::new();
808        let mut reported_depth = false;
809
810        for start in 0..nodes.len() {
811            if color[start] != WHITE {
812                continue;
813            }
814            color[start] = GRAY;
815            let mut stack: Vec<(usize, usize)> = vec![(start, 0)];
816
817            while let Some(&(node, edge_index)) = stack.last() {
818                if edge_index >= adjacency[node].len() {
819                    color[node] = BLACK;
820                    stack.pop();
821                    continue;
822                }
823                if let Some(top) = stack.last_mut() {
824                    top.1 += 1;
825                }
826                let edge = &adjacency[node][edge_index];
827                match color[edge.target] {
828                    // `stack.len() - 1` is the depth of `node`, so the target
829                    // sits one deeper.
830                    WHITE if stack.len() > self.options.max_depth => {
831                        if !reported_depth {
832                            reported_depth = true;
833                            report.errors.push(ValidationError::new(
834                                ErrorCode::MaxDepthExceeded,
835                                nodes[node].locator(&edge.location),
836                                format!(
837                                    "Global recursion limit exceeded: logical depth > {}. The \
838                                     component tree nests deeper than a renderer will draw; \
839                                     flatten it.",
840                                    self.options.max_depth
841                                ),
842                            ));
843                        }
844                        // Leave the subtree unexplored: it is condemned, and a
845                        // pathological payload should not cost more work.
846                        color[edge.target] = BLACK;
847                    }
848                    WHITE => {
849                        color[edge.target] = GRAY;
850                        stack.push((edge.target, 0));
851                    }
852                    GRAY => {
853                        // Back edge: the cycle is the current path from the
854                        // target onwards, closed by this edge.
855                        let path: Vec<usize> = stack.iter().map(|(n, _)| *n).collect();
856                        let start_of_cycle =
857                            path.iter().position(|n| *n == edge.target).unwrap_or(0);
858                        let cycle = &path[start_of_cycle..];
859                        let mut key = cycle.to_vec();
860                        key.sort_unstable();
861                        if reported.insert(key) {
862                            report
863                                .errors
864                                .push(self.cycle_error(nodes, cycle, node, edge));
865                        }
866                    }
867                    _ => {}
868                }
869            }
870        }
871    }
872
873    fn cycle_error(
874        &self,
875        nodes: &[Node<'_>],
876        cycle: &[usize],
877        from: usize,
878        edge: &Edge,
879    ) -> ValidationError {
880        let name = |index: usize| nodes[index].id.unwrap_or("<missing id>");
881        let mut chain: Vec<&str> = cycle.iter().map(|index| name(*index)).collect();
882        chain.push(name(edge.target));
883        // The two lead-ins are the phrasing every A2UI SDK uses for these
884        // conditions; keeping them identical means a renderer or an operator
885        // reading logs from a mixed-language system sees one vocabulary.
886        let detail = if cycle.len() == 1 {
887            format!(
888                "Self-reference detected: component '{}' references itself in '{}'.",
889                name(from),
890                edge.location
891            )
892        } else {
893            format!(
894                "Circular reference detected: child references form a loop: {}.",
895                chain.join(" -> ")
896            )
897        };
898        ValidationError::new(
899            ErrorCode::ChildCycle,
900            nodes[from].locator(&edge.location),
901            format!(
902                "{detail} A component tree must be acyclic; break the loop by pointing at a \
903                 different component."
904            ),
905        )
906    }
907
908    /// Data bindings: relative paths need a collection scope, and every path
909    /// must resolve when a data model is available.
910    fn check_bindings(
911        &self,
912        nodes: &[Node<'_>],
913        typed: &[Component],
914        ids: &BTreeMap<&str, usize>,
915        adjacency: &[Vec<Edge>],
916        data_model: Option<&Value>,
917        report: &mut ValidationReport,
918    ) {
919        // The scopes borrow whichever document is in play, so it has to outlive
920        // the loop; `null` stands in when the caller supplied none.
921        let no_data = Value::Null;
922        let has_data = data_model.is_some();
923        let data = data_model.unwrap_or(&no_data);
924        let scopes = collection_scopes(typed, ids, adjacency, self.catalog, data, has_data);
925
926        for node in nodes {
927            let Some(component) = node.component() else {
928                continue;
929            };
930            let Ok(raw) = serde_json::to_value(component) else {
931                continue;
932            };
933            let scope = scopes.get(&node.index);
934
935            for binding in collect_bindings(&raw) {
936                let is_absolute = binding.path.starts_with('/');
937                // An absolute path goes on the wire verbatim, so a malformed
938                // escape can never resolve for any data model. Worth saying so
939                // even when no data model is available to check against.
940                if self.options.check_binding_syntax
941                    && is_absolute
942                    && !is_valid_pointer(&binding.path)
943                {
944                    report.errors.push(ValidationError::new(
945                        ErrorCode::UnresolvedBinding,
946                        node.locator(&binding.location),
947                        format!(
948                            "Invalid path syntax: '{}' is not a valid JSON Pointer. Inside a \
949                             path, '~' must be written '~0' and '/' must be written '~1'.",
950                            binding.path
951                        ),
952                    ));
953                    continue;
954                }
955                if !self.options.check_bindings {
956                    continue;
957                }
958                if !is_absolute && scope.is_none() {
959                    report.errors.push(ValidationError::new(
960                        ErrorCode::UnresolvedBinding,
961                        node.locator(&binding.location),
962                        format!(
963                            "Relative path '{}' has nothing to resolve against: component '{}' \
964                             is not inside a list template. Use an absolute path starting with \
965                             '/'.",
966                            binding.path, component.id
967                        ),
968                    ));
969                    continue;
970                }
971                if !has_data {
972                    continue;
973                }
974                let resolver = match scope {
975                    Some(CollectionScope::Resolved(item)) => item.clone(),
976                    // The enclosing collection is missing or empty, so there is
977                    // no item to resolve a relative path against. The
978                    // collection itself is reported on its container.
979                    Some(CollectionScope::Unresolvable) if !is_absolute => continue,
980                    _ => Scope::root(data),
981                };
982                let resolved = resolver.resolve(&binding.path);
983                match (binding.is_collection, resolved) {
984                    (_, None) => report.errors.push(ValidationError::new(
985                        ErrorCode::UnresolvedBinding,
986                        node.locator(&binding.location),
987                        format!(
988                            "Path '{}' does not exist in the data model. Add the value with \
989                             updateDataModel, or bind to a path that exists.",
990                            binding.path
991                        ),
992                    )),
993                    (true, Some(value)) if !value.is_array() => {
994                        report.errors.push(ValidationError::new(
995                            ErrorCode::UnresolvedBinding,
996                            node.locator(&binding.location),
997                            format!(
998                                "Template path '{}' must point at an array to iterate; it points \
999                                 at {}.",
1000                                binding.path,
1001                                type_name(value)
1002                            ),
1003                        ));
1004                    }
1005                    _ => {}
1006                }
1007            }
1008        }
1009    }
1010}
1011
1012/// Whether a string is a syntactically valid RFC 6901 JSON Pointer.
1013fn is_valid_pointer(path: &str) -> bool {
1014    crate::binding::pointer_is_valid(path)
1015}
1016
1017/// Whether the renderer computes this value rather than reading it literally.
1018///
1019/// A `{"path": …}` binding and a `{"call": …}` / `{"functionCall": …}` invocation
1020/// both carry something other than the property's own value, so the type they
1021/// have on the wire says nothing about the type the renderer will see.
1022/// `{componentId, path}` is excluded: that is a child template, and its `path`
1023/// names a collection rather than a value.
1024fn resolves_at_render_time(value: &Value) -> bool {
1025    let Some(map) = value.as_object() else {
1026        return false;
1027    };
1028    (map.contains_key("path") && !map.contains_key("componentId"))
1029        || map.contains_key("call")
1030        || map.contains_key("functionCall")
1031}
1032
1033fn type_name(value: &Value) -> &'static str {
1034    match value {
1035        Value::Null => "null",
1036        Value::Bool(_) => "a boolean",
1037        Value::Number(_) => "a number",
1038        Value::String(_) => "a string",
1039        Value::Array(_) => "an array",
1040        Value::Object(_) => "an object",
1041    }
1042}
1043
1044#[derive(Debug, Clone)]
1045struct Edge {
1046    target: usize,
1047    location: String,
1048}
1049
1050/// The collection scope a component sits in, if any.
1051enum CollectionScope<'a> {
1052    /// Relative paths resolve against this item scope.
1053    Resolved(Scope<'a>),
1054    /// Inside a template whose collection could not be resolved, so relative
1055    /// paths cannot be checked here.
1056    Unresolvable,
1057}
1058
1059/// Assigns a collection scope to every component reachable through a template.
1060///
1061/// A `ChildList` template opens one scope per element of the bound array. For
1062/// validation we resolve relative paths against the **first** element, which is
1063/// enough to catch typos without iterating data that may be huge or absent.
1064/// Subtrees under a template inherit its scope; nested templates compose.
1065fn collection_scopes<'a>(
1066    components: &[Component],
1067    ids: &BTreeMap<&str, usize>,
1068    adjacency: &[Vec<Edge>],
1069    catalog: &Catalog,
1070    data: &'a Value,
1071    has_data: bool,
1072) -> BTreeMap<usize, CollectionScope<'a>> {
1073    let mut scopes: BTreeMap<usize, CollectionScope<'a>> = BTreeMap::new();
1074    let mut queue: Vec<(usize, Option<Scope<'a>>)> = Vec::new();
1075
1076    // Seed from every template edge.
1077    for component in components {
1078        let Some(&index) = ids.get(component.id.as_str()) else {
1079            continue;
1080        };
1081        for reference in catalog.references(component) {
1082            let Some(collection_path) = template_path(component, &reference.location) else {
1083                continue;
1084            };
1085            let Some(&target) = ids.get(reference.id.as_str()) else {
1086                continue;
1087            };
1088            // Templates nested inside another template resolve their collection
1089            // path in the outer scope.
1090            let base = match scopes.get(&index) {
1091                Some(CollectionScope::Resolved(outer)) => outer.clone(),
1092                Some(CollectionScope::Unresolvable) | None => Scope::root(data),
1093            };
1094            let item = base.item(&collection_path, 0);
1095            let resolvable = has_data
1096                && base
1097                    .resolve(&collection_path)
1098                    .and_then(Value::as_array)
1099                    .is_some_and(|items| !items.is_empty());
1100            queue.push((target, resolvable.then_some(item)));
1101        }
1102    }
1103
1104    // Propagate scopes down the subtree under each template.
1105    let mut guard = 0usize;
1106    while let Some((index, scope)) = queue.pop() {
1107        guard += 1;
1108        if guard > adjacency.len() * adjacency.len() + adjacency.len() {
1109            break; // Cyclic input; cycles are reported separately.
1110        }
1111        let entry = match &scope {
1112            Some(item) => CollectionScope::Resolved(item.clone()),
1113            None => CollectionScope::Unresolvable,
1114        };
1115        if scopes.insert(index, entry).is_some() {
1116            continue;
1117        }
1118        for edge in &adjacency[index] {
1119            queue.push((edge.target, scope.clone()));
1120        }
1121    }
1122    scopes
1123}
1124
1125/// The collection path of a template edge, if this reference is one.
1126fn template_path(component: &Component, location: &str) -> Option<String> {
1127    let prop = location.strip_suffix(".componentId")?;
1128    component
1129        .props
1130        .get(prop)?
1131        .as_object()?
1132        .get("path")?
1133        .as_str()
1134        .map(str::to_string)
1135}
1136
1137/// Fields of each agent → renderer operation: name, JSON type, and whether the
1138/// protocol requires it.
1139///
1140/// Transcribed from the payload structs in [`crate::message`], which are the
1141/// port of the v0.9 wire format. Fields the specification leaves untyped (an
1142/// `updateDataModel` value, a function's arguments) are simply absent: this is
1143/// the envelope contract, not a schema for everything inside it.
1144pub(crate) type EnvelopeField = (&'static str, PropType, bool);
1145pub(crate) const OPERATIONS: [(&str, &[EnvelopeField]); 6] = [
1146    (
1147        "createSurface",
1148        &[
1149            ("surfaceId", PropType::String, true),
1150            ("catalogId", PropType::String, true),
1151            ("theme", PropType::Object, false),
1152            ("sendDataModel", PropType::Boolean, false),
1153        ],
1154    ),
1155    (
1156        "updateComponents",
1157        &[
1158            ("surfaceId", PropType::String, true),
1159            ("components", PropType::Array, true),
1160        ],
1161    ),
1162    (
1163        "updateDataModel",
1164        &[
1165            ("surfaceId", PropType::String, true),
1166            ("path", PropType::String, false),
1167        ],
1168    ),
1169    ("deleteSurface", &[("surfaceId", PropType::String, true)]),
1170    (
1171        "callRendererFunction",
1172        &[
1173            ("functionCallId", PropType::String, true),
1174            ("callFunction", PropType::Object, true),
1175        ],
1176    ),
1177    (
1178        "agentFunctionResponse",
1179        &[("functionCallId", PropType::String, true)],
1180    ),
1181];
1182
1183/// Checks one message against the v0.9 envelope contract.
1184///
1185/// Every other toolkit gets this from `server_to_client.json` through a JSON
1186/// Schema engine. This crate speaks exactly one protocol version (see
1187/// `docs/DESIGN.md`), so the contract is a table rather than a document — and a
1188/// table is what lets a failure carry a locator into the message the caller
1189/// sent, rather than a path into a schema the caller never saw. The codes match
1190/// the ones the schema-driven toolkits report, because callers route on them.
1191///
1192/// Only agent → renderer messages belong here: a renderer's reply is not agent
1193/// output and is not part of a payload this validator is asked about.
1194fn check_envelope(message: &Value, locator: &str, report: &mut ValidationReport) {
1195    let Some(map) = message.as_object() else {
1196        report.errors.push(ValidationError::new(
1197            ErrorCode::TypeMismatch,
1198            locator,
1199            format!("A message must be an object, not {}.", type_name(message)),
1200        ));
1201        return;
1202    };
1203
1204    match map.get("version") {
1205        Some(Value::String(version)) if version == PROTOCOL_VERSION => {}
1206        Some(version) => report.errors.push(ValidationError::new(
1207            ErrorCode::InvalidValue,
1208            format!("{locator}.version"),
1209            format!(
1210                "This crate speaks A2UI {PROTOCOL_VERSION}, but the message declares {version}. \
1211                 Every message in a stream carries the same version."
1212            ),
1213        )),
1214        None => report.errors.push(ValidationError::new(
1215            ErrorCode::MissingField,
1216            format!("{locator}.version"),
1217            format!("Every message needs \"version\": \"{PROTOCOL_VERSION}\"."),
1218        )),
1219    }
1220
1221    let Some((key, fields)) = OPERATIONS
1222        .iter()
1223        .find(|(key, _)| map.contains_key(*key))
1224        .copied()
1225    else {
1226        let names: Vec<&str> = OPERATIONS.iter().map(|(key, _)| *key).collect();
1227        report.errors.push(ValidationError::new(
1228            ErrorCode::MissingField,
1229            locator.to_string(),
1230            format!(
1231                "A message must carry one of {}. This one carries {:?}.",
1232                names.join(", "),
1233                map.keys().collect::<Vec<_>>()
1234            ),
1235        ));
1236        return;
1237    };
1238
1239    let Some(payload) = map[key].as_object() else {
1240        report.errors.push(ValidationError::new(
1241            ErrorCode::TypeMismatch,
1242            format!("{locator}.{key}"),
1243            format!("'{key}' must be an object, not {}.", type_name(&map[key])),
1244        ));
1245        return;
1246    };
1247    for (field, value_type, required) in fields {
1248        match payload.get(*field) {
1249            Some(value) if !value.is_null() => {
1250                if !value_type.accepts(value) {
1251                    report.errors.push(ValidationError::new(
1252                        ErrorCode::TypeMismatch,
1253                        format!("{locator}.{key}.{field}"),
1254                        format!(
1255                            "'{field}' of '{key}' must be {}, not {}.",
1256                            value_type.describe(),
1257                            type_name(value)
1258                        ),
1259                    ));
1260                }
1261            }
1262            _ if *required => report.errors.push(ValidationError::new(
1263                ErrorCode::MissingField,
1264                format!("{locator}.{key}.{field}"),
1265                format!("'{key}' requires the field '{field}'."),
1266            )),
1267            _ => {}
1268        }
1269    }
1270}
1271
1272/// Reports JSON nesting and function-call chains that run past their limits.
1273///
1274/// Iterative with an explicit stack: the input is model-generated, and a
1275/// recursive walk over it is exactly the stack overflow this check exists to
1276/// prevent. `base_depth` is the depth `value` already sits at within its
1277/// enclosing document.
1278///
1279/// A `components` array is skipped, because components are checked one at a
1280/// time with locators that name the offending one; walking them here as well
1281/// would report the same nesting twice under a vaguer path.
1282///
1283/// At most one error of each kind is reported — a payload that is too deep is
1284/// too deep once, and listing every node past the limit would bury the point.
1285fn check_value_depth(
1286    value: &Value,
1287    path: &str,
1288    base_depth: usize,
1289    max_depth: usize,
1290    max_function_call_depth: usize,
1291    report: &mut ValidationReport,
1292) {
1293    let mut reported_depth = false;
1294    let mut reported_calls = false;
1295    let mut stack: Vec<(&Value, usize, usize)> = vec![(value, base_depth, 0)];
1296
1297    while let Some((current, depth, call_depth)) = stack.pop() {
1298        if depth > max_depth {
1299            if !reported_depth {
1300                reported_depth = true;
1301                report.errors.push(ValidationError::new(
1302                    ErrorCode::MaxDepthExceeded,
1303                    path,
1304                    format!(
1305                        "Global recursion limit exceeded: depth > {max_depth}. Flatten the \
1306                         structure; a renderer will not draw nesting this deep."
1307                    ),
1308                ));
1309            }
1310            // Stop descending: the message is already condemned, and walking
1311            // the rest costs time on input that is probably adversarial.
1312            continue;
1313        }
1314
1315        match current {
1316            Value::Array(items) => {
1317                for item in items {
1318                    stack.push((item, depth + 1, call_depth));
1319                }
1320            }
1321            Value::Object(map) => {
1322                // Two spellings of a function call, and both nest: a
1323                // `{"functionCall": ...}` wrapper, and the `{call, args}` object
1324                // it wraps. Each costs one level, so a chain written with both
1325                // spends the budget twice as fast as the number suggests. That
1326                // is what every other toolkit counts, and a payload one of them
1327                // rejects must be rejected here too.
1328                let wrapper = map.get("functionCall").filter(|value| value.is_object());
1329                let is_call = map.contains_key("call") && map.contains_key("args");
1330
1331                if (wrapper.is_some() || is_call) && call_depth >= max_function_call_depth {
1332                    if !reported_calls {
1333                        reported_calls = true;
1334                        report.errors.push(ValidationError::new(
1335                            ErrorCode::MaxDepthExceeded,
1336                            path,
1337                            format!(
1338                                "Recursion limit exceeded: functionCall depth > \
1339                                 {max_function_call_depth}. Compute the value before sending it \
1340                                 rather than chaining more calls."
1341                            ),
1342                        ));
1343                    }
1344                    continue;
1345                }
1346
1347                if let Some(wrapper) = wrapper {
1348                    stack.push((wrapper, depth + 1, call_depth + 1));
1349                    continue;
1350                }
1351                for (key, child) in map {
1352                    if key == "components" {
1353                        continue;
1354                    }
1355                    let next_call_depth = if is_call && key == "args" {
1356                        call_depth + 1
1357                    } else {
1358                        call_depth
1359                    };
1360                    stack.push((child, depth + 1, next_call_depth));
1361                }
1362            }
1363            _ => {}
1364        }
1365    }
1366}
1367
1368/// Node indices reachable from the root, or `None` when there is no root.
1369fn reachable_from_root(
1370    ids: &BTreeMap<&str, usize>,
1371    adjacency: &[Vec<Edge>],
1372    root_id: &str,
1373) -> Option<BTreeSet<usize>> {
1374    let root = *ids.get(root_id)?;
1375    let mut seen = BTreeSet::new();
1376    let mut stack = vec![root];
1377    while let Some(node) = stack.pop() {
1378        if !seen.insert(node) {
1379            continue;
1380        }
1381        for edge in &adjacency[node] {
1382            stack.push(edge.target);
1383        }
1384    }
1385    Some(seen)
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::*;
1391    use serde_json::json;
1392
1393    fn basic() -> Catalog {
1394        Catalog::basic()
1395    }
1396
1397    fn codes(report: &ValidationReport) -> Vec<ErrorCode> {
1398        report.errors.iter().map(|e| e.code).collect()
1399    }
1400
1401    #[test]
1402    fn a_well_formed_surface_validates_clean() {
1403        let catalog = basic();
1404        let components = vec![
1405            Component::new("root", "Column").with("children", json!(["title", "cta"])),
1406            Component::new("title", "Text").with("text", json!("Hello")),
1407            Component::new("cta", "Button")
1408                .with("child", json!("title"))
1409                .with("action", json!({"event": {"name": "go"}})),
1410        ];
1411        let report = Validator::new(&catalog).validate(&components);
1412        assert!(report.is_valid(), "{:?}", report.errors);
1413        assert!(report.unreachable.is_empty());
1414    }
1415
1416    #[test]
1417    fn empty_components_is_reported_once() {
1418        let report = Validator::new(&basic()).validate(&[]);
1419        assert_eq!(codes(&report), vec![ErrorCode::EmptyComponents]);
1420        assert_eq!(report.errors[0].path, "components");
1421    }
1422
1423    #[test]
1424    fn missing_id_and_type_come_from_raw_json() {
1425        let catalog = basic();
1426        let report = Validator::new(&catalog).validate_json(
1427            &[
1428                json!({"component": "Text", "text": "x"}),
1429                json!({"id": "b"}),
1430            ],
1431            None,
1432        );
1433        assert!(codes(&report).contains(&ErrorCode::MissingId));
1434        assert!(codes(&report).contains(&ErrorCode::MissingComponentType));
1435        assert_eq!(
1436            report
1437                .errors
1438                .iter()
1439                .find(|e| e.code == ErrorCode::MissingId)
1440                .unwrap()
1441                .path,
1442            "components[0].id"
1443        );
1444        assert_eq!(
1445            report
1446                .errors
1447                .iter()
1448                .find(|e| e.code == ErrorCode::MissingComponentType)
1449                .unwrap()
1450                .path,
1451            "components[1].component"
1452        );
1453    }
1454
1455    #[test]
1456    fn duplicate_ids_point_at_the_later_component() {
1457        let catalog = basic();
1458        let components = vec![
1459            Component::new("root", "Text").with("text", json!("a")),
1460            Component::new("dup", "Text").with("text", json!("b")),
1461            Component::new("dup", "Text").with("text", json!("c")),
1462        ];
1463        let report = Validator::new(&catalog).validate(&components);
1464        let error = report
1465            .errors
1466            .iter()
1467            .find(|e| e.code == ErrorCode::DuplicateId)
1468            .unwrap();
1469        assert_eq!(error.path, "components[2].id");
1470        assert!(error.message.contains("components[1]"));
1471    }
1472
1473    #[test]
1474    fn a_missing_root_is_reported_for_full_surfaces_only() {
1475        let catalog = basic();
1476        let components = vec![Component::new("c1", "Text").with("text", json!("hi"))];
1477        assert!(
1478            codes(&Validator::new(&catalog).validate(&components)).contains(&ErrorCode::NoRoot)
1479        );
1480        assert!(
1481            Validator::incremental(&catalog)
1482                .validate(&components)
1483                .is_valid()
1484        );
1485    }
1486
1487    #[test]
1488    fn unknown_component_types_are_rejected_against_the_catalog() {
1489        let catalog = basic();
1490        let report = Validator::new(&catalog)
1491            .validate(&[Component::new("root", "Sparkline").with("data", json!([1, 2]))]);
1492        let error = report
1493            .errors
1494            .iter()
1495            .find(|e| e.code == ErrorCode::UnknownComponent)
1496            .unwrap();
1497        assert_eq!(error.path, "components[0].component");
1498        assert!(error.message.contains("Text"));
1499    }
1500
1501    #[test]
1502    fn required_props_are_enforced_per_component_type() {
1503        let catalog = basic();
1504        let report = Validator::new(&catalog).validate(&[
1505            Component::new("root", "Column").with("children", json!(["t"])),
1506            Component::new("t", "Text"),
1507        ]);
1508        let error = report
1509            .errors
1510            .iter()
1511            .find(|e| e.code == ErrorCode::MissingRequiredProp)
1512            .unwrap();
1513        assert_eq!(error.path, "components[1].text");
1514    }
1515
1516    #[test]
1517    fn unresolved_children_are_located_precisely() {
1518        let catalog = basic();
1519        let report = Validator::new(&catalog).validate(&[
1520            Component::new("root", "Row").with("children", json!(["there", "gone"])),
1521            Component::new("there", "Text").with("text", json!("x")),
1522        ]);
1523        let error = report
1524            .errors
1525            .iter()
1526            .find(|e| e.code == ErrorCode::UnresolvedChild)
1527            .unwrap();
1528        assert_eq!(error.path, "components[0].children[1]");
1529        assert!(error.message.contains("'gone'"));
1530    }
1531
1532    #[test]
1533    fn template_references_are_resolved_like_any_other_child() {
1534        let catalog = basic();
1535        let ok = Validator::new(&catalog).validate(&[
1536            Component::new("root", "List")
1537                .with("children", json!({"componentId": "tpl", "path": "/items"})),
1538            Component::new("tpl", "Text").with("text", json!({"path": "label"})),
1539        ]);
1540        assert!(ok.is_valid(), "{:?}", ok.errors);
1541
1542        let broken = Validator::new(&catalog).validate(&[Component::new("root", "List").with(
1543            "children",
1544            json!({"componentId": "missing", "path": "/items"}),
1545        )]);
1546        let error = broken
1547            .errors
1548            .iter()
1549            .find(|e| e.code == ErrorCode::UnresolvedChild)
1550            .unwrap();
1551        assert_eq!(error.path, "components[0].children.componentId");
1552    }
1553
1554    #[test]
1555    fn dangling_children_are_allowed_for_incremental_updates() {
1556        let catalog = basic();
1557        let components = vec![Component::new("card", "Card").with("child", json!("elsewhere"))];
1558        assert!(!Validator::new(&catalog).validate(&components).is_valid());
1559        assert!(
1560            Validator::incremental(&catalog)
1561                .validate(&components)
1562                .is_valid()
1563        );
1564    }
1565
1566    #[test]
1567    fn self_reference_is_a_cycle_even_in_incremental_updates() {
1568        let catalog = basic();
1569        let components = vec![Component::new("card", "Card").with("child", json!("card"))];
1570        let report = Validator::incremental(&catalog).validate(&components);
1571        let error = report
1572            .errors
1573            .iter()
1574            .find(|e| e.code == ErrorCode::ChildCycle)
1575            .unwrap();
1576        assert_eq!(error.path, "components[0].child");
1577        assert!(error.message.contains("Self-reference detected"));
1578    }
1579
1580    #[test]
1581    fn two_node_cycles_are_reported_once_with_the_chain() {
1582        let catalog = basic();
1583        let components = vec![
1584            Component::new("root", "Card").with("child", json!("c1")),
1585            Component::new("c1", "Card").with("child", json!("root")),
1586        ];
1587        let report = Validator::new(&catalog).validate(&components);
1588        let cycles: Vec<_> = report
1589            .errors
1590            .iter()
1591            .filter(|e| e.code == ErrorCode::ChildCycle)
1592            .collect();
1593        assert_eq!(cycles.len(), 1, "{:?}", report.errors);
1594        assert!(cycles[0].message.contains("Circular reference detected"));
1595        assert!(cycles[0].message.contains("root -> c1 -> root"));
1596    }
1597
1598    #[test]
1599    fn cycles_are_found_when_they_are_unreachable_from_the_root() {
1600        let catalog = basic();
1601        let components = vec![
1602            Component::new("root", "Text").with("text", json!("hi")),
1603            Component::new("a", "Card").with("child", json!("b")),
1604            Component::new("b", "Card").with("child", json!("a")),
1605        ];
1606        let report = Validator::new(&catalog).validate(&components);
1607        assert_eq!(
1608            report
1609                .errors
1610                .iter()
1611                .filter(|e| e.code == ErrorCode::ChildCycle)
1612                .count(),
1613            1
1614        );
1615        assert_eq!(report.unreachable, vec!["a".to_string(), "b".to_string()]);
1616    }
1617
1618    #[test]
1619    fn distinct_cycles_are_each_reported() {
1620        let catalog = basic();
1621        let components = vec![
1622            Component::new("root", "Row").with("children", json!(["a", "c"])),
1623            Component::new("a", "Card").with("child", json!("b")),
1624            Component::new("b", "Card").with("child", json!("a")),
1625            Component::new("c", "Card").with("child", json!("c")),
1626        ];
1627        let report = Validator::new(&catalog).validate(&components);
1628        assert_eq!(
1629            report
1630                .errors
1631                .iter()
1632                .filter(|e| e.code == ErrorCode::ChildCycle)
1633                .count(),
1634            2
1635        );
1636    }
1637
1638    /// A chain of `Card`s `depth` links long, rooted at `root`.
1639    fn deep_chain(depth: usize) -> Vec<Component> {
1640        let mut components = Vec::with_capacity(depth + 2);
1641        components.push(Component::new("root", "Card").with("child", json!("n0")));
1642        for i in 0..depth {
1643            let next = if i + 1 == depth {
1644                json!("leaf")
1645            } else {
1646                json!(format!("n{}", i + 1))
1647            };
1648            components.push(Component::new(format!("n{i}"), "Card").with("child", next));
1649        }
1650        components.push(Component::new("leaf", "Text").with("text", json!("end")));
1651        components
1652    }
1653
1654    /// A value nested `depth` objects deep.
1655    fn deep_value(depth: usize) -> Value {
1656        let mut value = json!({"level": depth});
1657        for level in (0..depth).rev() {
1658            value = json!({"level": level, "next": value});
1659        }
1660        value
1661    }
1662
1663    #[test]
1664    fn a_tree_deeper_than_the_limit_is_reported_not_crashed() {
1665        let catalog = basic();
1666        // Far past the limit, and far past what any stack would survive if the
1667        // walk recursed.
1668        let report = Validator::new(&catalog).validate(&deep_chain(50_000));
1669        let depth_errors: Vec<_> = report
1670            .errors
1671            .iter()
1672            .filter(|e| e.code == ErrorCode::MaxDepthExceeded)
1673            .collect();
1674        assert_eq!(depth_errors.len(), 1, "reported once, not once per node");
1675        assert!(depth_errors[0].message.contains("logical depth > 50"));
1676        assert_eq!(depth_errors[0].path, "components[50].child");
1677    }
1678
1679    #[test]
1680    fn the_depth_limit_is_policy_not_what_keeps_the_walk_safe() {
1681        // With the limit lifted, the same 50k-deep tree still validates: every
1682        // walk is iterative, so nothing here depends on the cap to survive.
1683        // This is the test that would blow the stack if a walk recursed.
1684        let catalog = basic();
1685        let options = ValidateOptions::full_surface().with_max_depth(usize::MAX);
1686        let report = Validator::with_options(&catalog, options).validate(&deep_chain(50_000));
1687        assert!(
1688            report.is_valid(),
1689            "{:?}",
1690            &report.errors[..report.errors.len().min(3)]
1691        );
1692    }
1693
1694    #[test]
1695    fn json_from_the_wire_is_depth_bounded_before_this_crate_sees_it() {
1696        // The value walks in this crate recurse, and this is why that is safe:
1697        // anything arriving as text has already been through serde_json, which
1698        // refuses to build a `Value` nested deeper than 128. If that ever
1699        // changes, those walks need the same treatment as the graph walks.
1700        let ok = format!("{}{}", "[".repeat(127), "]".repeat(127));
1701        assert!(serde_json::from_str::<Value>(&ok).is_ok());
1702
1703        let too_deep = format!("{}{}", "[".repeat(200), "]".repeat(200));
1704        let error = serde_json::from_str::<Value>(&too_deep).unwrap_err();
1705        assert!(
1706            error.to_string().contains("recursion limit exceeded"),
1707            "{error}"
1708        );
1709    }
1710
1711    #[test]
1712    fn a_chain_within_the_limit_is_accepted() {
1713        let catalog = basic();
1714        // root -> n0..n47 -> leaf is 49 links, one inside the limit.
1715        let report = Validator::new(&catalog).validate(&deep_chain(48));
1716        assert!(report.is_valid(), "{:?}", report.errors);
1717    }
1718
1719    #[test]
1720    fn deeply_nested_json_inside_a_component_is_reported() {
1721        let catalog = basic();
1722        let component = Component::new("root", "Text")
1723            .with("text", json!("hi"))
1724            .with("accessibility", deep_value(400));
1725        let report = Validator::new(&catalog).validate(&[component]);
1726        let error = report
1727            .errors
1728            .iter()
1729            .find(|e| e.code == ErrorCode::MaxDepthExceeded)
1730            .expect("a depth error");
1731        assert!(error.message.contains("depth > 50"));
1732        assert_eq!(error.path, "components[0].accessibility");
1733    }
1734
1735    #[test]
1736    fn a_deeply_nested_data_model_is_reported_on_the_message() {
1737        let catalog = basic();
1738        let messages = vec![json!({
1739            "version": "v0.9",
1740            "updateDataModel": {"surfaceId": "s", "value": deep_value(400)}
1741        })];
1742        let report = Validator::new(&catalog).validate_json_messages(&messages);
1743        let error = report
1744            .errors
1745            .iter()
1746            .find(|e| e.code == ErrorCode::MaxDepthExceeded)
1747            .expect("a depth error");
1748        assert!(error.message.contains("Global recursion limit exceeded"));
1749        assert_eq!(error.path, "messages[0]");
1750    }
1751
1752    #[test]
1753    fn a_chain_of_function_calls_past_the_limit_is_reported() {
1754        let catalog = basic();
1755        // Six nested calls, one past the budget of five.
1756        let mut call = json!({"call": "f5", "args": {}});
1757        for level in (0..5).rev() {
1758            call = json!({"call": format!("f{level}"), "args": {"functionCall": call}});
1759        }
1760        let component = Component::new("root", "Button")
1761            .with("child", json!("root"))
1762            .with("action", json!({"functionCall": call}));
1763
1764        let report = Validator::with_options(
1765            &catalog,
1766            ValidateOptions {
1767                // Isolate the call-depth check from the cycle this component
1768                // has for brevity.
1769                ..ValidateOptions::incremental_update()
1770            },
1771        )
1772        .validate(&[component]);
1773        let error = report
1774            .errors
1775            .iter()
1776            .find(|e| e.code == ErrorCode::MaxDepthExceeded)
1777            .expect("a depth error");
1778        assert!(error.message.contains("functionCall depth > 5"), "{error}");
1779        assert_eq!(error.path, "components[0].action");
1780    }
1781
1782    #[test]
1783    fn a_short_chain_of_function_calls_is_accepted() {
1784        let catalog = basic();
1785        // Two calls. The budget of five is spent two levels per call, because
1786        // the wrapper and the call object each count, so this is close to the
1787        // practical ceiling.
1788        let mut call = json!({"call": "f1", "args": {}});
1789        for level in (0..1).rev() {
1790            call = json!({"call": format!("f{level}"), "args": {"functionCall": call}});
1791        }
1792        let components = vec![
1793            Component::new("root", "Button")
1794                .with("child", json!("label"))
1795                .with("action", json!({"functionCall": call})),
1796            Component::new("label", "Text").with("text", json!("go")),
1797        ];
1798        let report = Validator::new(&catalog).validate(&components);
1799        assert!(report.is_valid(), "{:?}", report.errors);
1800    }
1801
1802    #[test]
1803    fn unreachable_components_are_warnings_not_errors() {
1804        let catalog = basic();
1805        let report = Validator::new(&catalog).validate(&[
1806            Component::new("root", "Text").with("text", json!("root")),
1807            Component::new("orphan", "Text").with("text", json!("nobody points here")),
1808        ]);
1809        assert!(report.is_valid(), "{:?}", report.errors);
1810        assert_eq!(report.unreachable, vec!["orphan".to_string()]);
1811    }
1812
1813    #[test]
1814    fn relative_paths_outside_a_template_are_unresolved_bindings() {
1815        let catalog = basic();
1816        let report = Validator::new(&catalog)
1817            .validate(&[Component::new("root", "Text").with("text", json!({"path": "name"}))]);
1818        let error = report
1819            .errors
1820            .iter()
1821            .find(|e| e.code == ErrorCode::UnresolvedBinding)
1822            .unwrap();
1823        assert_eq!(error.path, "components[0].text");
1824        assert!(error.message.contains("not inside a list template"));
1825    }
1826
1827    #[test]
1828    fn relative_paths_inside_a_template_are_accepted() {
1829        let catalog = basic();
1830        let report = Validator::new(&catalog).validate(&[
1831            Component::new("root", "List")
1832                .with("children", json!({"componentId": "row", "path": "/people"})),
1833            Component::new("row", "Text").with("text", json!({"path": "name"})),
1834        ]);
1835        assert!(report.is_valid(), "{:?}", report.errors);
1836    }
1837
1838    #[test]
1839    fn bindings_are_resolved_against_a_supplied_data_model() {
1840        let catalog = basic();
1841        let components = vec![
1842            Component::new("root", "Column").with("children", json!(["a", "b"])),
1843            Component::new("a", "Text").with("text", json!({"path": "/user/name"})),
1844            Component::new("b", "Text").with("text", json!({"path": "/user/nope"})),
1845        ];
1846        let data = json!({"user": {"name": "Ada"}});
1847        let report = Validator::new(&catalog).validate_surface(&components, Some(&data));
1848        let errors: Vec<_> = report
1849            .errors
1850            .iter()
1851            .filter(|e| e.code == ErrorCode::UnresolvedBinding)
1852            .collect();
1853        assert_eq!(errors.len(), 1, "{:?}", report.errors);
1854        assert_eq!(errors[0].path, "components[2].text");
1855    }
1856
1857    #[test]
1858    fn template_paths_must_point_at_an_array() {
1859        let catalog = basic();
1860        let components = vec![
1861            Component::new("root", "List")
1862                .with("children", json!({"componentId": "row", "path": "/people"})),
1863            Component::new("row", "Text").with("text", json!({"path": "name"})),
1864        ];
1865        let data = json!({"people": {"not": "an array"}});
1866        let report = Validator::new(&catalog).validate_surface(&components, Some(&data));
1867        let error = report
1868            .errors
1869            .iter()
1870            .find(|e| e.code == ErrorCode::UnresolvedBinding)
1871            .unwrap();
1872        assert_eq!(error.path, "components[0].children");
1873        assert!(error.message.contains("must point at an array"));
1874    }
1875
1876    #[test]
1877    fn relative_paths_resolve_against_the_first_collection_item() {
1878        let catalog = basic();
1879        let components = vec![
1880            Component::new("root", "List")
1881                .with("children", json!({"componentId": "row", "path": "/people"})),
1882            Component::new("row", "Column").with("children", json!(["name", "typo"])),
1883            Component::new("name", "Text").with("text", json!({"path": "name"})),
1884            Component::new("typo", "Text").with("text", json!({"path": "nmae"})),
1885        ];
1886        let data = json!({"people": [{"name": "Ada"}]});
1887        let report = Validator::new(&catalog).validate_surface(&components, Some(&data));
1888        let errors: Vec<_> = report
1889            .errors
1890            .iter()
1891            .filter(|e| e.code == ErrorCode::UnresolvedBinding)
1892            .collect();
1893        assert_eq!(errors.len(), 1, "{:?}", report.errors);
1894        assert_eq!(errors[0].path, "components[3].text");
1895    }
1896
1897    #[test]
1898    fn every_error_code_has_a_stable_wire_string() {
1899        let all = [
1900            (ErrorCode::EmptyComponents, "empty_components"),
1901            (ErrorCode::MissingId, "missing_id"),
1902            (ErrorCode::MissingComponentType, "missing_component_type"),
1903            (ErrorCode::DuplicateId, "duplicate_id"),
1904            (ErrorCode::NoRoot, "no_root"),
1905            (ErrorCode::UnknownComponent, "unknown_component"),
1906            (ErrorCode::MissingRequiredProp, "missing_required_prop"),
1907            (ErrorCode::MissingField, "missing_field"),
1908            (ErrorCode::InvalidValue, "invalid_value"),
1909            (ErrorCode::TypeMismatch, "type_mismatch"),
1910            (ErrorCode::UnresolvedChild, "unresolved_child"),
1911            (ErrorCode::ChildCycle, "child_cycle"),
1912            (ErrorCode::UnresolvedBinding, "unresolved_binding"),
1913            (ErrorCode::MaxDepthExceeded, "max_depth_exceeded"),
1914        ];
1915        for (code, wire) in all {
1916            assert_eq!(code.as_str(), wire);
1917            assert_eq!(serde_json::to_value(code).unwrap(), json!(wire));
1918        }
1919    }
1920
1921    #[test]
1922    fn a_property_of_the_wrong_json_type_is_reported_against_the_catalog() {
1923        let catalog = basic();
1924        let report = Validator::new(&catalog).validate(&[
1925            Component::new("root", "Column").with("children", json!(["count"])),
1926            // The catalog says Slider.value is a number and Slider.label a
1927            // string; a model that swaps them produces a surface no renderer can
1928            // draw, and nothing before this caught it.
1929            Component::new("count", "Slider")
1930                .with("value", json!("seven"))
1931                .with("max", json!(10))
1932                .with("label", json!(3)),
1933        ]);
1934        let mismatches: Vec<&str> = report
1935            .errors
1936            .iter()
1937            .filter(|e| e.code == ErrorCode::TypeMismatch)
1938            .map(|e| e.path.as_str())
1939            .collect();
1940        assert_eq!(
1941            mismatches,
1942            vec!["components[1].label", "components[1].value"]
1943        );
1944        assert!(
1945            report
1946                .errors
1947                .iter()
1948                .any(|e| e.message.contains("not a string")),
1949            "{:?}",
1950            report.errors
1951        );
1952    }
1953
1954    #[test]
1955    fn a_value_the_renderer_computes_is_never_type_checked() {
1956        let catalog = basic();
1957        // Every one of these is legal in a string property: a binding, a
1958        // function call, and the wrapper spelling of a call. Their type on the
1959        // wire is an object, and rejecting them would break every real surface.
1960        let report = Validator::new(&catalog).validate(&[
1961            Component::new("root", "Column").with("children", json!(["a", "b", "c"])),
1962            Component::new("a", "Text").with("text", json!({"path": "/user/name"})),
1963            Component::new("b", "Text").with(
1964                "text",
1965                json!({"call": "formatString", "args": {"value": "hi"}}),
1966            ),
1967            Component::new("c", "Text").with("text", json!({"functionCall": {"call": "now"}})),
1968        ]);
1969        assert!(report.is_valid(), "{:?}", report.errors);
1970    }
1971
1972    #[test]
1973    fn a_property_the_catalog_leaves_untyped_accepts_anything() {
1974        let catalog = basic();
1975        // `Icon.name` is a string, an `{svgPath}` object, or a binding, so the
1976        // catalog pins no type and this crate demands none.
1977        let report = Validator::new(&catalog)
1978            .validate(&[Component::new("root", "Icon").with("name", json!({"svgPath": "M0 0"}))]);
1979        assert!(report.is_valid(), "{:?}", report.errors);
1980    }
1981
1982    #[test]
1983    fn type_checking_can_be_switched_off() {
1984        let catalog = basic();
1985        let options = ValidateOptions {
1986            check_prop_types: false,
1987            ..ValidateOptions::full_surface()
1988        };
1989        let report = Validator::with_options(&catalog, options)
1990            .validate(&[Component::new("root", "Text").with("text", json!(123))]);
1991        assert!(report.is_valid(), "{:?}", report.errors);
1992    }
1993
1994    #[test]
1995    fn a_message_without_a_version_is_reported_as_a_missing_field() {
1996        let catalog = basic();
1997        let messages = vec![json!({"createSurface": {"surfaceId": "s", "catalogId": "c"}})];
1998        let report = Validator::new(&catalog).validate_json_messages(&messages);
1999        assert_eq!(codes(&report), vec![ErrorCode::MissingField]);
2000        assert_eq!(report.errors[0].path, "messages[0].version");
2001    }
2002
2003    #[test]
2004    fn a_message_from_another_protocol_version_is_reported_as_an_invalid_value() {
2005        let catalog = basic();
2006        let messages = vec![json!({
2007            "version": "v0.8",
2008            "createSurface": {"surfaceId": "s", "catalogId": "c"}
2009        })];
2010        let report = Validator::new(&catalog).validate_json_messages(&messages);
2011        assert_eq!(codes(&report), vec![ErrorCode::InvalidValue]);
2012        assert_eq!(report.errors[0].path, "messages[0].version");
2013    }
2014
2015    #[test]
2016    fn an_operation_is_held_to_its_own_required_fields_and_types() {
2017        let catalog = basic();
2018        let messages = vec![
2019            json!({"version": "v0.9", "createSurface": {"surfaceId": "s"}}),
2020            json!({"version": "v0.9", "deleteSurface": {"surfaceId": 123}}),
2021        ];
2022        let report = Validator::new(&catalog).validate_json_messages(&messages);
2023        let located: Vec<(ErrorCode, &str)> = report
2024            .errors
2025            .iter()
2026            .map(|e| (e.code, e.path.as_str()))
2027            .collect();
2028        assert_eq!(
2029            located,
2030            vec![
2031                (
2032                    ErrorCode::MissingField,
2033                    "messages[0].createSurface.catalogId"
2034                ),
2035                (
2036                    ErrorCode::TypeMismatch,
2037                    "messages[1].deleteSurface.surfaceId"
2038                ),
2039            ]
2040        );
2041    }
2042
2043    #[test]
2044    fn a_message_carrying_no_operation_at_all_is_reported() {
2045        let catalog = basic();
2046        let messages = vec![json!({"version": "v0.9", "action": {"name": "go"}})];
2047        let report = Validator::new(&catalog).validate_json_messages(&messages);
2048        assert_eq!(codes(&report), vec![ErrorCode::MissingField]);
2049        assert_eq!(report.errors[0].path, "messages[0]");
2050    }
2051
2052    #[test]
2053    fn envelope_checking_can_be_switched_off() {
2054        let catalog = basic();
2055        let options = ValidateOptions {
2056            check_envelope: false,
2057            ..ValidateOptions::incremental_update()
2058        };
2059        let messages = vec![
2060            json!({"updateComponents": {"surfaceId": "s", "components": [
2061                {"id": "root", "component": "Text", "text": "hi"}
2062            ]}}),
2063        ];
2064        let report = Validator::with_options(&catalog, options).validate_json_messages(&messages);
2065        assert!(report.is_valid(), "{:?}", report.errors);
2066    }
2067
2068    #[test]
2069    fn every_message_this_crate_emits_satisfies_its_own_envelope_check() {
2070        let catalog = basic();
2071        let messages = vec![
2072            AgentMessage::create_surface("s", "cat"),
2073            AgentMessage::update_components(
2074                "s",
2075                vec![Component::new(ROOT_ID, "Text").with("text", json!("hi"))],
2076            ),
2077            AgentMessage::update_data_model("s", "/user", json!({"name": "Ada"})),
2078            AgentMessage::delete_surface("s"),
2079        ];
2080        let report = Validator::new(&catalog).validate_messages(&messages);
2081        assert!(report.is_valid(), "{:?}", report.errors);
2082    }
2083
2084    #[test]
2085    fn validate_messages_picks_the_contract_from_the_stream() {
2086        let catalog = basic();
2087        let validator = Validator::new(&catalog);
2088
2089        let incremental = vec![AgentMessage::update_components(
2090            "s",
2091            vec![Component::new("c", "Card").with("child", json!("already-there"))],
2092        )];
2093        assert!(validator.validate_messages(&incremental).is_valid());
2094
2095        let full = vec![
2096            AgentMessage::create_surface("s", "cat"),
2097            AgentMessage::update_components(
2098                "s",
2099                vec![Component::new("c", "Card").with("child", json!("gone"))],
2100            ),
2101        ];
2102        let report = validator.validate_messages(&full);
2103        assert!(codes(&report).contains(&ErrorCode::NoRoot));
2104        assert!(codes(&report).contains(&ErrorCode::UnresolvedChild));
2105    }
2106
2107    #[test]
2108    fn validate_messages_replays_the_data_model() {
2109        let catalog = basic();
2110        let messages = vec![
2111            AgentMessage::create_surface("s", "cat"),
2112            AgentMessage::update_components(
2113                "s",
2114                vec![Component::new("root", "Text").with("text", json!({"path": "/user/name"}))],
2115            ),
2116            AgentMessage::update_data_model("s", "/user/name", json!("Ada")),
2117        ];
2118        assert!(
2119            Validator::new(&catalog)
2120                .validate_messages(&messages)
2121                .is_valid()
2122        );
2123
2124        let messages = vec![
2125            AgentMessage::create_surface("s", "cat"),
2126            AgentMessage::update_components(
2127                "s",
2128                vec![Component::new("root", "Text").with("text", json!({"path": "/user/name"}))],
2129            ),
2130            AgentMessage::update_data_model("s", "/user/other", json!("Ada")),
2131        ];
2132        let report = Validator::new(&catalog).validate_messages(&messages);
2133        assert!(codes(&report).contains(&ErrorCode::UnresolvedBinding));
2134    }
2135
2136    #[test]
2137    fn an_empty_catalog_skips_type_checks() {
2138        let catalog = Catalog::empty("none");
2139        let report = Validator::new(&catalog)
2140            .validate(&[Component::new("root", "Whatever").with("x", json!(1))]);
2141        assert!(report.is_valid(), "{:?}", report.errors);
2142    }
2143
2144    #[test]
2145    fn report_converts_into_a_result_carrying_every_error() {
2146        let catalog = basic();
2147        let report = Validator::new(&catalog).validate(&[]);
2148        let Err(Error::Validation { errors }) = report.into_result() else {
2149            panic!("expected a validation error");
2150        };
2151        assert_eq!(errors.len(), 1);
2152        assert!(errors.to_string().contains("empty_components"));
2153    }
2154}