Skip to main content

ag_ui_a2ui/
catalog.rs

1//! Component catalogs: what a surface is allowed to contain.
2//!
3//! A catalog names the component types and functions a surface may use, the
4//! properties each component takes, which of those properties are structural
5//! (they hold component ids rather than data), and any composition constraints.
6//! Agent and renderer must agree on one, identified by an opaque `catalogId`.
7//!
8//! Two ways in:
9//!
10//! - [`Catalog::basic`] — the 18-component standard catalog, built in.
11//! - [`Catalog::from_schema`] — parse any A2UI catalog JSON Schema document,
12//!   which is how custom design systems are described.
13//!
14//! # Structural properties are what make validation possible
15//!
16//! The specification is explicit that a catalog must type child references as
17//! `ComponentId` or `ChildList` rather than as bare strings — a validator
18//! decides which fields are structural links by looking for exactly those
19//! references. A raw `"type": "string"` is treated as static text (a URL, a
20//! label) and its target is never checked. [`PropKind`] preserves that
21//! distinction.
22//!
23//! # Property types are carried, not interpreted
24//!
25//! Each property also keeps the JSON type its schema pins it to ([`PropType`]),
26//! which is what lets [`crate::validate`] reject `{"columns": "three"}` where the
27//! catalog says integer. That is one constraint out of JSON Schema, not an
28//! engine: `pattern`, `minimum`, `additionalProperties` and the rest are left to
29//! whatever validates the document itself. A property whose schema states no
30//! type, or states several, is [`PropType::Unconstrained`] and is never rejected
31//! — a catalog this crate reads loosely must not turn into false failures.
32
33use 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
41/// Component types of the basic catalog, in specification order.
42pub 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
63/// Function names of the basic catalog.
64pub 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
81/// Icon names the basic catalog's `Icon` component accepts.
82pub 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
144/// Optional properties every component carries, whatever its type.
145///
146/// `id` and `component` live on [`Component`] itself. These two come from the
147/// shared `ComponentCommon` fragments rather than from any one component's
148/// definition, so a catalog does not redeclare them per type. (`checks` is not
149/// here: it belongs only to input components, and is declared on each.)
150pub const COMMON_PROPS: [&str; 2] = ["accessibility", "weight"];
151
152/// How a component property participates in the component graph.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum PropKind {
155    /// A literal or data-bound value. Never a structural link.
156    Value,
157    /// A single component id (`common_types.json#/$defs/ComponentId`).
158    ComponentRef,
159    /// A `ChildList`: either an array of component ids or a template object.
160    ChildList,
161    /// An array of objects, where the named keys each hold a component id.
162    ///
163    /// `Tabs.tabs` is the canonical case: `[{title, child}, ...]`.
164    ObjectListRefs {
165        /// Keys within each array element that hold a component id.
166        ref_keys: Vec<String>,
167    },
168}
169
170impl PropKind {
171    /// Whether this property can point at other components.
172    pub fn is_structural(&self) -> bool {
173        !matches!(self, PropKind::Value)
174    }
175}
176
177/// The JSON type a property's schema pins its value to.
178///
179/// Populated from a bare `"type"` in the property schema, or from a `$ref` to
180/// one of the common types, which name a JSON type and a data binding together:
181/// `DynamicString` is "a string *or* a `{"path": …}`". The binding half needs no
182/// representation here because a binding carries the renderer's value, not the
183/// property's, so [`crate::validate`] skips bound values entirely.
184#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
185pub enum PropType {
186    /// The schema pins no single type, so no value is ever rejected.
187    #[default]
188    Unconstrained,
189    /// `"type": "string"`, `ComponentId`, `DynamicString`.
190    String,
191    /// `"type": "number"`, `DynamicNumber`.
192    Number,
193    /// `"type": "integer"`, which also accepts a whole number written `2.0`.
194    Integer,
195    /// `"type": "boolean"`, `DynamicBoolean`.
196    Boolean,
197    /// `"type": "object"`, `Action`.
198    Object,
199    /// `"type": "array"`, `DynamicStringList`.
200    Array,
201}
202
203impl PropType {
204    /// Whether `value` satisfies this type.
205    ///
206    /// An [`PropType::Unconstrained`] property accepts anything. `null` is
207    /// accepted by every type: an explicit null is how a payload clears an
208    /// optional property, and a required one missing is
209    /// [`ErrorCode::MissingRequiredProp`](crate::validate::ErrorCode) rather
210    /// than a type failure.
211    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            // Matches JSON Schema, where 2.0 is an integer and 2.5 is not.
218            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    /// The type as a noun phrase, for error messages.
226    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    /// Reads the JSON Schema spelling of a type.
239    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            // Includes "null": a property that may only be null constrains
248            // nothing worth reporting.
249            _ => PropType::Unconstrained,
250        }
251    }
252}
253
254/// One property of a component type.
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct PropDef {
257    /// Property name as it appears on the wire.
258    pub name: String,
259    /// Whether the property holds data or component references.
260    pub kind: PropKind,
261    /// The JSON type the schema pins the value to, if it pins one.
262    pub value_type: PropType,
263    /// Human-readable description, carried into generated prompts.
264    pub description: Option<String>,
265    /// Permitted values, when the schema constrains them to an enum.
266    pub enum_values: Vec<String>,
267    /// Whether the component is invalid without this property.
268    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/// One component type in a catalog.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct ComponentDef {
302    /// Type name, e.g. `Text`.
303    pub name: String,
304    /// Human-readable description, carried into generated prompts.
305    pub description: Option<String>,
306    /// Properties, keyed by name.
307    pub props: BTreeMap<String, PropDef>,
308    /// Property names that must be present, in declaration order.
309    pub required: Vec<String>,
310    /// Parent component types this may sit under. `None` means unrestricted.
311    ///
312    /// [`SURFACE_COMPONENT`] stands for the
313    /// implicit surface container, so `["Surface"]` restricts a component to
314    /// being the tree root.
315    pub allowed_parents: Option<Vec<String>>,
316    /// Child component types this may contain. `None` means unrestricted.
317    pub allowed_children: Option<Vec<String>>,
318    /// Declaration order of properties, for stable prompt rendering.
319    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    /// Properties in declaration order.
343    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    /// Every component id a concrete component of this type references.
350    ///
351    /// Locators are relative to the component object, e.g. `child`,
352    /// `children[2]`, `children.componentId`, `tabs[0].child`.
353    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/// One component id reference found inside a component.
413#[derive(Debug, Clone, PartialEq, Eq)]
414pub struct ComponentRef {
415    /// Where the reference sits within the component, e.g. `children[1]`.
416    pub location: String,
417    /// The referenced component id.
418    pub id: String,
419}
420
421/// One function in a catalog.
422#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct FunctionDef {
424    /// Function name, e.g. `formatString`.
425    pub name: String,
426    /// Human-readable description, carried into generated prompts.
427    pub description: Option<String>,
428    /// Declared return type, when the catalog states one.
429    pub return_type: Option<String>,
430}
431
432/// A set of component and function definitions, identified by `catalogId`.
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct Catalog {
435    /// Opaque identifier agent and renderer negotiate on.
436    pub catalog_id: String,
437    /// Human-readable title.
438    pub title: Option<String>,
439    /// Human-readable description.
440    pub description: Option<String>,
441    /// Markdown design guidance specific to this catalog, for prompts.
442    pub instructions: Option<String>,
443    /// Component types, keyed by name.
444    pub components: BTreeMap<String, ComponentDef>,
445    /// Functions, keyed by name.
446    pub functions: BTreeMap<String, FunctionDef>,
447    component_order: Vec<String>,
448}
449
450impl Catalog {
451    /// An empty catalog with the given id.
452    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    /// Looks up a component type.
465    pub fn component(&self, name: &str) -> Option<&ComponentDef> {
466        self.components.get(name)
467    }
468
469    /// Whether this catalog defines the given component type.
470    pub fn has_component(&self, name: &str) -> bool {
471        self.components.contains_key(name)
472    }
473
474    /// Component types in declaration order.
475    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    /// Adds or replaces a component type.
482    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    /// Every component id referenced by `component`, using this catalog's
490    /// structural property definitions.
491    ///
492    /// Returns an empty list for an unknown component type: without a
493    /// definition there is no way to tell a child reference from a label.
494    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    /// The standard 18-component basic catalog.
501    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    /// Parses an A2UI catalog JSON Schema document.
524    ///
525    /// Understands the shape the specification uses: a top-level `components`
526    /// map whose values are JSON Schemas, each typically an `allOf` of shared
527    /// fragments plus a final object holding `properties` and `required`. The
528    /// `allOf` members are merged; `$ref`s to `ComponentId` and `ChildList` are
529    /// what mark a property structural.
530    ///
531    /// # Errors
532    ///
533    /// Returns [`Error::Catalog`] if the document is not an object or has no
534    /// `catalogId`.
535    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    /// Checks `allowedParents` / `allowedChildren` across a component tree.
572    ///
573    /// Kept separate from [`crate::validate`] on purpose: the specification
574    /// assigns composition failures their own renderer-side error codes
575    /// (`UNALLOWED_PARENT`, `UNALLOWED_CHILD`), distinct from the structural
576    /// validation codes. Components with no declared constraints never produce
577    /// violations, which is every component of the basic catalog.
578    ///
579    /// The root component's parent is the implicit
580    /// [`Surface`](crate::constants::SURFACE_COMPONENT) container.
581    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        // The implicit Surface container is the root component's parent.
594        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    /// Renders the catalog as a compact summary for a generating model.
656    ///
657    /// One line per component with its required and optional properties, plus
658    /// the function list. This is the cheap alternative to pasting the whole
659    /// JSON Schema; when a model needs the exact document instead, use
660    #[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/// A composition-constraint failure.
733#[derive(Debug, Clone, PartialEq, Eq)]
734pub struct CompositionViolation {
735    /// Which constraint was broken.
736    pub code: CompositionCode,
737    /// Locator into the components list, e.g. `components[3].children[0]`.
738    pub path: String,
739    /// Human- and LLM-readable explanation.
740    pub message: String,
741}
742
743/// Error codes the specification assigns to composition failures.
744#[derive(Debug, Clone, Copy, PartialEq, Eq)]
745pub enum CompositionCode {
746    /// A component sits under a parent its `allowedParents` excludes.
747    UnallowedParent,
748    /// A container holds a child its `allowedChildren` excludes.
749    UnallowedChild,
750}
751
752impl CompositionCode {
753    /// The wire string for this code.
754    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
772/// Merges an `allOf` chain into a flat view of properties and requirements.
773fn 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            // Reversed so the declared order survives the stack.
798            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            // Required but undescribed: keep it so the validator can still
839            // demand it.
840            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
869/// Decides whether a property schema describes a structural link.
870fn prop_kind_from_schema(schema: &Value) -> PropKind {
871    if let Some(kind) = ref_kind(schema.get("$ref")) {
872        return kind;
873    }
874    // `allOf` / `oneOf` / `anyOf` wrappers around a structural $ref.
875    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                // An array of ComponentId behaves exactly like a static child list.
889                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
919/// Reads the JSON type a property schema pins its value to.
920///
921/// Deliberately narrow. A `$ref` is resolved by the *name* of its target rather
922/// than by following it, because the common types are a fixed, published set and
923/// a catalog is free to reference them across documents this crate never loads.
924/// An unrecognized target constrains nothing.
925fn 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    // `allOf` members all have to hold, so any one of them that states a type
933    // states the type. `oneOf` / `anyOf` members are alternatives, so they only
934    // pin a type when every arm agrees — `DynamicString` spelled out inline is
935    // `anyOf: [string, DataBinding]`, which pins nothing.
936    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
959/// The JSON type behind a `$ref` to one of the published common types.
960fn 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        // `ChildList` is an array or a template object; `DynamicValue` is
968        // anything at all. Neither narrows to one type.
969        _ => 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
1026/// The standard catalog's component definitions, transcribed from
1027/// `basic_catalog.json` of the v0.9 specification.
1028///
1029/// Property types come from that document, and
1030/// `the_built_in_basic_catalog_types_match_the_vendored_specification_document`
1031/// in this module's tests is what keeps the transcription honest.
1032fn 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                // ISO 8601 bounds. The specification narrows the format further
1269                // (`date`, `time` or `date-time`); this crate checks the type
1270                // only, which is the part a generating model gets wrong.
1271                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        // An Image url is a string but never a component reference.
1349        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        // A `Dynamic*` reference is its underlying type; a binding in its place
1480        // is skipped by the validator rather than described here.
1481        assert_eq!(type_of("title"), PropType::String);
1482        // Alternatives that disagree, and a schema with no type at all, pin
1483        // nothing rather than guessing.
1484        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        // `Catalog::basic()` is hand-transcribed so it costs no parsing at
1491        // startup. This is what keeps the transcription honest: the vendored
1492        // v0.9 `basic_catalog.json` is the source it was copied from, so parsing
1493        // that document has to produce the same types.
1494        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                // `checks` is declared per input component here but is not in
1508                // the specification document; nothing to compare it against.
1509                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        // Per spec: a raw string type is static text, so its target is never checked.
1526        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}