1use std::collections::{BTreeMap, BTreeSet};
34
35use serde_json::Value;
36
37use crate::constants::{BASIC_CATALOG_ID, ROOT_ID, SURFACE_COMPONENT};
38use crate::error::{Error, Result};
39use crate::message::Component;
40
41pub const BASIC_COMPONENTS: [&str; 18] = [
43 "Text",
44 "Image",
45 "Icon",
46 "Video",
47 "AudioPlayer",
48 "Row",
49 "Column",
50 "List",
51 "Card",
52 "Tabs",
53 "Divider",
54 "Modal",
55 "Button",
56 "CheckBox",
57 "TextField",
58 "DateTimeInput",
59 "ChoicePicker",
60 "Slider",
61];
62
63pub const BASIC_FUNCTIONS: [&str; 14] = [
65 "required",
66 "regex",
67 "length",
68 "numeric",
69 "email",
70 "formatString",
71 "formatNumber",
72 "formatCurrency",
73 "formatDate",
74 "pluralize",
75 "openUrl",
76 "and",
77 "or",
78 "not",
79];
80
81pub const BASIC_ICON_NAMES: [&str; 59] = [
83 "accountCircle",
84 "add",
85 "arrowBack",
86 "arrowForward",
87 "attachFile",
88 "calendarToday",
89 "call",
90 "camera",
91 "check",
92 "close",
93 "delete",
94 "download",
95 "edit",
96 "event",
97 "error",
98 "fastForward",
99 "favorite",
100 "favoriteOff",
101 "folder",
102 "help",
103 "home",
104 "info",
105 "locationOn",
106 "lock",
107 "lockOpen",
108 "mail",
109 "menu",
110 "moreVert",
111 "moreHoriz",
112 "notificationsOff",
113 "notifications",
114 "pause",
115 "payment",
116 "person",
117 "phone",
118 "photo",
119 "play",
120 "print",
121 "refresh",
122 "rewind",
123 "search",
124 "send",
125 "settings",
126 "share",
127 "shoppingCart",
128 "skipNext",
129 "skipPrevious",
130 "star",
131 "starHalf",
132 "starOff",
133 "stop",
134 "upload",
135 "visibility",
136 "visibilityOff",
137 "volumeDown",
138 "volumeMute",
139 "volumeOff",
140 "volumeUp",
141 "warning",
142];
143
144pub const COMMON_PROPS: [&str; 2] = ["accessibility", "weight"];
151
152#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum PropKind {
155 Value,
157 ComponentRef,
159 ChildList,
161 ObjectListRefs {
165 ref_keys: Vec<String>,
167 },
168}
169
170impl PropKind {
171 pub fn is_structural(&self) -> bool {
173 !matches!(self, PropKind::Value)
174 }
175}
176
177#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
185pub enum PropType {
186 #[default]
188 Unconstrained,
189 String,
191 Number,
193 Integer,
195 Boolean,
197 Object,
199 Array,
201}
202
203impl PropType {
204 pub fn accepts(self, value: &Value) -> bool {
212 match self {
213 PropType::Unconstrained => true,
214 _ if value.is_null() => true,
215 PropType::String => value.is_string(),
216 PropType::Number => value.is_number(),
217 PropType::Integer => value.as_f64().is_some_and(|n| n.fract() == 0.0),
219 PropType::Boolean => value.is_boolean(),
220 PropType::Object => value.is_object(),
221 PropType::Array => value.is_array(),
222 }
223 }
224
225 pub fn describe(self) -> &'static str {
227 match self {
228 PropType::Unconstrained => "any value",
229 PropType::String => "a string",
230 PropType::Number => "a number",
231 PropType::Integer => "an integer",
232 PropType::Boolean => "a boolean",
233 PropType::Object => "an object",
234 PropType::Array => "an array",
235 }
236 }
237
238 fn from_schema_name(name: &str) -> Self {
240 match name {
241 "string" => PropType::String,
242 "number" => PropType::Number,
243 "integer" => PropType::Integer,
244 "boolean" => PropType::Boolean,
245 "object" => PropType::Object,
246 "array" => PropType::Array,
247 _ => PropType::Unconstrained,
250 }
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct PropDef {
257 pub name: String,
259 pub kind: PropKind,
261 pub value_type: PropType,
263 pub description: Option<String>,
265 pub enum_values: Vec<String>,
267 pub required: bool,
269}
270
271impl PropDef {
272 fn new(name: &str, kind: PropKind, required: bool) -> Self {
273 Self {
274 name: name.to_string(),
275 kind,
276 value_type: PropType::Unconstrained,
277 description: None,
278 enum_values: Vec::new(),
279 required,
280 }
281 }
282
283 fn typed(mut self, value_type: PropType) -> Self {
284 self.value_type = value_type;
285 self
286 }
287
288 fn described(mut self, description: &str) -> Self {
289 self.description = Some(description.to_string());
290 self
291 }
292
293 fn with_enum(mut self, values: &[&str]) -> Self {
294 self.enum_values = values.iter().map(|v| (*v).to_string()).collect();
295 self
296 }
297}
298
299#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct ComponentDef {
302 pub name: String,
304 pub description: Option<String>,
306 pub props: BTreeMap<String, PropDef>,
308 pub required: Vec<String>,
310 pub allowed_parents: Option<Vec<String>>,
316 pub allowed_children: Option<Vec<String>>,
318 prop_order: Vec<String>,
320}
321
322impl ComponentDef {
323 fn new(name: &str, description: &str, props: Vec<PropDef>) -> Self {
324 let required = props
325 .iter()
326 .filter(|p| p.required)
327 .map(|p| p.name.clone())
328 .collect();
329 let prop_order = props.iter().map(|p| p.name.clone()).collect();
330 let props = props.into_iter().map(|p| (p.name.clone(), p)).collect();
331 Self {
332 name: name.to_string(),
333 description: Some(description.to_string()),
334 props,
335 required,
336 allowed_parents: None,
337 allowed_children: None,
338 prop_order,
339 }
340 }
341
342 pub fn props_in_order(&self) -> impl Iterator<Item = &PropDef> {
344 self.prop_order
345 .iter()
346 .filter_map(|name| self.props.get(name))
347 }
348
349 pub fn references(&self, component: &Component) -> Vec<ComponentRef> {
354 let mut refs = Vec::new();
355 for (name, def) in &self.props {
356 let Some(value) = component.props.get(name) else {
357 continue;
358 };
359 match &def.kind {
360 PropKind::Value => {}
361 PropKind::ComponentRef => {
362 if let Value::String(id) = value {
363 refs.push(ComponentRef {
364 location: name.clone(),
365 id: id.clone(),
366 });
367 }
368 }
369 PropKind::ChildList => match value {
370 Value::Array(items) => {
371 for (index, item) in items.iter().enumerate() {
372 if let Value::String(id) = item {
373 refs.push(ComponentRef {
374 location: format!("{name}[{index}]"),
375 id: id.clone(),
376 });
377 }
378 }
379 }
380 Value::Object(map) => {
381 if let Some(Value::String(id)) = map.get("componentId") {
382 refs.push(ComponentRef {
383 location: format!("{name}.componentId"),
384 id: id.clone(),
385 });
386 }
387 }
388 _ => {}
389 },
390 PropKind::ObjectListRefs { ref_keys } => {
391 if let Value::Array(items) = value {
392 for (index, item) in items.iter().enumerate() {
393 let Value::Object(map) = item else { continue };
394 for key in ref_keys {
395 if let Some(Value::String(id)) = map.get(key) {
396 refs.push(ComponentRef {
397 location: format!("{name}[{index}].{key}"),
398 id: id.clone(),
399 });
400 }
401 }
402 }
403 }
404 }
405 }
406 }
407 refs.sort_by(|a, b| a.location.cmp(&b.location));
408 refs
409 }
410}
411
412#[derive(Debug, Clone, PartialEq, Eq)]
414pub struct ComponentRef {
415 pub location: String,
417 pub id: String,
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct FunctionDef {
424 pub name: String,
426 pub description: Option<String>,
428 pub return_type: Option<String>,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct Catalog {
435 pub catalog_id: String,
437 pub title: Option<String>,
439 pub description: Option<String>,
441 pub instructions: Option<String>,
443 pub components: BTreeMap<String, ComponentDef>,
445 pub functions: BTreeMap<String, FunctionDef>,
447 component_order: Vec<String>,
448}
449
450impl Catalog {
451 pub fn empty(catalog_id: impl Into<String>) -> Self {
453 Self {
454 catalog_id: catalog_id.into(),
455 title: None,
456 description: None,
457 instructions: None,
458 components: BTreeMap::new(),
459 functions: BTreeMap::new(),
460 component_order: Vec::new(),
461 }
462 }
463
464 pub fn component(&self, name: &str) -> Option<&ComponentDef> {
466 self.components.get(name)
467 }
468
469 pub fn has_component(&self, name: &str) -> bool {
471 self.components.contains_key(name)
472 }
473
474 pub fn components_in_order(&self) -> impl Iterator<Item = &ComponentDef> {
476 self.component_order
477 .iter()
478 .filter_map(|name| self.components.get(name))
479 }
480
481 pub fn insert_component(&mut self, def: ComponentDef) {
483 if !self.components.contains_key(&def.name) {
484 self.component_order.push(def.name.clone());
485 }
486 self.components.insert(def.name.clone(), def);
487 }
488
489 pub fn references(&self, component: &Component) -> Vec<ComponentRef> {
495 self.component(&component.component)
496 .map(|def| def.references(component))
497 .unwrap_or_default()
498 }
499
500 pub fn basic() -> Self {
502 let mut catalog = Self::empty(BASIC_CATALOG_ID);
503 catalog.title = Some("A2UI Basic Catalog".to_string());
504 catalog.description =
505 Some("The baseline set of A2UI components and client-side functions.".to_string());
506
507 for def in basic_component_defs() {
508 catalog.insert_component(def);
509 }
510 for (name, description, return_type) in BASIC_FUNCTION_DEFS {
511 catalog.functions.insert(
512 name.to_string(),
513 FunctionDef {
514 name: name.to_string(),
515 description: Some(description.to_string()),
516 return_type: Some(return_type.to_string()),
517 },
518 );
519 }
520 catalog
521 }
522
523 pub fn from_schema(schema: &Value) -> Result<Self> {
536 let root = schema
537 .as_object()
538 .ok_or_else(|| Error::catalog("catalog document must be a JSON object"))?;
539
540 let catalog_id = root
541 .get("catalogId")
542 .or_else(|| root.get("$id"))
543 .and_then(Value::as_str)
544 .ok_or_else(|| Error::catalog("catalog document is missing 'catalogId'"))?;
545
546 let mut catalog = Self::empty(catalog_id);
547 catalog.title = string_field(root.get("title"));
548 catalog.description = string_field(root.get("description"));
549 catalog.instructions = string_field(root.get("instructions"));
550
551 if let Some(Value::Object(components)) = root.get("components") {
552 for (name, component_schema) in components {
553 catalog.insert_component(component_def_from_schema(name, component_schema));
554 }
555 }
556 if let Some(Value::Object(functions)) = root.get("functions") {
557 for (name, function_schema) in functions {
558 catalog.functions.insert(
559 name.to_string(),
560 FunctionDef {
561 name: name.to_string(),
562 description: string_field(function_schema.get("description")),
563 return_type: string_field(function_schema.get("returnType")),
564 },
565 );
566 }
567 }
568 Ok(catalog)
569 }
570
571 pub fn composition_violations(&self, components: &[Component]) -> Vec<CompositionViolation> {
582 let types: BTreeMap<&str, &str> = components
583 .iter()
584 .map(|c| (c.id.as_str(), c.component.as_str()))
585 .collect();
586 let index: BTreeMap<&str, usize> = components
587 .iter()
588 .enumerate()
589 .map(|(i, c)| (c.id.as_str(), i))
590 .collect();
591 let mut out = Vec::new();
592
593 if let Some(root) = components.iter().find(|c| c.id == ROOT_ID) {
595 if let Some(def) = self.component(&root.component) {
596 if let Some(allowed) = &def.allowed_parents {
597 if !allowed.iter().any(|p| p == SURFACE_COMPONENT) {
598 let position = index.get(ROOT_ID).copied().unwrap_or(0);
599 out.push(CompositionViolation {
600 code: CompositionCode::UnallowedParent,
601 path: format!("components[{position}].component"),
602 message: format!(
603 "'{}' cannot be the root of a surface: allowedParents is [{}], \
604 which does not include '{SURFACE_COMPONENT}'.",
605 root.component,
606 allowed.join(", ")
607 ),
608 });
609 }
610 }
611 }
612 }
613
614 for (position, parent) in components.iter().enumerate() {
615 let Some(parent_def) = self.component(&parent.component) else {
616 continue;
617 };
618 for reference in parent_def.references(parent) {
619 let Some(child_type) = types.get(reference.id.as_str()) else {
620 continue;
621 };
622 if let Some(allowed) = &parent_def.allowed_children {
623 if !allowed.iter().any(|c| c == child_type) {
624 out.push(CompositionViolation {
625 code: CompositionCode::UnallowedChild,
626 path: format!("components[{position}].{}", reference.location),
627 message: format!(
628 "'{}' cannot contain '{child_type}': allowedChildren is [{}].",
629 parent.component,
630 allowed.join(", ")
631 ),
632 });
633 }
634 }
635 if let Some(child_def) = self.component(child_type) {
636 if let Some(allowed) = &child_def.allowed_parents {
637 if !allowed.iter().any(|p| p == &parent.component) {
638 out.push(CompositionViolation {
639 code: CompositionCode::UnallowedParent,
640 path: format!("components[{position}].{}", reference.location),
641 message: format!(
642 "'{child_type}' cannot sit under '{}': allowedParents is [{}].",
643 parent.component,
644 allowed.join(", ")
645 ),
646 });
647 }
648 }
649 }
650 }
651 }
652 out
653 }
654
655 #[cfg_attr(
661 feature = "toolkit",
662 doc = "[`SchemaBundle::render_llm_instructions`](crate::toolkit::schema::SchemaBundle::render_llm_instructions)."
663 )]
664 #[cfg_attr(
665 not(feature = "toolkit"),
666 doc = "`SchemaBundle::render_llm_instructions`, behind the `toolkit` feature."
667 )]
668 pub fn render_summary(&self) -> String {
669 let mut out = String::new();
670 out.push_str("### Component catalog\n");
671 out.push_str(&format!("catalogId: {}\n", self.catalog_id));
672 if let Some(instructions) = &self.instructions {
673 out.push_str(instructions);
674 out.push('\n');
675 }
676 out.push_str(
677 "Every component object is `{\"id\": <unique id>, \"component\": <type>, ...props}`. \
678 Children are referenced by id; components are never nested inline.\n\n",
679 );
680 for def in self.components_in_order() {
681 out.push_str(&format!("- {}", def.name));
682 let required: Vec<String> = def
683 .props_in_order()
684 .filter(|p| p.required)
685 .map(describe_prop)
686 .collect();
687 let optional: Vec<String> = def
688 .props_in_order()
689 .filter(|p| !p.required)
690 .map(describe_prop)
691 .collect();
692 if !required.is_empty() {
693 out.push_str(&format!(" required: {}", required.join(", ")));
694 }
695 if !optional.is_empty() {
696 out.push_str(&format!("; optional: {}", optional.join(", ")));
697 }
698 if let Some(allowed) = &def.allowed_parents {
699 out.push_str(&format!("; allowedParents: [{}]", allowed.join(", ")));
700 }
701 if let Some(allowed) = &def.allowed_children {
702 out.push_str(&format!("; allowedChildren: [{}]", allowed.join(", ")));
703 }
704 out.push('\n');
705 }
706 if !self.functions.is_empty() {
707 let names: Vec<&str> = self.functions.keys().map(String::as_str).collect();
708 out.push_str(&format!("\nFunctions: {}\n", names.join(", ")));
709 }
710 out
711 }
712}
713
714fn describe_prop(prop: &PropDef) -> String {
715 let kind = match &prop.kind {
716 PropKind::Value if !prop.enum_values.is_empty() => {
717 format!("one of [{}]", prop.enum_values.join("|"))
718 }
719 PropKind::Value => "value".to_string(),
720 PropKind::ComponentRef => "component id".to_string(),
721 PropKind::ChildList => "component ids or {componentId, path}".to_string(),
722 PropKind::ObjectListRefs { ref_keys } => {
723 format!(
724 "array of objects with component id in {}",
725 ref_keys.join("/")
726 )
727 }
728 };
729 format!("{} ({kind})", prop.name)
730}
731
732#[derive(Debug, Clone, PartialEq, Eq)]
734pub struct CompositionViolation {
735 pub code: CompositionCode,
737 pub path: String,
739 pub message: String,
741}
742
743#[derive(Debug, Clone, Copy, PartialEq, Eq)]
745pub enum CompositionCode {
746 UnallowedParent,
748 UnallowedChild,
750}
751
752impl CompositionCode {
753 pub fn as_str(self) -> &'static str {
755 match self {
756 CompositionCode::UnallowedParent => "UNALLOWED_PARENT",
757 CompositionCode::UnallowedChild => "UNALLOWED_CHILD",
758 }
759 }
760}
761
762impl std::fmt::Display for CompositionCode {
763 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
764 f.write_str(self.as_str())
765 }
766}
767
768fn string_field(value: Option<&Value>) -> Option<String> {
769 value.and_then(Value::as_str).map(str::to_string)
770}
771
772fn component_def_from_schema(name: &str, schema: &Value) -> ComponentDef {
774 let mut props: BTreeMap<String, PropDef> = BTreeMap::new();
775 let mut prop_order: Vec<String> = Vec::new();
776 let mut required: BTreeSet<String> = BTreeSet::new();
777 let mut required_order: Vec<String> = Vec::new();
778 let mut description = None;
779 let mut allowed_parents = None;
780 let mut allowed_children = None;
781
782 let mut stack = vec![schema];
783 while let Some(current) = stack.pop() {
784 let Some(object) = current.as_object() else {
785 continue;
786 };
787 if description.is_none() {
788 description = string_field(object.get("description"));
789 }
790 if allowed_parents.is_none() {
791 allowed_parents = string_list(object.get("allowedParents"));
792 }
793 if allowed_children.is_none() {
794 allowed_children = string_list(object.get("allowedChildren"));
795 }
796 if let Some(Value::Array(members)) = object.get("allOf") {
797 for member in members.iter().rev() {
799 stack.push(member);
800 }
801 }
802 if let Some(Value::Array(names)) = object.get("required") {
803 for entry in names {
804 if let Some(field) = entry.as_str() {
805 if field != "component" && required.insert(field.to_string()) {
806 required_order.push(field.to_string());
807 }
808 }
809 }
810 }
811 if let Some(Value::Object(properties)) = object.get("properties") {
812 for (prop_name, prop_schema) in properties {
813 if prop_name == "component" || prop_name == "id" {
814 continue;
815 }
816 if !props.contains_key(prop_name) {
817 prop_order.push(prop_name.clone());
818 }
819 props.insert(
820 prop_name.clone(),
821 PropDef {
822 name: prop_name.clone(),
823 kind: prop_kind_from_schema(prop_schema),
824 value_type: prop_type_from_schema(prop_schema),
825 description: string_field(prop_schema.get("description")),
826 enum_values: string_list(prop_schema.get("enum")).unwrap_or_default(),
827 required: false,
828 },
829 );
830 }
831 }
832 }
833
834 for field in &required_order {
835 if let Some(prop) = props.get_mut(field) {
836 prop.required = true;
837 } else {
838 prop_order.push(field.clone());
841 props.insert(field.clone(), PropDef::new(field, PropKind::Value, true));
842 }
843 }
844
845 ComponentDef {
846 name: name.to_string(),
847 description,
848 props,
849 required: required_order,
850 allowed_parents,
851 allowed_children,
852 prop_order,
853 }
854}
855
856fn string_list(value: Option<&Value>) -> Option<Vec<String>> {
857 let Some(Value::Array(items)) = value else {
858 return None;
859 };
860 Some(
861 items
862 .iter()
863 .filter_map(Value::as_str)
864 .map(str::to_string)
865 .collect(),
866 )
867}
868
869fn prop_kind_from_schema(schema: &Value) -> PropKind {
871 if let Some(kind) = ref_kind(schema.get("$ref")) {
872 return kind;
873 }
874 for key in ["allOf", "oneOf", "anyOf"] {
876 if let Some(Value::Array(members)) = schema.get(key) {
877 for member in members {
878 let kind = prop_kind_from_schema(member);
879 if kind.is_structural() {
880 return kind;
881 }
882 }
883 }
884 }
885 if schema.get("type").and_then(Value::as_str) == Some("array") {
886 if let Some(items) = schema.get("items") {
887 if let Some(kind) = ref_kind(items.get("$ref")) {
888 return match kind {
890 PropKind::ComponentRef => PropKind::ChildList,
891 other => other,
892 };
893 }
894 if let Some(Value::Object(item_props)) = items.get("properties") {
895 let ref_keys: Vec<String> = item_props
896 .iter()
897 .filter(|(_, value)| prop_kind_from_schema(value).is_structural())
898 .map(|(key, _)| key.clone())
899 .collect();
900 if !ref_keys.is_empty() {
901 return PropKind::ObjectListRefs { ref_keys };
902 }
903 }
904 }
905 }
906 PropKind::Value
907}
908
909fn ref_kind(reference: Option<&Value>) -> Option<PropKind> {
910 let reference = reference?.as_str()?;
911 let target = reference.rsplit('/').next()?;
912 match target {
913 "ComponentId" => Some(PropKind::ComponentRef),
914 "ChildList" => Some(PropKind::ChildList),
915 _ => None,
916 }
917}
918
919fn prop_type_from_schema(schema: &Value) -> PropType {
926 if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
927 return ref_type(reference);
928 }
929 if let Some(name) = schema.get("type").and_then(Value::as_str) {
930 return PropType::from_schema_name(name);
931 }
932 if let Some(Value::Array(members)) = schema.get("allOf") {
937 if let Some(found) = members
938 .iter()
939 .map(prop_type_from_schema)
940 .find(|found| *found != PropType::Unconstrained)
941 {
942 return found;
943 }
944 }
945 for key in ["oneOf", "anyOf"] {
946 let Some(Value::Array(members)) = schema.get(key) else {
947 continue;
948 };
949 let mut arms = members.iter().map(prop_type_from_schema);
950 if let Some(first) = arms.next() {
951 if first != PropType::Unconstrained && arms.all(|arm| arm == first) {
952 return first;
953 }
954 }
955 }
956 PropType::Unconstrained
957}
958
959fn ref_type(reference: &str) -> PropType {
961 match reference.rsplit('/').next().unwrap_or_default() {
962 "ComponentId" | "DynamicString" => PropType::String,
963 "DynamicNumber" => PropType::Number,
964 "DynamicBoolean" => PropType::Boolean,
965 "DynamicStringList" => PropType::Array,
966 "Action" | "DataBinding" | "AccessibilityAttributes" => PropType::Object,
967 _ => PropType::Unconstrained,
970 }
971}
972
973const BASIC_FUNCTION_DEFS: [(&str, &str, &str); 14] = [
974 (
975 "required",
976 "Checks that the value is not null, undefined, or empty.",
977 "boolean",
978 ),
979 (
980 "regex",
981 "Checks that the value matches a regular expression string.",
982 "boolean",
983 ),
984 ("length", "Checks string length constraints.", "boolean"),
985 ("numeric", "Checks numeric range constraints.", "boolean"),
986 (
987 "email",
988 "Checks that the value is a valid email address.",
989 "boolean",
990 ),
991 (
992 "formatString",
993 "Interpolates data model values and function results into a string.",
994 "string",
995 ),
996 (
997 "formatNumber",
998 "Formats a number with grouping and precision.",
999 "string",
1000 ),
1001 (
1002 "formatCurrency",
1003 "Formats a number as a currency string.",
1004 "string",
1005 ),
1006 (
1007 "formatDate",
1008 "Formats a date/time using a pattern.",
1009 "string",
1010 ),
1011 (
1012 "pluralize",
1013 "Selects a localized string based on a numeric count.",
1014 "string",
1015 ),
1016 ("openUrl", "Opens a URL in a browser.", "void"),
1017 (
1018 "and",
1019 "Logical AND over a list of boolean values.",
1020 "boolean",
1021 ),
1022 ("or", "Logical OR over a list of boolean values.", "boolean"),
1023 ("not", "Logical NOT of a boolean value.", "boolean"),
1024];
1025
1026fn basic_component_defs() -> Vec<ComponentDef> {
1033 use PropKind::{ChildList, ComponentRef, ObjectListRefs, Value as Val};
1034 use PropType::{Array as Arr, Boolean as Bool, Number as Num, Object as Obj, String as Str};
1035
1036 let align = ["start", "center", "end", "stretch"];
1037 let justify = [
1038 "start",
1039 "center",
1040 "end",
1041 "spaceBetween",
1042 "spaceAround",
1043 "spaceEvenly",
1044 "stretch",
1045 ];
1046 let checks = || {
1047 PropDef::new("checks", Val, false)
1048 .described("Function calls that must return true for this component to be valid.")
1049 };
1050
1051 vec![
1052 ComponentDef::new(
1053 "Text",
1054 "Displays text. Supports simple Markdown.",
1055 vec![
1056 PropDef::new("text", Val, true)
1057 .typed(Str)
1058 .described("The text content to display."),
1059 PropDef::new("variant", Val, false)
1060 .typed(Str)
1061 .described("A hint for the base text style.")
1062 .with_enum(&["h1", "h2", "h3", "h4", "h5", "caption", "body"]),
1063 ],
1064 ),
1065 ComponentDef::new(
1066 "Image",
1067 "Displays an image from a URL.",
1068 vec![
1069 PropDef::new("url", Val, true)
1070 .typed(Str)
1071 .described("The image URL."),
1072 PropDef::new("description", Val, false)
1073 .typed(Str)
1074 .described("Alternative text describing the image."),
1075 PropDef::new("fit", Val, false).typed(Str).with_enum(&[
1076 "contain",
1077 "cover",
1078 "fill",
1079 "none",
1080 "scaleDown",
1081 ]),
1082 PropDef::new("variant", Val, false).typed(Str).with_enum(&[
1083 "icon",
1084 "avatar",
1085 "smallFeature",
1086 "mediumFeature",
1087 "largeFeature",
1088 "header",
1089 ]),
1090 ],
1091 ),
1092 ComponentDef::new(
1093 "Icon",
1094 "Displays a system-provided icon from a predefined list.",
1095 vec![
1096 PropDef::new("name", Val, true)
1097 .described("The icon name, an {svgPath} object, or a data binding.")
1098 .with_enum(&BASIC_ICON_NAMES),
1099 ],
1100 ),
1101 ComponentDef::new(
1102 "Video",
1103 "Displays a video from a URL.",
1104 vec![
1105 PropDef::new("url", Val, true)
1106 .typed(Str)
1107 .described("The video URL."),
1108 ],
1109 ),
1110 ComponentDef::new(
1111 "AudioPlayer",
1112 "A player for audio content from a URL.",
1113 vec![
1114 PropDef::new("url", Val, true)
1115 .typed(Str)
1116 .described("The audio URL."),
1117 PropDef::new("description", Val, false)
1118 .typed(Str)
1119 .described("Text describing the audio content."),
1120 ],
1121 ),
1122 ComponentDef::new(
1123 "Row",
1124 "A layout container that arranges its children horizontally.",
1125 vec![
1126 PropDef::new("children", ChildList, true),
1127 PropDef::new("justify", Val, false)
1128 .typed(Str)
1129 .described("Arrangement along the main (horizontal) axis.")
1130 .with_enum(&justify),
1131 PropDef::new("align", Val, false)
1132 .typed(Str)
1133 .described("Alignment along the cross (vertical) axis.")
1134 .with_enum(&align),
1135 ],
1136 ),
1137 ComponentDef::new(
1138 "Column",
1139 "A layout container that arranges its children vertically.",
1140 vec![
1141 PropDef::new("children", ChildList, true),
1142 PropDef::new("justify", Val, false)
1143 .typed(Str)
1144 .with_enum(&justify),
1145 PropDef::new("align", Val, false)
1146 .typed(Str)
1147 .with_enum(&align),
1148 ],
1149 ),
1150 ComponentDef::new(
1151 "List",
1152 "A scrollable list of components.",
1153 vec![
1154 PropDef::new("children", ChildList, true),
1155 PropDef::new("direction", Val, false)
1156 .typed(Str)
1157 .with_enum(&["vertical", "horizontal"]),
1158 PropDef::new("align", Val, false)
1159 .typed(Str)
1160 .with_enum(&align),
1161 ],
1162 ),
1163 ComponentDef::new(
1164 "Card",
1165 "A container with card-like styling.",
1166 vec![
1167 PropDef::new("child", ComponentRef, true)
1168 .typed(Str)
1169 .described(
1170 "The single child to render inside the card. Wrap multiple elements in a \
1171 Row or Column and pass that container's id.",
1172 ),
1173 ],
1174 ),
1175 ComponentDef::new(
1176 "Tabs",
1177 "A set of tabs, each with a title and a child component.",
1178 vec![
1179 PropDef::new(
1180 "tabs",
1181 ObjectListRefs {
1182 ref_keys: vec!["child".to_string()],
1183 },
1184 true,
1185 )
1186 .typed(Arr)
1187 .described("Array of {title, child} objects."),
1188 ],
1189 ),
1190 ComponentDef::new(
1191 "Divider",
1192 "A horizontal or vertical dividing line.",
1193 vec![
1194 PropDef::new("axis", Val, false)
1195 .typed(Str)
1196 .with_enum(&["horizontal", "vertical"]),
1197 ],
1198 ),
1199 ComponentDef::new(
1200 "Modal",
1201 "A dialog shown over the main content, opened by a trigger component.",
1202 vec![
1203 PropDef::new("trigger", ComponentRef, true)
1204 .typed(Str)
1205 .described("The component that opens the modal."),
1206 PropDef::new("content", ComponentRef, true)
1207 .typed(Str)
1208 .described("The component shown inside the modal."),
1209 ],
1210 ),
1211 ComponentDef::new(
1212 "Button",
1213 "A clickable button that dispatches an action.",
1214 vec![
1215 PropDef::new("child", ComponentRef, true)
1216 .typed(Str)
1217 .described("The button's label component, usually a Text."),
1218 PropDef::new("action", Val, true)
1219 .typed(Obj)
1220 .described("An {event} sent to the agent, or a local {functionCall}."),
1221 PropDef::new("variant", Val, false).typed(Str).with_enum(&[
1222 "default",
1223 "primary",
1224 "borderless",
1225 ]),
1226 checks(),
1227 ],
1228 ),
1229 ComponentDef::new(
1230 "CheckBox",
1231 "A checkbox with a label and a boolean value.",
1232 vec![
1233 PropDef::new("label", Val, true).typed(Str),
1234 PropDef::new("value", Val, true)
1235 .typed(Bool)
1236 .described("Two-way bound boolean, usually {\"path\": ...}."),
1237 checks(),
1238 ],
1239 ),
1240 ComponentDef::new(
1241 "TextField",
1242 "A field for user text input.",
1243 vec![
1244 PropDef::new("label", Val, true).typed(Str),
1245 PropDef::new("value", Val, false)
1246 .typed(Str)
1247 .described("Two-way bound string, usually {\"path\": ...}."),
1248 PropDef::new("variant", Val, false).typed(Str).with_enum(&[
1249 "shortText",
1250 "longText",
1251 "number",
1252 "obscured",
1253 ]),
1254 PropDef::new("validationRegexp", Val, false).typed(Str),
1255 checks(),
1256 ],
1257 ),
1258 ComponentDef::new(
1259 "DateTimeInput",
1260 "An input for a date and/or a time.",
1261 vec![
1262 PropDef::new("value", Val, true)
1263 .typed(Str)
1264 .described("ISO 8601 value, two-way bound."),
1265 PropDef::new("label", Val, false).typed(Str),
1266 PropDef::new("enableDate", Val, false).typed(Bool),
1267 PropDef::new("enableTime", Val, false).typed(Bool),
1268 PropDef::new("min", Val, false).typed(Str),
1272 PropDef::new("max", Val, false).typed(Str),
1273 checks(),
1274 ],
1275 ),
1276 ComponentDef::new(
1277 "ChoicePicker",
1278 "Selects one or more options from a list.",
1279 vec![
1280 PropDef::new("options", Val, true)
1281 .typed(Arr)
1282 .described("Array of {label, value} options."),
1283 PropDef::new("value", Val, true)
1284 .typed(Arr)
1285 .described("Selected values as a string array, two-way bound."),
1286 PropDef::new("label", Val, false).typed(Str),
1287 PropDef::new("variant", Val, false)
1288 .typed(Str)
1289 .with_enum(&["mutuallyExclusive", "multipleSelection"]),
1290 PropDef::new("displayStyle", Val, false)
1291 .typed(Str)
1292 .with_enum(&["checkbox", "chips"]),
1293 PropDef::new("filterable", Val, false).typed(Bool),
1294 checks(),
1295 ],
1296 ),
1297 ComponentDef::new(
1298 "Slider",
1299 "A slider for selecting a numeric value within a range.",
1300 vec![
1301 PropDef::new("value", Val, true)
1302 .typed(Num)
1303 .described("Two-way bound number."),
1304 PropDef::new("max", Val, true).typed(Num),
1305 PropDef::new("min", Val, false).typed(Num),
1306 PropDef::new("label", Val, false).typed(Str),
1307 checks(),
1308 ],
1309 ),
1310 ]
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315 use super::*;
1316 use serde_json::json;
1317
1318 #[test]
1319 fn basic_catalog_has_the_eighteen_components_and_fourteen_functions() {
1320 let catalog = Catalog::basic();
1321 assert_eq!(catalog.components.len(), 18);
1322 assert_eq!(catalog.functions.len(), 14);
1323 for name in BASIC_COMPONENTS {
1324 assert!(catalog.has_component(name), "missing {name}");
1325 }
1326 let order: Vec<&str> = catalog
1327 .components_in_order()
1328 .map(|d| d.name.as_str())
1329 .collect();
1330 assert_eq!(order, BASIC_COMPONENTS.to_vec());
1331 }
1332
1333 #[test]
1334 fn structural_props_are_marked_and_value_props_are_not() {
1335 let catalog = Catalog::basic();
1336 assert_eq!(
1337 catalog.component("Card").unwrap().props["child"].kind,
1338 PropKind::ComponentRef
1339 );
1340 assert_eq!(
1341 catalog.component("Row").unwrap().props["children"].kind,
1342 PropKind::ChildList
1343 );
1344 assert_eq!(
1345 catalog.component("Text").unwrap().props["text"].kind,
1346 PropKind::Value
1347 );
1348 assert!(
1350 !catalog.component("Image").unwrap().props["url"]
1351 .kind
1352 .is_structural()
1353 );
1354 }
1355
1356 #[test]
1357 fn references_cover_ids_lists_templates_and_nested_objects() {
1358 let catalog = Catalog::basic();
1359
1360 let card = Component::new("c", "Card").with("child", json!("inner"));
1361 assert_eq!(
1362 catalog.references(&card),
1363 vec![ComponentRef {
1364 location: "child".into(),
1365 id: "inner".into()
1366 }]
1367 );
1368
1369 let row = Component::new("r", "Row").with("children", json!(["a", "b"]));
1370 let ids: Vec<String> = catalog.references(&row).into_iter().map(|r| r.id).collect();
1371 assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
1372
1373 let list = Component::new("l", "List")
1374 .with("children", json!({"componentId": "tpl", "path": "/items"}));
1375 assert_eq!(
1376 catalog.references(&list),
1377 vec![ComponentRef {
1378 location: "children.componentId".into(),
1379 id: "tpl".into()
1380 }]
1381 );
1382
1383 let tabs = Component::new("t", "Tabs").with(
1384 "tabs",
1385 json!([{"title": "One", "child": "p1"}, {"title": "Two", "child": "p2"}]),
1386 );
1387 let locations: Vec<String> = catalog
1388 .references(&tabs)
1389 .into_iter()
1390 .map(|r| r.location)
1391 .collect();
1392 assert_eq!(
1393 locations,
1394 vec!["tabs[0].child".to_string(), "tabs[1].child".to_string()]
1395 );
1396 }
1397
1398 #[test]
1399 fn unknown_component_types_yield_no_references() {
1400 let catalog = Catalog::basic();
1401 let mystery = Component::new("m", "Sparkline").with("child", json!("x"));
1402 assert!(catalog.references(&mystery).is_empty());
1403 }
1404
1405 #[test]
1406 fn from_schema_merges_all_of_and_detects_ref_kinds() {
1407 let schema = json!({
1408 "catalogId": "test",
1409 "components": {
1410 "Panel": {
1411 "type": "object",
1412 "allOf": [
1413 {"$ref": "common_types.json#/$defs/ComponentCommon"},
1414 {
1415 "type": "object",
1416 "properties": {
1417 "component": {"const": "Panel"},
1418 "children": {"$ref": "common_types.json#/$defs/ChildList"},
1419 "header": {"$ref": "common_types.json#/$defs/ComponentId"},
1420 "title": {"type": "string"}
1421 },
1422 "required": ["component", "children"]
1423 }
1424 ]
1425 }
1426 },
1427 "functions": {"now": {"returnType": "string"}}
1428 });
1429 let catalog = Catalog::from_schema(&schema).unwrap();
1430 let panel = catalog.component("Panel").unwrap();
1431 assert_eq!(panel.props["children"].kind, PropKind::ChildList);
1432 assert_eq!(panel.props["header"].kind, PropKind::ComponentRef);
1433 assert_eq!(panel.props["title"].kind, PropKind::Value);
1434 assert_eq!(panel.required, vec!["children"]);
1435 assert_eq!(
1436 catalog.functions["now"].return_type.as_deref(),
1437 Some("string")
1438 );
1439 }
1440
1441 #[test]
1442 fn from_schema_reads_the_type_each_property_declares() {
1443 let schema = json!({
1444 "catalogId": "test",
1445 "components": {
1446 "Chart": {
1447 "type": "object",
1448 "allOf": [
1449 {
1450 "type": "object",
1451 "properties": {
1452 "columns": {"type": "integer"},
1453 "ratio": {"type": "number"},
1454 "stacked": {"type": "boolean"},
1455 "series": {"type": "array"},
1456 "legend": {"type": "object"}
1457 }
1458 },
1459 {
1460 "type": "object",
1461 "properties": {
1462 "title": {"$ref": "common_types.json#/$defs/DynamicString"},
1463 "caption": {"anyOf": [{"type": "string"}, {"type": "number"}]},
1464 "anything": {"description": "no type at all"}
1465 }
1466 }
1467 ]
1468 }
1469 }
1470 });
1471 let catalog = Catalog::from_schema(&schema).unwrap();
1472 let chart = catalog.component("Chart").unwrap();
1473 let type_of = |name: &str| chart.props[name].value_type;
1474 assert_eq!(type_of("columns"), PropType::Integer);
1475 assert_eq!(type_of("ratio"), PropType::Number);
1476 assert_eq!(type_of("stacked"), PropType::Boolean);
1477 assert_eq!(type_of("series"), PropType::Array);
1478 assert_eq!(type_of("legend"), PropType::Object);
1479 assert_eq!(type_of("title"), PropType::String);
1482 assert_eq!(type_of("caption"), PropType::Unconstrained);
1485 assert_eq!(type_of("anything"), PropType::Unconstrained);
1486 }
1487
1488 #[test]
1489 fn the_built_in_basic_catalog_types_match_the_vendored_specification_document() {
1490 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1495 .join("tests/spec_v0_9/basic_catalog.json");
1496 let document: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap())
1497 .expect("the vendored basic catalog is JSON");
1498 let parsed = Catalog::from_schema(&document).unwrap();
1499 let built_in = Catalog::basic();
1500
1501 let mut compared = 0;
1502 for def in built_in.components_in_order() {
1503 let from_spec = parsed
1504 .component(&def.name)
1505 .unwrap_or_else(|| panic!("the specification catalog has no '{}'", def.name));
1506 for prop in def.props_in_order() {
1507 let Some(spec_prop) = from_spec.props.get(&prop.name) else {
1510 continue;
1511 };
1512 assert_eq!(
1513 prop.value_type, spec_prop.value_type,
1514 "{}.{} is {:?} here and {:?} in the specification",
1515 def.name, prop.name, prop.value_type, spec_prop.value_type
1516 );
1517 compared += 1;
1518 }
1519 }
1520 assert!(compared > 40, "only {compared} properties were compared");
1521 }
1522
1523 #[test]
1524 fn from_schema_treats_bare_strings_as_data_not_links() {
1525 let schema = json!({
1527 "catalogId": "loose",
1528 "components": {
1529 "Card": {"type": "object", "properties": {"child": {"type": "string"}}}
1530 }
1531 });
1532 let catalog = Catalog::from_schema(&schema).unwrap();
1533 assert_eq!(
1534 catalog.component("Card").unwrap().props["child"].kind,
1535 PropKind::Value
1536 );
1537 let card = Component::new("root", "Card").with("child", json!("missing"));
1538 assert!(catalog.references(&card).is_empty());
1539 }
1540
1541 #[test]
1542 fn from_schema_requires_a_catalog_id() {
1543 assert!(Catalog::from_schema(&json!({"components": {}})).is_err());
1544 assert!(Catalog::from_schema(&json!("nope")).is_err());
1545 }
1546
1547 #[test]
1548 fn composition_constraints_flag_bad_parents_and_children() {
1549 let schema = json!({
1550 "catalogId": "menu",
1551 "components": {
1552 "AppLayout": {
1553 "type": "object",
1554 "allowedParents": ["Surface"],
1555 "properties": {"child": {"$ref": "#/$defs/ComponentId"}}
1556 },
1557 "Menu": {
1558 "type": "object",
1559 "allowedChildren": ["MenuItem"],
1560 "properties": {"children": {"$ref": "#/$defs/ChildList"}}
1561 },
1562 "MenuItem": {"type": "object", "allowedParents": ["Menu"]},
1563 "Text": {"type": "object"}
1564 }
1565 });
1566 let catalog = Catalog::from_schema(&schema).unwrap();
1567
1568 let good = vec![
1569 Component::new(ROOT_ID, "AppLayout").with("child", json!("m")),
1570 Component::new("m", "Menu").with("children", json!(["i"])),
1571 Component::new("i", "MenuItem"),
1572 ];
1573 assert!(catalog.composition_violations(&good).is_empty());
1574
1575 let bad = vec![
1576 Component::new(ROOT_ID, "Menu").with("children", json!(["t"])),
1577 Component::new("t", "Text"),
1578 ];
1579 let violations = catalog.composition_violations(&bad);
1580 assert_eq!(violations.len(), 1);
1581 assert_eq!(violations[0].code, CompositionCode::UnallowedChild);
1582 assert_eq!(violations[0].path, "components[0].children[0]");
1583
1584 let misplaced = vec![
1585 Component::new(ROOT_ID, "Menu").with("children", json!(["a"])),
1586 Component::new("a", "AppLayout"),
1587 ];
1588 let violations = catalog.composition_violations(&misplaced);
1589 assert!(
1590 violations
1591 .iter()
1592 .any(|v| v.code == CompositionCode::UnallowedParent)
1593 );
1594 }
1595
1596 #[test]
1597 fn root_must_satisfy_its_own_allowed_parents() {
1598 let schema = json!({
1599 "catalogId": "menu",
1600 "components": {"MenuItem": {"type": "object", "allowedParents": ["Menu"]}}
1601 });
1602 let catalog = Catalog::from_schema(&schema).unwrap();
1603 let violations = catalog.composition_violations(&[Component::new(ROOT_ID, "MenuItem")]);
1604 assert_eq!(violations.len(), 1);
1605 assert_eq!(violations[0].code, CompositionCode::UnallowedParent);
1606 assert_eq!(violations[0].path, "components[0].component");
1607 }
1608
1609 #[test]
1610 fn basic_catalog_declares_no_composition_constraints() {
1611 let catalog = Catalog::basic();
1612 let components = vec![
1613 Component::new(ROOT_ID, "Card").with("child", json!("t")),
1614 Component::new("t", "Text").with("text", json!("hi")),
1615 ];
1616 assert!(catalog.composition_violations(&components).is_empty());
1617 }
1618
1619 #[test]
1620 fn the_summary_mentions_every_component() {
1621 let rendered = Catalog::basic().render_summary();
1622 for name in BASIC_COMPONENTS {
1623 assert!(rendered.contains(name), "instructions omit {name}");
1624 }
1625 assert!(rendered.contains("children (component ids or {componentId, path})"));
1626 }
1627}