1use 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
56pub const MAX_DEPTH: usize = 50;
64
65pub const MAX_FUNCTION_CALL_DEPTH: usize = 5;
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76#[non_exhaustive]
77pub enum ErrorCode {
78 EmptyComponents,
80 MissingId,
82 MissingComponentType,
84 DuplicateId,
86 NoRoot,
88 UnknownComponent,
90 MissingRequiredProp,
92 MissingField,
98 InvalidValue,
101 TypeMismatch,
104 UnresolvedChild,
106 ChildCycle,
108 UnresolvedBinding,
110 MaxDepthExceeded,
118}
119
120impl ErrorCode {
121 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct ValidationError {
154 pub code: ErrorCode,
156 pub path: String,
158 pub message: String,
160}
161
162impl ValidationError {
163 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#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct ValidateOptions {
182 pub root_id: String,
184 pub require_root: bool,
186 pub allow_dangling_children: bool,
188 pub check_component_types: bool,
193 pub check_required_props: bool,
195 pub check_prop_types: bool,
202 pub check_envelope: bool,
209 pub check_bindings: bool,
212 pub check_binding_syntax: bool,
218 pub max_depth: usize,
224 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 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 pub fn incremental_update() -> Self {
259 Self {
260 require_root: false,
261 allow_dangling_children: true,
262 ..Self::full_surface()
263 }
264 }
265
266 #[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 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
283pub struct ValidationReport {
284 pub errors: Vec<ValidationError>,
286 pub unreachable: Vec<String>,
293}
294
295impl ValidationReport {
296 pub fn is_valid(&self) -> bool {
298 self.errors.is_empty()
299 }
300
301 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 pub fn errors(&self) -> ValidationErrors {
318 ValidationErrors(self.errors.clone())
319 }
320}
321
322#[derive(Clone, Debug)]
324pub struct Validator<'a> {
325 catalog: &'a Catalog,
326 options: ValidateOptions,
327}
328
329struct Node<'a> {
331 index: usize,
332 id: Option<&'a str>,
333 kind: Option<&'a str>,
334 props: Option<&'a Map<String, Value>>,
335 borrowed: Option<&'a Component>,
337 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 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 pub fn new(catalog: &'a Catalog) -> Self {
404 Self {
405 catalog,
406 options: ValidateOptions::full_surface(),
407 }
408 }
409
410 pub fn incremental(catalog: &'a Catalog) -> Self {
412 Self {
413 catalog,
414 options: ValidateOptions::incremental_update(),
415 }
416 }
417
418 pub fn with_options(catalog: &'a Catalog, options: ValidateOptions) -> Self {
420 Self { catalog, options }
421 }
422
423 pub fn validate(&self, components: &[Component]) -> ValidationReport {
425 self.validate_surface(components, None)
426 }
427
428 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 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 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 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 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 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 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 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 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 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 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 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 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 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 color[edge.target] = BLACK;
847 }
848 WHITE => {
849 color[edge.target] = GRAY;
850 stack.push((edge.target, 0));
851 }
852 GRAY => {
853 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 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 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 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 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 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
1012fn is_valid_pointer(path: &str) -> bool {
1014 crate::binding::pointer_is_valid(path)
1015}
1016
1017fn 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
1050enum CollectionScope<'a> {
1052 Resolved(Scope<'a>),
1054 Unresolvable,
1057}
1058
1059fn 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 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 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 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; }
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
1125fn 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
1137pub(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
1183fn 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
1272fn 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 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 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
1368fn 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 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 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 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 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 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 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 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 ..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 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 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 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 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}