1use std::collections::HashMap;
24use std::sync::OnceLock;
25
26use schemars::schema_for;
27use serde_json::{to_value, Value};
28
29use crate::component::{
30 ActionCardProps, AlertProps, AvatarProps, BadgeProps, BreadcrumbProps, ButtonGroupProps,
31 ButtonProps, CalendarCellProps, CardProps, CheckboxListProps, CheckboxProps, ChecklistProps,
32 CollapsibleProps, DataTableProps, DescriptionListProps, DetailPageProps, DropdownMenuProps,
33 EmptyStateProps, FormProps, FormSectionProps, GridProps, HeaderProps, ImageProps, InputProps,
34 KanbanBoardProps, MediaCardGridProps, ModalProps, NotificationDropdownProps, PageHeaderProps,
35 PaginationProps, ProductTileProps, ProgressProps, RawHtmlProps, SelectProps, SeparatorProps,
36 SidebarProps, SkeletonProps, StatCardProps, StreamTextProps, SwitchProps, TableProps,
37 TabsProps, TextProps, ToastProps,
38};
39
40pub struct ComponentSpec {
47 pub name: String,
49 pub description: String,
51 pub props_schema: Value,
53 pub is_plugin: bool,
55 pub slot_fields: Vec<String>,
60}
61
62pub struct Catalog {
68 pub(crate) components: HashMap<String, ComponentSpec>,
70 pub(crate) plugin_components: HashMap<String, ComponentSpec>,
72 pub(crate) full_schema: Value,
74 pub(crate) per_component_schemas: HashMap<String, Value>,
76 pub(crate) validator: jsonschema::Validator,
78}
79
80#[derive(Debug, thiserror::Error)]
82pub enum CatalogError {
83 #[error("unknown component type '{type_name}' at element '{element_id}'")]
85 UnknownType {
86 element_id: String,
88 type_name: String,
90 },
91 #[error("props invalid for '{type_name}' at element '{element_id}': {errors:?}")]
93 PropsInvalid {
94 element_id: String,
96 type_name: String,
98 errors: Vec<String>,
100 },
101 #[error("spec invalid: {errors:?}")]
103 SpecInvalid {
104 errors: Vec<String>,
106 },
107 #[error("catalog build failed: {0}")]
109 BuildFailed(String),
110 #[error("schema serialization error: {0}")]
112 SchemaSerialization(#[from] serde_json::Error),
113}
114
115type SchemaFn = fn() -> Value;
118
119static BUILTIN_SPECS: &[(&str, &str, SchemaFn, &[&str])] = &[
125 (
127 "Text",
128 "Semantic text element (p / h1 / h2 / h3 / span / div / section).",
129 || to_value(schema_for!(TextProps)).unwrap(),
130 &[],
131 ),
132 (
133 "Button",
134 "Interactive button with variant, size, optional icon, and disabled state.",
135 || to_value(schema_for!(ButtonProps)).unwrap(),
136 &[],
137 ),
138 (
139 "Badge",
140 "Small variant-styled label.",
141 || to_value(schema_for!(BadgeProps)).unwrap(),
142 &[],
143 ),
144 (
145 "Alert",
146 "Inline notice with info / success / warning / error variants.",
147 || to_value(schema_for!(AlertProps)).unwrap(),
148 &[],
149 ),
150 (
151 "Separator",
152 "Horizontal or vertical divider between content sections.",
153 || to_value(schema_for!(SeparatorProps)).unwrap(),
154 &[],
155 ),
156 (
157 "Progress",
158 "Progress bar with 0–100 percentage value and optional label.",
159 || to_value(schema_for!(ProgressProps)).unwrap(),
160 &[],
161 ),
162 (
163 "Avatar",
164 "Circular user image with fallback initials and size variants.",
165 || to_value(schema_for!(AvatarProps)).unwrap(),
166 &[],
167 ),
168 (
169 "Image",
170 "Image with optional aspect ratio and skeleton fallback on load error.",
171 || to_value(schema_for!(ImageProps)).unwrap(),
172 &[],
173 ),
174 (
175 "Skeleton",
176 "Loading placeholder with configurable width / height / rounding.",
177 || to_value(schema_for!(SkeletonProps)).unwrap(),
178 &[],
179 ),
180 (
181 "Breadcrumb",
182 "Navigation trail of label + optional URL items.",
183 || to_value(schema_for!(BreadcrumbProps)).unwrap(),
184 &[],
185 ),
186 (
187 "Pagination",
188 "Page navigation for paginated data (current / per_page / total).",
189 || to_value(schema_for!(PaginationProps)).unwrap(),
190 &[],
191 ),
192 (
193 "DescriptionList",
194 "Key-value pairs displayed as a description list with optional format.",
195 || to_value(schema_for!(DescriptionListProps)).unwrap(),
196 &[],
197 ),
198 (
199 "EmptyState",
200 "Standardized empty view with title, description, and optional CTA.",
201 || to_value(schema_for!(EmptyStateProps)).unwrap(),
202 &[],
203 ),
204 (
205 "StatCard",
206 "Live-updatable metric card with label, value, icon, SSE target.",
207 || to_value(schema_for!(StatCardProps)).unwrap(),
208 &[],
209 ),
210 (
211 "Checklist",
212 "Onboarding-style checklist with dismissal and server-side state.",
213 || to_value(schema_for!(ChecklistProps)).unwrap(),
214 &[],
215 ),
216 (
217 "Toast",
218 "Declarative notification intent consumed by the runtime JS via data attributes.",
219 || to_value(schema_for!(ToastProps)).unwrap(),
220 &[],
221 ),
222 (
223 "NotificationDropdown",
224 "Dropdown listing notification items with icons, timestamps, read state.",
225 || to_value(schema_for!(NotificationDropdownProps)).unwrap(),
226 &[],
227 ),
228 (
229 "Sidebar",
230 "Dashboard sidebar with fixed top / bottom items and collapsible nav groups.",
231 || to_value(schema_for!(SidebarProps)).unwrap(),
232 &[],
233 ),
234 (
235 "Header",
236 "Dashboard top bar with business name, notification badge, user menu.",
237 || to_value(schema_for!(HeaderProps)).unwrap(),
238 &[],
239 ),
240 (
241 "DropdownMenu",
242 "Trigger button with an absolutely-positioned kebab-style action panel.",
243 || to_value(schema_for!(DropdownMenuProps)).unwrap(),
244 &[],
245 ),
246 (
247 "CalendarCell",
248 "Single day in a month grid with today highlight, out-of-month muting, event dots.",
249 || to_value(schema_for!(CalendarCellProps)).unwrap(),
250 &[],
251 ),
252 (
253 "ActionCard",
254 "Clickable row with icon, title, description, chevron, and variant-colored border.",
255 || to_value(schema_for!(ActionCardProps)).unwrap(),
256 &[],
257 ),
258 (
259 "ProductTile",
260 "Touch-friendly POS tile with name, price, and +/- quantity controls.",
261 || to_value(schema_for!(ProductTileProps)).unwrap(),
262 &[],
263 ),
264 (
265 "RawHtml",
266 "Server-injected HTML island. CONSUMER is responsible for sanitization — see docs/src/json-ui/plugins.md.",
267 || to_value(schema_for!(RawHtmlProps)).unwrap(),
268 &[],
269 ),
270 (
271 "StreamText",
272 "Connects to a server-sent-events endpoint and renders token-by-token output as plain text. The SSE endpoint must emit `event: done` on completion to prevent auto-reconnect.",
273 || to_value(schema_for!(StreamTextProps)).unwrap(),
274 &[],
275 ),
276 (
278 "Card",
279 "Content container with title, description, optional badge and subtitle, body children, and optional footer slot.",
280 || to_value(schema_for!(CardProps)).unwrap(),
281 &["footer"],
282 ),
283 (
284 "Modal",
285 "Dialog overlay with title, description, body children, and optional footer slot.",
286 || to_value(schema_for!(ModalProps)).unwrap(),
287 &["footer"],
288 ),
289 (
290 "Tabs",
291 "Tabbed content; per-tab children live in TabsProps.tabs[i].children.",
292 || to_value(schema_for!(TabsProps)).unwrap(),
293 &[],
294 ),
295 (
296 "KanbanBoard",
297 "Horizontally scrollable kanban columns on desktop, tab-switched on mobile.",
298 || to_value(schema_for!(KanbanBoardProps)).unwrap(),
299 &[],
300 ),
301 (
302 "PageHeader",
303 "Page title with optional breadcrumb and action button slot.",
304 || to_value(schema_for!(PageHeaderProps)).unwrap(),
305 &["actions"],
306 ),
307 (
308 "DetailPage",
309 "Canonical resource-detail skeleton: PageHeader chrome, optional info Card slot, and stacked body sections from Element.children.",
310 || to_value(schema_for!(DetailPageProps)).unwrap(),
311 &["actions", "info"],
312 ),
313 (
314 "Grid",
315 "Responsive multi-column grid with configurable breakpoint columns, gap, scroll.",
316 || to_value(schema_for!(GridProps)).unwrap(),
317 &[],
318 ),
319 (
320 "Collapsible",
321 "Expandable <details> / <summary> section.",
322 || to_value(schema_for!(CollapsibleProps)).unwrap(),
323 &[],
324 ),
325 (
326 "FormSection",
327 "Visual grouping within a form with title, description, and layout variant.",
328 || to_value(schema_for!(FormSectionProps)).unwrap(),
329 &[],
330 ),
331 (
332 "ButtonGroup",
333 "Horizontal button row with configurable gap.",
334 || to_value(schema_for!(ButtonGroupProps)).unwrap(),
335 &[],
336 ),
337 (
339 "Form",
340 "Form container with action binding and field components.",
341 || to_value(schema_for!(FormProps)).unwrap(),
342 &[],
343 ),
344 (
345 "Input",
346 "Text input with type variants, validation error, data_path pre-fill.",
347 || to_value(schema_for!(InputProps)).unwrap(),
348 &[],
349 ),
350 (
351 "Select",
352 "Dropdown select with options, error, data_path pre-fill.",
353 || to_value(schema_for!(SelectProps)).unwrap(),
354 &[],
355 ),
356 (
357 "Checkbox",
358 "Boolean checkbox with label, description, data binding.",
359 || to_value(schema_for!(CheckboxProps)).unwrap(),
360 &[],
361 ),
362 (
363 "Switch",
364 "Toggle switch (visual alternative to Checkbox); auto-submit when `action` set.",
365 || to_value(schema_for!(SwitchProps)).unwrap(),
366 &[],
367 ),
368 (
369 "CheckboxList",
370 "Multi-select checkbox group from static options or data-driven array. \
371 Each checked option submits as field=value.",
372 || to_value(schema_for!(CheckboxListProps)).unwrap(),
373 &[],
374 ),
375 (
376 "CheckboxGroup",
377 "Multi-select checkbox group (alias for CheckboxList). Each checked option \
378 submits as field=value with array-submit semantics. Identical props to \
379 CheckboxList; see that entry for full schema.",
380 || to_value(schema_for!(CheckboxListProps)).unwrap(),
381 &[],
382 ),
383 (
385 "Table",
386 "Data table with columns, row_actions, sorting, empty_message.",
387 || to_value(schema_for!(TableProps)).unwrap(),
388 &[],
389 ),
390 (
391 "DataTable",
392 "Stripe-style alternating-row table with per-row DropdownMenu and mobile card fallback.",
393 || to_value(schema_for!(DataTableProps)).unwrap(),
394 &[],
395 ),
396 (
397 "MediaCardGrid",
398 "Responsive card grid backed by a data array. Each card shows an optional screenshot image, title, description, status badge, and per-row dropdown actions.",
399 || to_value(schema_for!(MediaCardGridProps)).unwrap(),
400 &[],
401 ),
402];
403
404fn sanitize_schema(mut schema: Value) -> Value {
412 fn walk(v: &mut Value) {
413 if let Some(obj) = v.as_object_mut() {
414 if let Some(defs) = obj.remove("definitions") {
415 obj.entry("$defs".to_string()).or_insert(defs);
416 }
417 if let Some(Value::String(ref_str)) = obj.get_mut("$ref") {
418 if let Some(suffix) = ref_str.strip_prefix("#/definitions/") {
419 *ref_str = format!("#/$defs/{suffix}");
420 }
421 }
422 let keys: Vec<String> = obj.keys().cloned().collect();
424 for k in keys {
425 if let Some(child) = obj.get_mut(&k) {
426 walk(child);
427 }
428 }
429 } else if let Some(arr) = v.as_array_mut() {
430 for item in arr.iter_mut() {
431 walk(item);
432 }
433 }
434 }
435 walk(&mut schema);
436 schema
437}
438
439fn hoist_defs(schema: &mut Value, shared_defs: &mut serde_json::Map<String, Value>) {
448 if let Some(obj) = schema.as_object_mut() {
449 if let Some(Value::Object(defs)) = obj.remove("$defs") {
450 for (k, v) in defs {
451 shared_defs.entry(k).or_insert(v);
452 }
453 }
454 }
455}
456
457fn assemble_full_schema(per_component: &HashMap<String, Value>) -> Result<Value, CatalogError> {
469 let mut action_schema = sanitize_schema(to_value(schema_for!(crate::action::Action))?);
471 let mut visibility_schema =
472 sanitize_schema(to_value(schema_for!(crate::visibility::Visibility))?);
473
474 let mut shared_defs: serde_json::Map<String, Value> = serde_json::Map::new();
476 hoist_defs(&mut action_schema, &mut shared_defs);
477 hoist_defs(&mut visibility_schema, &mut shared_defs);
478
479 let mut names: Vec<&String> = per_component.keys().collect();
483 names.sort();
484 let one_of: Vec<Value> = names
485 .into_iter()
486 .map(|name| {
487 let mut props_schema = per_component[name].clone();
488 hoist_defs(&mut props_schema, &mut shared_defs);
490 serde_json::json!({
491 "allOf": [
492 {
493 "type": "object",
494 "required": ["type"],
495 "properties": {
496 "type": { "const": name }
497 }
498 },
499 {
500 "type": "object",
501 "properties": {
502 "props": props_schema,
503 "children": { "type": "array", "items": { "type": "string" } },
504 "action": { "$ref": "#/$defs/Action" },
505 "visible": { "$ref": "#/$defs/Visibility" }
506 }
507 }
508 ]
509 })
510 })
511 .collect();
512
513 shared_defs
516 .entry("Action".to_string())
517 .or_insert(action_schema);
518 shared_defs
519 .entry("Visibility".to_string())
520 .or_insert(visibility_schema);
521 shared_defs.insert(
523 "Element".to_string(),
524 serde_json::json!({ "oneOf": one_of }),
525 );
526
527 Ok(serde_json::json!({
528 "$schema": "https://json-schema.org/draft/2020-12/schema",
529 "$id": "ferro-json-ui/v2",
530 "type": "object",
531 "required": ["$schema", "root", "elements"],
532 "properties": {
533 "$schema": { "const": "ferro-json-ui/v2" },
534 "root": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,127}$" },
535 "elements": {
536 "type": "object",
537 "additionalProperties": { "$ref": "#/$defs/Element" }
538 },
539 "title": { "type": ["string", "null"] },
540 "layout": { "type": ["string", "null"] },
541 "data": true
542 },
543 "$defs": shared_defs
544 }))
545}
546
547impl Catalog {
550 pub fn build() -> Result<Self, CatalogError> {
561 if BUILTIN_SPECS.len() != crate::render::BUILTIN_TYPES.len() {
565 return Err(CatalogError::BuildFailed(format!(
566 "BUILTIN_SPECS has {} entries but BUILTIN_TYPES has {} — \
567 add an entry to BUILTIN_SPECS or remove from BUILTIN_TYPES",
568 BUILTIN_SPECS.len(),
569 crate::render::BUILTIN_TYPES.len(),
570 )));
571 }
572
573 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
575 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len() * 2);
576 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
577 let raw = schema_fn();
578 let schema = sanitize_schema(raw);
579 per_component_schemas.insert((*name).to_string(), schema.clone());
580 components.insert(
581 (*name).to_string(),
582 ComponentSpec {
583 name: (*name).to_string(),
584 description: (*desc).to_string(),
585 props_schema: schema,
586 is_plugin: false,
587 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
588 },
589 );
590 }
591
592 let mut plugin_components = HashMap::new();
598 for plugin_type in crate::plugin::registered_plugin_types() {
599 if components.contains_key(&plugin_type) {
601 continue;
602 }
603 let raw = crate::plugin::with_plugin(&plugin_type, |p| p.props_schema())
604 .unwrap_or(Value::Null);
605 let schema = sanitize_schema(raw);
606 if jsonschema::validator_for(&schema).is_err() {
608 return Err(CatalogError::BuildFailed(format!(
609 "plugin '{plugin_type}' returned an invalid JSON Schema"
610 )));
611 }
612 per_component_schemas.insert(plugin_type.clone(), schema.clone());
613 plugin_components.insert(
614 plugin_type.clone(),
615 ComponentSpec {
616 name: plugin_type,
617 description: String::from("Plugin component."),
618 props_schema: schema,
619 is_plugin: true,
620 slot_fields: Vec::new(),
621 },
622 );
623 }
624
625 let full_schema = assemble_full_schema(&per_component_schemas)?;
627
628 let validator = jsonschema::validator_for(&full_schema)
630 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
631
632 Ok(Catalog {
633 components,
634 plugin_components,
635 full_schema,
636 per_component_schemas,
637 validator,
638 })
639 }
640
641 pub fn json_schema(&self) -> &Value {
648 &self.full_schema
649 }
650
651 pub fn validate(&self, spec: &crate::spec::Spec) -> Result<(), Vec<CatalogError>> {
671 let mut errors: Vec<CatalogError> = Vec::new();
672
673 for (id, el) in &spec.elements {
675 let known = self.components.contains_key(&el.type_name)
676 || self.plugin_components.contains_key(&el.type_name);
677 if !known {
678 errors.push(CatalogError::UnknownType {
679 element_id: id.clone(),
680 type_name: el.type_name.clone(),
681 });
682 }
683 }
684 if !errors.is_empty() {
689 return Err(errors);
690 }
691
692 for (id, el) in &spec.elements {
694 if let Some(schema) = self.per_component_schemas.get(&el.type_name) {
695 if el.props.is_null() {
700 continue;
701 }
702 let v = match jsonschema::validator_for(schema) {
706 Ok(v) => v,
707 Err(e) => {
708 errors.push(CatalogError::BuildFailed(format!(
709 "compiling per-component schema for '{}': {e}",
710 el.type_name
711 )));
712 continue;
713 }
714 };
715 let validation_props = strip_expr_objects(&el.props);
721 let mut per_elem_errs: Vec<String> = Vec::new();
722 for err in v.iter_errors(&validation_props) {
723 per_elem_errs.push(format!("{}: {}", err.instance_path(), err));
724 }
725 if !per_elem_errs.is_empty() {
726 errors.push(CatalogError::PropsInvalid {
727 element_id: id.clone(),
728 type_name: el.type_name.clone(),
729 errors: per_elem_errs,
730 });
731 }
732 }
733 }
734
735 let spec_value = match serde_json::to_value(spec) {
737 Ok(v) => v,
738 Err(e) => {
739 errors.push(CatalogError::SchemaSerialization(e));
740 return Err(errors);
741 }
742 };
743 let stripped_spec_value = strip_expr_objects(&spec_value);
745 let mut envelope_errs: Vec<String> = Vec::new();
746 for err in self.validator.iter_errors(&stripped_spec_value) {
747 envelope_errs.push(format!("{}: {}", err.instance_path(), err));
748 }
749 if !envelope_errs.is_empty() {
750 errors.push(CatalogError::SpecInvalid {
751 errors: envelope_errs,
752 });
753 }
754
755 if errors.is_empty() {
756 Ok(())
757 } else {
758 Err(errors)
759 }
760 }
761
762 pub fn component_schema(&self, type_name: &str) -> Option<&Value> {
776 self.per_component_schemas.get(type_name)
777 }
778
779 pub fn components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
786 let mut entries: Vec<&ComponentSpec> = self.components.values().collect();
787 entries.sort_by(|a, b| a.name.cmp(&b.name));
788 entries.into_iter()
789 }
790
791 pub fn plugin_components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
797 let mut entries: Vec<&ComponentSpec> = self.plugin_components.values().collect();
798 entries.sort_by(|a, b| a.name.cmp(&b.name));
799 entries.into_iter()
800 }
801
802 pub fn prompt(&self) -> String {
818 let mut out = String::with_capacity(8 * 1024);
819 out.push_str("## Component Catalog\n\n");
820 out.push_str("Slot fields are Vec<String> of element IDs; body children come from Element.children.\n\n");
821 for spec in self.components_sorted() {
822 render_component_section(&mut out, spec);
823 }
824 if self.plugin_components.is_empty() {
825 return out;
826 }
827 out.push_str("## Plugin Components\n\n");
828 for spec in self.plugin_components_sorted() {
829 render_component_section(&mut out, spec);
830 }
831 out
832 }
833}
834
835fn render_component_section(out: &mut String, spec: &ComponentSpec) {
852 out.push_str("### ");
853 out.push_str(&spec.name);
854 out.push('\n');
855 out.push_str(&spec.description);
856 out.push('\n');
857
858 let props_line = render_props_line(&spec.props_schema);
859 if !props_line.is_empty() {
860 out.push_str("Props: ");
861 out.push_str(&props_line);
862 out.push('\n');
863 }
864 if !spec.slot_fields.is_empty() {
865 out.push_str("Slots: ");
866 out.push_str(&spec.slot_fields.join(", "));
867 out.push('\n');
868 }
869 out.push('\n');
870}
871
872fn render_props_line(schema: &Value) -> String {
883 let Some(obj) = schema.as_object() else {
884 return String::new();
885 };
886 let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
887 return String::new();
888 };
889 let required: std::collections::HashSet<&str> = obj
890 .get("required")
891 .and_then(|v| v.as_array())
892 .map(|arr| {
893 arr.iter()
894 .filter_map(|v| v.as_str())
895 .collect::<std::collections::HashSet<_>>()
896 })
897 .unwrap_or_default();
898
899 let parts: Vec<String> = props
900 .iter()
901 .map(|(name, field_schema)| {
902 let ty = render_field_type(field_schema, required.contains(name.as_str()));
903 format!("{name} ({ty})")
904 })
905 .collect();
906 parts.join(", ")
907}
908
909fn render_field_type(schema: &Value, is_required: bool) -> String {
911 if let Some(variants) = schema.get("enum").and_then(|v| v.as_array()) {
913 let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
914 let inner = render_enum_inline(&names);
915 return wrap_optional(inner, is_required);
916 }
917 for key in ["anyOf", "oneOf"] {
919 if let Some(arr) = schema.get(key).and_then(|v| v.as_array()) {
920 let has_null = arr
921 .iter()
922 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
923 let non_null: Vec<&Value> = arr
924 .iter()
925 .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
926 .collect();
927 if has_null && non_null.len() == 1 {
928 let inner = render_field_type(non_null[0], true);
929 return format!("Option<{inner}>");
930 }
931 }
932 }
933 if let Some(types) = schema.get("type").and_then(|v| v.as_array()) {
935 let non_null: Vec<&str> = types
936 .iter()
937 .filter_map(|v| v.as_str())
938 .filter(|s| *s != "null")
939 .collect();
940 let has_null = types.iter().any(|v| v.as_str() == Some("null"));
941 if has_null && non_null.len() == 1 {
942 return format!("Option<{}>", rust_for_json_type(non_null[0], schema));
943 }
944 }
945 if let Some(t) = schema.get("type").and_then(|v| v.as_str()) {
947 let inner = rust_for_json_type(t, schema);
948 return wrap_optional(inner, is_required);
949 }
950 wrap_optional("<see schema>".to_string(), is_required)
952}
953
954fn rust_for_json_type(t: &str, schema: &Value) -> String {
956 match t {
957 "string" => "String".to_string(),
958 "integer" => "i64".to_string(),
959 "number" => "f64".to_string(),
960 "boolean" => "bool".to_string(),
961 "array" => {
962 if let Some(items) = schema.get("items") {
963 let inner = render_field_type(items, true);
964 format!("Vec<{inner}>")
965 } else {
966 "Vec<Value>".to_string()
967 }
968 }
969 "object" => "Object".to_string(),
970 other => other.to_string(),
971 }
972}
973
974fn render_enum_inline(variants: &[&str]) -> String {
976 if variants.len() <= 8 {
977 variants.join("|")
978 } else {
979 format!("one of {} — see schema", variants.len())
980 }
981}
982
983fn wrap_optional(inner: String, is_required: bool) -> String {
985 if is_required {
986 inner
987 } else {
988 format!("Option<{inner}>")
989 }
990}
991
992fn strip_expr_objects(val: &Value) -> Value {
1001 match val {
1002 Value::Object(map) => {
1003 if map.len() == 1 && (map.contains_key("$data") || map.contains_key("$template")) {
1004 Value::String(String::new())
1005 } else {
1006 Value::Object(
1007 map.iter()
1008 .map(|(k, v)| (k.clone(), strip_expr_objects(v)))
1009 .collect(),
1010 )
1011 }
1012 }
1013 Value::Array(arr) => Value::Array(arr.iter().map(strip_expr_objects).collect()),
1014 other => other.clone(),
1015 }
1016}
1017
1018pub fn global_catalog() -> &'static Catalog {
1031 static GLOBAL_CATALOG: OnceLock<Catalog> = OnceLock::new();
1032 GLOBAL_CATALOG.get_or_init(|| {
1033 Catalog::build().expect("catalog build failed — see CatalogError for details")
1034 })
1035}
1036
1037#[cfg(test)]
1040impl Catalog {
1041 pub(crate) fn build_builtins_only() -> Result<Self, CatalogError> {
1046 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
1047 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len());
1048 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
1049 let raw = schema_fn();
1050 let schema = sanitize_schema(raw);
1051 per_component_schemas.insert((*name).to_string(), schema.clone());
1052 components.insert(
1053 (*name).to_string(),
1054 ComponentSpec {
1055 name: (*name).to_string(),
1056 description: (*desc).to_string(),
1057 props_schema: schema,
1058 is_plugin: false,
1059 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
1060 },
1061 );
1062 }
1063 let full_schema = assemble_full_schema(&per_component_schemas)?;
1064 let validator = jsonschema::validator_for(&full_schema)
1065 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
1066 Ok(Catalog {
1067 components,
1068 plugin_components: HashMap::new(),
1069 full_schema,
1070 per_component_schemas,
1071 validator,
1072 })
1073 }
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use super::*;
1079
1080 #[test]
1081 fn builtin_types_count_is_39() {
1082 assert_eq!(crate::render::BUILTIN_TYPES.len(), 45);
1090 }
1091
1092 #[test]
1093 fn builtin_specs_len_matches_dispatch() {
1094 assert_eq!(BUILTIN_SPECS.len(), crate::render::BUILTIN_TYPES.len());
1095 assert_eq!(BUILTIN_SPECS.len(), 45);
1096 }
1097
1098 #[test]
1099 fn builtin_specs_names_match_dispatch() {
1100 use std::collections::HashSet;
1101 let specs: HashSet<&str> = BUILTIN_SPECS.iter().map(|(n, ..)| *n).collect();
1102 let types: HashSet<&str> = crate::render::BUILTIN_TYPES.iter().copied().collect();
1103 assert_eq!(specs, types, "BUILTIN_SPECS names must match BUILTIN_TYPES");
1104 }
1105
1106 #[test]
1107 fn build_populates_all_builtins() {
1108 let cat = Catalog::build_builtins_only().expect("build succeeds");
1110 for name in crate::render::BUILTIN_TYPES.iter() {
1111 assert!(
1112 cat.components.contains_key(*name),
1113 "built-in '{name}' missing from catalog.components"
1114 );
1115 let spec = &cat.components[*name];
1116 assert_eq!(spec.name, *name);
1117 assert!(
1118 !spec.description.is_empty(),
1119 "'{name}' has empty description"
1120 );
1121 assert!(
1122 spec.props_schema.is_object(),
1123 "'{name}' props_schema is not a JSON object"
1124 );
1125 assert!(!spec.is_plugin);
1126 }
1127 }
1128
1129 #[test]
1130 fn build_card_has_footer_slot() {
1131 let cat = Catalog::build_builtins_only().expect("build succeeds");
1133 let card = &cat.components["Card"];
1134 assert_eq!(card.slot_fields, vec!["footer"]);
1135 }
1136
1137 #[test]
1138 fn build_modal_has_footer_slot() {
1139 let cat = Catalog::build_builtins_only().expect("build succeeds");
1141 let modal = &cat.components["Modal"];
1142 assert_eq!(modal.slot_fields, vec!["footer"]);
1143 }
1144
1145 #[test]
1146 fn build_pageheader_has_actions_slot() {
1147 let cat = Catalog::build_builtins_only().expect("build succeeds");
1149 let ph = &cat.components["PageHeader"];
1150 assert_eq!(ph.slot_fields, vec!["actions"]);
1151 }
1152
1153 #[test]
1154 fn build_text_has_no_slots() {
1155 let cat = Catalog::build_builtins_only().expect("build succeeds");
1157 assert!(cat.components["Text"].slot_fields.is_empty());
1158 }
1159
1160 #[test]
1161 fn build_populates_per_component_schemas() {
1162 let cat = Catalog::build_builtins_only().expect("build succeeds");
1164 assert_eq!(
1165 cat.per_component_schemas.len(),
1166 BUILTIN_SPECS.len() + cat.plugin_components.len()
1167 );
1168 }
1169
1170 #[test]
1171 fn sanitize_schema_rewrites_definitions_to_dollar_defs() {
1172 let raw = serde_json::json!({
1173 "type": "object",
1174 "definitions": { "Foo": { "type": "string" } },
1175 "properties": {
1176 "x": { "$ref": "#/definitions/Foo" }
1177 }
1178 });
1179 let out = sanitize_schema(raw);
1180 assert!(out.get("definitions").is_none());
1181 assert!(out.get("$defs").is_some());
1182 assert_eq!(
1183 out["properties"]["x"]["$ref"].as_str().unwrap(),
1184 "#/$defs/Foo"
1185 );
1186 }
1187
1188 #[test]
1189 fn sanitize_schema_is_idempotent() {
1190 let raw = serde_json::json!({
1191 "type": "object",
1192 "$defs": { "Foo": { "type": "string" } },
1193 "properties": {
1194 "x": { "$ref": "#/$defs/Foo" }
1195 }
1196 });
1197 let once = sanitize_schema(raw.clone());
1198 let twice = sanitize_schema(once.clone());
1199 assert_eq!(once, twice);
1200 assert!(twice.get("definitions").is_none());
1202 assert!(twice.get("$defs").is_some());
1203 }
1204
1205 #[test]
1206 fn json_schema_has_spec_envelope_shape() {
1207 let cat = Catalog::build_builtins_only().expect("build");
1210 let schema = cat.json_schema();
1211 assert_eq!(schema["$id"], "ferro-json-ui/v2");
1212 assert_eq!(schema["type"], "object");
1213 let required: Vec<&str> = schema["required"]
1214 .as_array()
1215 .unwrap()
1216 .iter()
1217 .map(|v| v.as_str().unwrap())
1218 .collect();
1219 assert!(required.contains(&"$schema"));
1220 assert!(required.contains(&"root"));
1221 assert!(required.contains(&"elements"));
1222 }
1223
1224 #[test]
1225 fn json_schema_has_action_and_visibility_defs() {
1226 let cat = Catalog::build_builtins_only().expect("build");
1227 let schema = cat.json_schema();
1228 assert!(
1229 schema["$defs"]["Action"].is_object(),
1230 "$defs/Action missing"
1231 );
1232 assert!(
1233 schema["$defs"]["Visibility"].is_object(),
1234 "$defs/Visibility missing"
1235 );
1236 assert!(
1237 schema["$defs"]["Element"].is_object(),
1238 "$defs/Element missing"
1239 );
1240 }
1241
1242 #[test]
1243 fn json_schema_oneof_covers_all_builtins() {
1244 let cat = Catalog::build_builtins_only().expect("build");
1245 let schema = cat.json_schema();
1246 let one_of = schema["$defs"]["Element"]["oneOf"]
1248 .as_array()
1249 .expect("Element.oneOf is an array");
1250
1251 let mut discriminators: std::collections::HashSet<String> =
1253 std::collections::HashSet::new();
1254 for variant in one_of {
1255 let c = variant["allOf"][0]["properties"]["type"]["const"]
1256 .as_str()
1257 .expect("every variant pins a type const");
1258 discriminators.insert(c.to_string());
1259 }
1260
1261 for name in crate::render::BUILTIN_TYPES.iter() {
1262 assert!(
1263 discriminators.contains(*name),
1264 "oneOf is missing discriminator for '{name}'"
1265 );
1266 }
1267
1268 assert_eq!(
1270 discriminators.len(),
1271 crate::render::BUILTIN_TYPES.len(),
1272 "oneOf variant count mismatch"
1273 );
1274 }
1275
1276 #[test]
1277 fn json_schema_is_valid() {
1278 use jsonschema::draft202012;
1279 let cat = Catalog::build_builtins_only().expect("build");
1280 let schema = cat.json_schema();
1281 assert!(
1282 draft202012::meta::is_valid(schema),
1283 "assembled full_schema did not meta-validate as Draft 2020-12"
1284 );
1285 }
1286
1287 #[test]
1288 fn validator_is_compiled_once_and_usable() {
1289 let cat = Catalog::build_builtins_only().expect("build");
1290 let minimal_valid = serde_json::json!({
1294 "$schema": "ferro-json-ui/v2",
1295 "root": "r",
1296 "elements": {
1297 "r": { "type": "Text", "props": { "content": "hi" } }
1298 }
1299 });
1300 assert!(cat.validator.is_valid(&minimal_valid));
1302 }
1303
1304 #[test]
1305 fn validator_rejects_wrong_schema_version() {
1306 let cat = Catalog::build_builtins_only().expect("build");
1307 let wrong_version = serde_json::json!({
1308 "$schema": "ferro-json-ui/v99-wrong",
1309 "root": "r",
1310 "elements": {
1311 "r": { "type": "Text", "props": { "content": "hi" } }
1312 }
1313 });
1314 assert!(
1315 !cat.validator.is_valid(&wrong_version),
1316 "validator should reject unknown $schema version via const"
1317 );
1318 }
1319
1320 #[test]
1321 fn oneof_variants_are_deterministic_sorted() {
1322 let cat1 = Catalog::build_builtins_only().expect("build 1");
1323 let cat2 = Catalog::build_builtins_only().expect("build 2");
1324 assert_eq!(
1326 serde_json::to_string(cat1.json_schema()).unwrap(),
1327 serde_json::to_string(cat2.json_schema()).unwrap()
1328 );
1329 }
1330
1331 fn test_spec_with(type_name: &str, props: Value) -> crate::spec::Spec {
1335 use crate::spec::{Element, Spec};
1336 use std::collections::HashMap;
1337 let mut elements = HashMap::new();
1338 elements.insert(
1339 "r".to_string(),
1340 Element {
1341 type_name: type_name.to_string(),
1342 props,
1343 children: Vec::new(),
1344 action: None,
1345 visible: None,
1346 each: None,
1347 if_: None,
1348 },
1349 );
1350 Spec {
1351 schema: crate::spec::SCHEMA_VERSION.to_string(),
1352 root: "r".to_string(),
1353 elements,
1354 title: None,
1355 layout: None,
1356 data: Value::Null,
1357 }
1358 }
1359
1360 #[test]
1361 fn validate_positive_per_type() {
1362 let cat = Catalog::build_builtins_only().expect("build");
1365 let cases: Vec<(&str, Value)> = vec![
1366 ("Text", serde_json::json!({ "content": "hi" })),
1367 ("Button", serde_json::json!({ "label": "Save" })),
1368 ("Badge", serde_json::json!({ "label": "New" })),
1369 ("Separator", serde_json::json!({})),
1370 ];
1371 for (ty, props) in cases {
1372 let spec = test_spec_with(ty, props.clone());
1373 match cat.validate(&spec) {
1374 Ok(()) => {}
1375 Err(errs) => panic!("validate({ty}) failed: {errs:?}"),
1376 }
1377 }
1378 }
1379
1380 #[test]
1381 fn validate_unknown_type() {
1382 let cat = Catalog::build_builtins_only().expect("build");
1383 let spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1384 let errs = cat.validate(&spec).expect_err("should fail");
1385 assert!(
1386 errs.iter().any(|e| matches!(
1387 e,
1388 CatalogError::UnknownType { type_name, .. } if type_name == "NotARealComponent"
1389 )),
1390 "expected UnknownType for NotARealComponent; got {errs:?}"
1391 );
1392 }
1393
1394 #[test]
1395 fn validate_missing_required_prop() {
1396 let cat = Catalog::build_builtins_only().expect("build");
1399 let spec = test_spec_with("Card", serde_json::json!({}));
1400 let errs = cat.validate(&spec).expect_err("should fail");
1401 assert!(
1402 errs.iter().any(|e| matches!(
1403 e,
1404 CatalogError::PropsInvalid { type_name, .. } if type_name == "Card"
1405 )),
1406 "expected PropsInvalid for missing required 'title'; got {errs:?}"
1407 );
1408 }
1409
1410 #[test]
1411 fn validate_bad_schema_version() {
1412 let cat = Catalog::build_builtins_only().expect("build");
1413 let mut spec = test_spec_with("Text", serde_json::json!({ "content": "hi" }));
1414 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1415 let errs = cat.validate(&spec).expect_err("should fail");
1416 assert!(
1417 errs.iter()
1418 .any(|e| matches!(e, CatalogError::SpecInvalid { .. })),
1419 "expected SpecInvalid for wrong $schema version; got {errs:?}"
1420 );
1421 }
1422
1423 #[test]
1424 fn validate_pre_dispatch_short_circuits() {
1425 let cat = Catalog::build_builtins_only().expect("build");
1428 let mut spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1429 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1430 let errs = cat.validate(&spec).expect_err("should fail");
1431
1432 let has_unknown = errs
1433 .iter()
1434 .any(|e| matches!(e, CatalogError::UnknownType { .. }));
1435 let has_spec_invalid = errs
1436 .iter()
1437 .any(|e| matches!(e, CatalogError::SpecInvalid { .. }));
1438 let has_props_invalid = errs
1439 .iter()
1440 .any(|e| matches!(e, CatalogError::PropsInvalid { .. }));
1441
1442 assert!(has_unknown, "expected UnknownType");
1443 assert!(
1444 !has_spec_invalid,
1445 "Stage 3 ran despite Stage 1 failing: {errs:?}"
1446 );
1447 assert!(
1448 !has_props_invalid,
1449 "Stage 2 ran despite Stage 1 failing: {errs:?}"
1450 );
1451 }
1452
1453 #[test]
1454 fn validator_is_cached_not_recompiled() {
1455 let cat = Catalog::build_builtins_only().expect("build");
1459 for _ in 0..100 {
1460 let spec = test_spec_with("Text", serde_json::json!({ "content": "x" }));
1461 assert!(cat.validate(&spec).is_ok());
1462 }
1463 }
1464
1465 #[test]
1466 fn validate_accumulates_multiple_errors_across_elements() {
1467 use crate::spec::{Element, Spec};
1469 use std::collections::HashMap;
1470 let cat = Catalog::build_builtins_only().expect("build");
1471 let mut elements = HashMap::new();
1472 elements.insert(
1473 "a".to_string(),
1474 Element {
1475 type_name: "Card".to_string(),
1476 props: serde_json::json!({}), children: Vec::new(),
1478 action: None,
1479 visible: None,
1480 each: None,
1481 if_: None,
1482 },
1483 );
1484 elements.insert(
1485 "b".to_string(),
1486 Element {
1487 type_name: "Button".to_string(),
1488 props: serde_json::json!({}), children: Vec::new(),
1490 action: None,
1491 visible: None,
1492 each: None,
1493 if_: None,
1494 },
1495 );
1496 let spec = Spec {
1497 schema: crate::spec::SCHEMA_VERSION.to_string(),
1498 root: "a".to_string(),
1499 elements,
1500 title: None,
1501 layout: None,
1502 data: Value::Null,
1503 };
1504 let errs = cat.validate(&spec).expect_err("should fail");
1505 let props_invalid_count = errs
1506 .iter()
1507 .filter(|e| matches!(e, CatalogError::PropsInvalid { .. }))
1508 .count();
1509 assert!(
1510 props_invalid_count >= 2,
1511 "expected at least 2 PropsInvalid errors; got {errs:?}"
1512 );
1513 }
1514
1515 #[test]
1521 fn build_discovers_plugins_and_rejects_invalid_schema() {
1522 use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
1523
1524 struct GoodPlugin;
1525 impl JsonUiPlugin for GoodPlugin {
1526 fn component_type(&self) -> &str {
1527 "GoodPlugin_117"
1528 }
1529 fn props_schema(&self) -> Value {
1530 serde_json::json!({ "type": "object" })
1531 }
1532 fn render(&self, _: &Value, _: &Value) -> String {
1533 String::new()
1534 }
1535 fn css_assets(&self) -> Vec<Asset> {
1536 vec![]
1537 }
1538 fn js_assets(&self) -> Vec<Asset> {
1539 vec![]
1540 }
1541 fn init_script(&self) -> Option<String> {
1542 None
1543 }
1544 }
1545
1546 register_plugin(GoodPlugin);
1547
1548 let cat = Catalog::build().expect("build succeeds with valid plugin only");
1550 assert!(
1551 cat.plugin_components.contains_key("GoodPlugin_117"),
1552 "plugin 'GoodPlugin_117' should have been discovered"
1553 );
1554 assert!(cat.plugin_components["GoodPlugin_117"].is_plugin);
1555
1556 struct BadPlugin;
1558 impl JsonUiPlugin for BadPlugin {
1559 fn component_type(&self) -> &str {
1560 "BadPlugin_117"
1561 }
1562 fn props_schema(&self) -> Value {
1563 serde_json::json!({ "type": 42 })
1566 }
1567 fn render(&self, _: &Value, _: &Value) -> String {
1568 String::new()
1569 }
1570 fn css_assets(&self) -> Vec<Asset> {
1571 vec![]
1572 }
1573 fn js_assets(&self) -> Vec<Asset> {
1574 vec![]
1575 }
1576 fn init_script(&self) -> Option<String> {
1577 None
1578 }
1579 }
1580
1581 register_plugin(BadPlugin);
1582 match Catalog::build() {
1583 Err(CatalogError::BuildFailed(msg)) => {
1584 assert!(
1585 msg.contains("BadPlugin_117"),
1586 "error should mention plugin name, got: {msg}"
1587 );
1588 }
1589 Err(other) => panic!("expected BuildFailed mentioning BadPlugin_117, got: {other:?}"),
1590 Ok(_) => panic!("expected build to fail due to invalid plugin schema"),
1591 }
1592 }
1593
1594 #[test]
1597 fn component_schema_returns_props_only() {
1598 let cat = Catalog::build_builtins_only().expect("build");
1602 let schema = cat
1603 .component_schema("Card")
1604 .expect("Card is a built-in component");
1605
1606 let obj = schema
1610 .as_object()
1611 .expect("Card props schema is a JSON object");
1612
1613 assert!(
1615 obj.contains_key("type") || obj.contains_key("oneOf") || obj.contains_key("anyOf"),
1616 "CardProps schema should be a structural object schema; got {obj:?}"
1617 );
1618
1619 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
1621 assert!(
1622 props.contains_key("title"),
1623 "CardProps schema.properties should include 'title'; got keys: {:?}",
1624 props.keys().collect::<Vec<_>>()
1625 );
1626 } else {
1627 panic!(
1628 "CardProps schema missing top-level 'properties' map — \
1629 sanitizer or Plan 02 may be wrong. Got: {}",
1630 serde_json::to_string_pretty(schema).unwrap_or_default()
1631 );
1632 }
1633
1634 let is_element_wrapper = obj
1637 .get("properties")
1638 .and_then(|v| v.as_object())
1639 .map(|p| p.contains_key("children") && p.contains_key("props"))
1640 .unwrap_or(false);
1641 assert!(
1642 !is_element_wrapper,
1643 "component_schema('Card') returned an Element wrapper; must be Props-only (CONTEXT D-19)"
1644 );
1645 }
1646
1647 #[test]
1648 fn component_schema_none_for_unknown() {
1649 let cat = Catalog::build_builtins_only().expect("build");
1650 assert!(
1651 cat.component_schema("NotARealComponent_117_05").is_none(),
1652 "unknown component must return None"
1653 );
1654 assert!(cat.component_schema("").is_none());
1656 }
1657
1658 #[test]
1659 fn component_schema_resolves_every_builtin() {
1660 let cat = Catalog::build_builtins_only().expect("build");
1664 for name in crate::render::BUILTIN_TYPES.iter() {
1665 assert!(
1666 cat.component_schema(name).is_some(),
1667 "built-in '{name}' has no per-component schema"
1668 );
1669 }
1670 }
1671
1672 #[test]
1673 fn components_sorted_yields_ascending_by_name() {
1674 let cat = Catalog::build_builtins_only().expect("build");
1675 let names: Vec<String> = cat
1676 .components_sorted()
1677 .map(|spec| spec.name.clone())
1678 .collect();
1679 assert_eq!(names.len(), crate::render::BUILTIN_TYPES.len());
1680 let mut sorted = names.clone();
1681 sorted.sort();
1682 assert_eq!(
1683 names, sorted,
1684 "components_sorted must yield ascending order"
1685 );
1686
1687 let plugin_names: Vec<String> = cat
1689 .plugin_components_sorted()
1690 .map(|spec| spec.name.clone())
1691 .collect();
1692 let mut plugin_sorted = plugin_names.clone();
1693 plugin_sorted.sort();
1694 assert_eq!(
1695 plugin_names, plugin_sorted,
1696 "plugin_components_sorted must yield ascending order"
1697 );
1698 }
1699
1700 #[test]
1703 fn prompt_under_size_budget() {
1704 let cat = Catalog::build_builtins_only().expect("build");
1705 let prompt = cat.prompt();
1706 let bytes = prompt.len();
1707 assert!(
1711 bytes <= 11 * 1024,
1712 "prompt() is {bytes} bytes, exceeds 11 KB budget (CONTEXT D-17)"
1713 );
1714 }
1715
1716 #[test]
1717 fn prompt_mentions_every_builtin() {
1718 let cat = Catalog::build_builtins_only().expect("build");
1719 let prompt = cat.prompt();
1720 for name in crate::render::BUILTIN_TYPES.iter() {
1721 let heading = format!("### {name}\n");
1722 assert!(
1723 prompt.contains(&heading),
1724 "prompt() missing section heading for '{name}'"
1725 );
1726 }
1727 }
1728
1729 #[test]
1730 fn prompt_is_deterministic() {
1731 let cat1 = Catalog::build_builtins_only().expect("build 1");
1732 let cat2 = Catalog::build_builtins_only().expect("build 2");
1733 assert_eq!(
1734 cat1.prompt(),
1735 cat2.prompt(),
1736 "prompt() must be deterministic"
1737 );
1738 }
1739
1740 #[test]
1741 fn prompt_documents_slot_fields() {
1742 let cat = Catalog::build_builtins_only().expect("build");
1745 let prompt = cat.prompt();
1746 let card_start = prompt.find("### Card\n").expect("Card section present");
1747 let card_slice = &prompt[card_start..];
1748 let end = card_slice[3..]
1750 .find("### ")
1751 .map(|i| i + 3)
1752 .unwrap_or(card_slice.len());
1753 let card_section = &card_slice[..end];
1754 assert!(
1755 card_section.contains("Slots: footer"),
1756 "Card section missing 'Slots: footer' line:\n{card_section}"
1757 );
1758 }
1759
1760 #[test]
1761 fn prompt_is_not_raw_json_schema() {
1762 let cat = Catalog::build_builtins_only().expect("build");
1763 let prompt = cat.prompt();
1764 assert!(
1765 prompt.starts_with("## Component Catalog"),
1766 "prompt() should start with Markdown header, not JSON"
1767 );
1768 assert!(
1769 !prompt.contains("\"$schema\""),
1770 "prompt() must not embed raw JSON Schema (ROADMAP caveat)"
1771 );
1772 }
1773
1774 #[test]
1775 fn catalog_contains_checkbox_group() {
1776 let cat = Catalog::build_builtins_only().expect("build");
1777 assert!(
1778 cat.component_schema("CheckboxGroup").is_some(),
1779 "CheckboxGroup must be registered in BUILTIN_SPECS as an alias for CheckboxList"
1780 );
1781 }
1782
1783 #[test]
1784 fn global_catalog_includes_stream_text() {
1785 let cat = Catalog::build_builtins_only().expect("build");
1786 assert!(
1787 cat.components.contains_key("StreamText"),
1788 "catalog must include StreamText"
1789 );
1790 let spec = &cat.components["StreamText"];
1791 assert_eq!(spec.name, "StreamText");
1792 assert!(
1793 spec.description.contains("event: done"),
1794 "StreamText description must mention 'event: done'; got: {}",
1795 spec.description
1796 );
1797 assert!(
1798 spec.props_schema.is_object(),
1799 "StreamText props_schema must be a JSON object"
1800 );
1801 assert!(!spec.is_plugin);
1802 }
1803}