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, SegmentedControlProps,
36 SelectProps, SeparatorProps, SidebarLayoutProps, SidebarProps, SkeletonProps, StatCardProps,
37 StreamTextProps, SwitchProps, TableProps, 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 (
338 "SegmentedControl",
339 "Connected button cluster — date scrollers, view toggles, mode pickers. Items via literal or data_path.",
340 || to_value(schema_for!(SegmentedControlProps)).unwrap(),
341 &[],
342 ),
343 (
344 "SidebarLayout",
345 "Two-column layout with sticky vertical nav (left) and main content slot (right). Mobile-collapsing.",
346 || to_value(schema_for!(SidebarLayoutProps)).unwrap(),
347 &[],
348 ),
349 (
351 "Form",
352 "Form container with action binding and field components.",
353 || to_value(schema_for!(FormProps)).unwrap(),
354 &[],
355 ),
356 (
357 "Input",
358 "Text input with type variants, validation error, data_path pre-fill.",
359 || to_value(schema_for!(InputProps)).unwrap(),
360 &[],
361 ),
362 (
363 "Select",
364 "Dropdown select with options, error, data_path pre-fill.",
365 || to_value(schema_for!(SelectProps)).unwrap(),
366 &[],
367 ),
368 (
369 "Checkbox",
370 "Boolean checkbox with label, description, data binding.",
371 || to_value(schema_for!(CheckboxProps)).unwrap(),
372 &[],
373 ),
374 (
375 "Switch",
376 "Toggle switch (visual alternative to Checkbox); auto-submit when `action` set.",
377 || to_value(schema_for!(SwitchProps)).unwrap(),
378 &[],
379 ),
380 (
381 "CheckboxList",
382 "Multi-select checkbox group from static options or data-driven array. \
383 Each checked option submits as field=value.",
384 || to_value(schema_for!(CheckboxListProps)).unwrap(),
385 &[],
386 ),
387 (
388 "CheckboxGroup",
389 "Multi-select checkbox group (alias for CheckboxList). Each checked option \
390 submits as field=value with array-submit semantics. Identical props to \
391 CheckboxList; see that entry for full schema.",
392 || to_value(schema_for!(CheckboxListProps)).unwrap(),
393 &[],
394 ),
395 (
397 "Table",
398 "Data table with columns, row_actions, sorting, empty_message.",
399 || to_value(schema_for!(TableProps)).unwrap(),
400 &[],
401 ),
402 (
403 "DataTable",
404 "Stripe-style alternating-row table with per-row DropdownMenu and mobile card fallback.",
405 || to_value(schema_for!(DataTableProps)).unwrap(),
406 &[],
407 ),
408 (
409 "MediaCardGrid",
410 "Responsive card grid backed by a data array. Each card shows an optional screenshot image, title, description, status badge, and per-row dropdown actions.",
411 || to_value(schema_for!(MediaCardGridProps)).unwrap(),
412 &[],
413 ),
414];
415
416fn sanitize_schema(mut schema: Value) -> Value {
424 fn walk(v: &mut Value) {
425 if let Some(obj) = v.as_object_mut() {
426 if let Some(defs) = obj.remove("definitions") {
427 obj.entry("$defs".to_string()).or_insert(defs);
428 }
429 if let Some(Value::String(ref_str)) = obj.get_mut("$ref") {
430 if let Some(suffix) = ref_str.strip_prefix("#/definitions/") {
431 *ref_str = format!("#/$defs/{suffix}");
432 }
433 }
434 let keys: Vec<String> = obj.keys().cloned().collect();
436 for k in keys {
437 if let Some(child) = obj.get_mut(&k) {
438 walk(child);
439 }
440 }
441 } else if let Some(arr) = v.as_array_mut() {
442 for item in arr.iter_mut() {
443 walk(item);
444 }
445 }
446 }
447 walk(&mut schema);
448 schema
449}
450
451fn hoist_defs(schema: &mut Value, shared_defs: &mut serde_json::Map<String, Value>) {
460 if let Some(obj) = schema.as_object_mut() {
461 if let Some(Value::Object(defs)) = obj.remove("$defs") {
462 for (k, v) in defs {
463 shared_defs.entry(k).or_insert(v);
464 }
465 }
466 }
467}
468
469fn assemble_full_schema(per_component: &HashMap<String, Value>) -> Result<Value, CatalogError> {
481 let mut action_schema = sanitize_schema(to_value(schema_for!(crate::action::Action))?);
483 let mut visibility_schema =
484 sanitize_schema(to_value(schema_for!(crate::visibility::Visibility))?);
485
486 let mut shared_defs: serde_json::Map<String, Value> = serde_json::Map::new();
488 hoist_defs(&mut action_schema, &mut shared_defs);
489 hoist_defs(&mut visibility_schema, &mut shared_defs);
490
491 let mut names: Vec<&String> = per_component.keys().collect();
495 names.sort();
496 let one_of: Vec<Value> = names
497 .into_iter()
498 .map(|name| {
499 let mut props_schema = per_component[name].clone();
500 hoist_defs(&mut props_schema, &mut shared_defs);
502 serde_json::json!({
503 "allOf": [
504 {
505 "type": "object",
506 "required": ["type"],
507 "properties": {
508 "type": { "const": name }
509 }
510 },
511 {
512 "type": "object",
513 "properties": {
514 "props": props_schema,
515 "children": { "type": "array", "items": { "type": "string" } },
516 "action": { "$ref": "#/$defs/Action" },
517 "visible": { "$ref": "#/$defs/Visibility" }
518 }
519 }
520 ]
521 })
522 })
523 .collect();
524
525 shared_defs
528 .entry("Action".to_string())
529 .or_insert(action_schema);
530 shared_defs
531 .entry("Visibility".to_string())
532 .or_insert(visibility_schema);
533 shared_defs.insert(
535 "Element".to_string(),
536 serde_json::json!({ "oneOf": one_of }),
537 );
538
539 Ok(serde_json::json!({
540 "$schema": "https://json-schema.org/draft/2020-12/schema",
541 "$id": "ferro-json-ui/v2",
542 "type": "object",
543 "required": ["$schema", "root", "elements"],
544 "properties": {
545 "$schema": { "const": "ferro-json-ui/v2" },
546 "root": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,127}$" },
547 "elements": {
548 "type": "object",
549 "additionalProperties": { "$ref": "#/$defs/Element" }
550 },
551 "title": { "type": ["string", "null"] },
552 "layout": { "type": ["string", "null"] },
553 "data": true
554 },
555 "$defs": shared_defs
556 }))
557}
558
559impl Catalog {
562 pub fn build() -> Result<Self, CatalogError> {
573 if BUILTIN_SPECS.len() != crate::render::BUILTIN_TYPES.len() {
577 return Err(CatalogError::BuildFailed(format!(
578 "BUILTIN_SPECS has {} entries but BUILTIN_TYPES has {} — \
579 add an entry to BUILTIN_SPECS or remove from BUILTIN_TYPES",
580 BUILTIN_SPECS.len(),
581 crate::render::BUILTIN_TYPES.len(),
582 )));
583 }
584
585 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
587 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len() * 2);
588 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
589 let raw = schema_fn();
590 let schema = sanitize_schema(raw);
591 per_component_schemas.insert((*name).to_string(), schema.clone());
592 components.insert(
593 (*name).to_string(),
594 ComponentSpec {
595 name: (*name).to_string(),
596 description: (*desc).to_string(),
597 props_schema: schema,
598 is_plugin: false,
599 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
600 },
601 );
602 }
603
604 let mut plugin_components = HashMap::new();
610 for plugin_type in crate::plugin::registered_plugin_types() {
611 if components.contains_key(&plugin_type) {
613 continue;
614 }
615 let raw = crate::plugin::with_plugin(&plugin_type, |p| p.props_schema())
616 .unwrap_or(Value::Null);
617 let schema = sanitize_schema(raw);
618 if jsonschema::validator_for(&schema).is_err() {
620 return Err(CatalogError::BuildFailed(format!(
621 "plugin '{plugin_type}' returned an invalid JSON Schema"
622 )));
623 }
624 per_component_schemas.insert(plugin_type.clone(), schema.clone());
625 plugin_components.insert(
626 plugin_type.clone(),
627 ComponentSpec {
628 name: plugin_type,
629 description: String::from("Plugin component."),
630 props_schema: schema,
631 is_plugin: true,
632 slot_fields: Vec::new(),
633 },
634 );
635 }
636
637 let full_schema = assemble_full_schema(&per_component_schemas)?;
639
640 let validator = jsonschema::validator_for(&full_schema)
642 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
643
644 Ok(Catalog {
645 components,
646 plugin_components,
647 full_schema,
648 per_component_schemas,
649 validator,
650 })
651 }
652
653 pub fn json_schema(&self) -> &Value {
660 &self.full_schema
661 }
662
663 pub fn validate(&self, spec: &crate::spec::Spec) -> Result<(), Vec<CatalogError>> {
683 let mut errors: Vec<CatalogError> = Vec::new();
684
685 for (id, el) in &spec.elements {
687 let known = self.components.contains_key(&el.type_name)
688 || self.plugin_components.contains_key(&el.type_name);
689 if !known {
690 errors.push(CatalogError::UnknownType {
691 element_id: id.clone(),
692 type_name: el.type_name.clone(),
693 });
694 }
695 }
696 if !errors.is_empty() {
701 return Err(errors);
702 }
703
704 for (id, el) in &spec.elements {
706 if let Some(schema) = self.per_component_schemas.get(&el.type_name) {
707 if el.props.is_null() {
712 continue;
713 }
714 let v = match jsonschema::validator_for(schema) {
718 Ok(v) => v,
719 Err(e) => {
720 errors.push(CatalogError::BuildFailed(format!(
721 "compiling per-component schema for '{}': {e}",
722 el.type_name
723 )));
724 continue;
725 }
726 };
727 let validation_props = strip_expr_objects(&el.props);
733 let mut per_elem_errs: Vec<String> = Vec::new();
734 for err in v.iter_errors(&validation_props) {
735 per_elem_errs.push(format!("{}: {}", err.instance_path(), err));
736 }
737 if !per_elem_errs.is_empty() {
738 errors.push(CatalogError::PropsInvalid {
739 element_id: id.clone(),
740 type_name: el.type_name.clone(),
741 errors: per_elem_errs,
742 });
743 }
744 }
745 }
746
747 let spec_value = match serde_json::to_value(spec) {
749 Ok(v) => v,
750 Err(e) => {
751 errors.push(CatalogError::SchemaSerialization(e));
752 return Err(errors);
753 }
754 };
755 let stripped_spec_value = strip_expr_objects(&spec_value);
757 let mut envelope_errs: Vec<String> = Vec::new();
758 for err in self.validator.iter_errors(&stripped_spec_value) {
759 envelope_errs.push(format!("{}: {}", err.instance_path(), err));
760 }
761 if !envelope_errs.is_empty() {
762 errors.push(CatalogError::SpecInvalid {
763 errors: envelope_errs,
764 });
765 }
766
767 if errors.is_empty() {
768 Ok(())
769 } else {
770 Err(errors)
771 }
772 }
773
774 pub fn component_schema(&self, type_name: &str) -> Option<&Value> {
788 self.per_component_schemas.get(type_name)
789 }
790
791 pub fn components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
798 let mut entries: Vec<&ComponentSpec> = self.components.values().collect();
799 entries.sort_by(|a, b| a.name.cmp(&b.name));
800 entries.into_iter()
801 }
802
803 pub fn plugin_components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
809 let mut entries: Vec<&ComponentSpec> = self.plugin_components.values().collect();
810 entries.sort_by(|a, b| a.name.cmp(&b.name));
811 entries.into_iter()
812 }
813
814 pub fn prompt(&self) -> String {
830 let mut out = String::with_capacity(8 * 1024);
831 out.push_str("## Component Catalog\n\n");
832 out.push_str("Slot fields are Vec<String> of element IDs; body children come from Element.children.\n\n");
833 for spec in self.components_sorted() {
834 render_component_section(&mut out, spec);
835 }
836 if self.plugin_components.is_empty() {
837 return out;
838 }
839 out.push_str("## Plugin Components\n\n");
840 for spec in self.plugin_components_sorted() {
841 render_component_section(&mut out, spec);
842 }
843 out
844 }
845}
846
847fn render_component_section(out: &mut String, spec: &ComponentSpec) {
864 out.push_str("### ");
865 out.push_str(&spec.name);
866 out.push('\n');
867 out.push_str(&spec.description);
868 out.push('\n');
869
870 let props_line = render_props_line(&spec.props_schema);
871 if !props_line.is_empty() {
872 out.push_str("Props: ");
873 out.push_str(&props_line);
874 out.push('\n');
875 }
876 if !spec.slot_fields.is_empty() {
877 out.push_str("Slots: ");
878 out.push_str(&spec.slot_fields.join(", "));
879 out.push('\n');
880 }
881 out.push('\n');
882}
883
884fn render_props_line(schema: &Value) -> String {
895 let Some(obj) = schema.as_object() else {
896 return String::new();
897 };
898 let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
899 return String::new();
900 };
901 let required: std::collections::HashSet<&str> = obj
902 .get("required")
903 .and_then(|v| v.as_array())
904 .map(|arr| {
905 arr.iter()
906 .filter_map(|v| v.as_str())
907 .collect::<std::collections::HashSet<_>>()
908 })
909 .unwrap_or_default();
910
911 let parts: Vec<String> = props
912 .iter()
913 .map(|(name, field_schema)| {
914 let ty = render_field_type(field_schema, required.contains(name.as_str()));
915 format!("{name} ({ty})")
916 })
917 .collect();
918 parts.join(", ")
919}
920
921fn render_field_type(schema: &Value, is_required: bool) -> String {
923 if let Some(variants) = schema.get("enum").and_then(|v| v.as_array()) {
925 let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
926 let inner = render_enum_inline(&names);
927 return wrap_optional(inner, is_required);
928 }
929 for key in ["anyOf", "oneOf"] {
931 if let Some(arr) = schema.get(key).and_then(|v| v.as_array()) {
932 let has_null = arr
933 .iter()
934 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
935 let non_null: Vec<&Value> = arr
936 .iter()
937 .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
938 .collect();
939 if has_null && non_null.len() == 1 {
940 let inner = render_field_type(non_null[0], true);
941 return format!("Option<{inner}>");
942 }
943 }
944 }
945 if let Some(types) = schema.get("type").and_then(|v| v.as_array()) {
947 let non_null: Vec<&str> = types
948 .iter()
949 .filter_map(|v| v.as_str())
950 .filter(|s| *s != "null")
951 .collect();
952 let has_null = types.iter().any(|v| v.as_str() == Some("null"));
953 if has_null && non_null.len() == 1 {
954 return format!("Option<{}>", rust_for_json_type(non_null[0], schema));
955 }
956 }
957 if let Some(t) = schema.get("type").and_then(|v| v.as_str()) {
959 let inner = rust_for_json_type(t, schema);
960 return wrap_optional(inner, is_required);
961 }
962 wrap_optional("<see schema>".to_string(), is_required)
964}
965
966fn rust_for_json_type(t: &str, schema: &Value) -> String {
968 match t {
969 "string" => "String".to_string(),
970 "integer" => "i64".to_string(),
971 "number" => "f64".to_string(),
972 "boolean" => "bool".to_string(),
973 "array" => {
974 if let Some(items) = schema.get("items") {
975 let inner = render_field_type(items, true);
976 format!("Vec<{inner}>")
977 } else {
978 "Vec<Value>".to_string()
979 }
980 }
981 "object" => "Object".to_string(),
982 other => other.to_string(),
983 }
984}
985
986fn render_enum_inline(variants: &[&str]) -> String {
988 if variants.len() <= 8 {
989 variants.join("|")
990 } else {
991 format!("one of {} — see schema", variants.len())
992 }
993}
994
995fn wrap_optional(inner: String, is_required: bool) -> String {
997 if is_required {
998 inner
999 } else {
1000 format!("Option<{inner}>")
1001 }
1002}
1003
1004fn strip_expr_objects(val: &Value) -> Value {
1013 match val {
1014 Value::Object(map) => {
1015 if map.len() == 1 && (map.contains_key("$data") || map.contains_key("$template")) {
1016 Value::String(String::new())
1017 } else {
1018 Value::Object(
1019 map.iter()
1020 .map(|(k, v)| (k.clone(), strip_expr_objects(v)))
1021 .collect(),
1022 )
1023 }
1024 }
1025 Value::Array(arr) => Value::Array(arr.iter().map(strip_expr_objects).collect()),
1026 other => other.clone(),
1027 }
1028}
1029
1030pub fn global_catalog() -> &'static Catalog {
1043 static GLOBAL_CATALOG: OnceLock<Catalog> = OnceLock::new();
1044 GLOBAL_CATALOG.get_or_init(|| {
1045 Catalog::build().expect("catalog build failed — see CatalogError for details")
1046 })
1047}
1048
1049#[cfg(test)]
1052impl Catalog {
1053 pub(crate) fn build_builtins_only() -> Result<Self, CatalogError> {
1058 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
1059 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len());
1060 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
1061 let raw = schema_fn();
1062 let schema = sanitize_schema(raw);
1063 per_component_schemas.insert((*name).to_string(), schema.clone());
1064 components.insert(
1065 (*name).to_string(),
1066 ComponentSpec {
1067 name: (*name).to_string(),
1068 description: (*desc).to_string(),
1069 props_schema: schema,
1070 is_plugin: false,
1071 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
1072 },
1073 );
1074 }
1075 let full_schema = assemble_full_schema(&per_component_schemas)?;
1076 let validator = jsonschema::validator_for(&full_schema)
1077 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
1078 Ok(Catalog {
1079 components,
1080 plugin_components: HashMap::new(),
1081 full_schema,
1082 per_component_schemas,
1083 validator,
1084 })
1085 }
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090 use super::*;
1091
1092 #[test]
1093 fn builtin_types_count_drift_guard() {
1094 assert_eq!(crate::render::BUILTIN_TYPES.len(), 47);
1101 }
1102
1103 #[test]
1104 fn builtin_specs_len_matches_dispatch() {
1105 assert_eq!(BUILTIN_SPECS.len(), crate::render::BUILTIN_TYPES.len());
1108 }
1109
1110 #[test]
1111 fn builtin_specs_names_match_dispatch() {
1112 use std::collections::HashSet;
1113 let specs: HashSet<&str> = BUILTIN_SPECS.iter().map(|(n, ..)| *n).collect();
1114 let types: HashSet<&str> = crate::render::BUILTIN_TYPES.iter().copied().collect();
1115 assert_eq!(specs, types, "BUILTIN_SPECS names must match BUILTIN_TYPES");
1116 }
1117
1118 #[test]
1119 fn build_populates_all_builtins() {
1120 let cat = Catalog::build_builtins_only().expect("build succeeds");
1122 for name in crate::render::BUILTIN_TYPES.iter() {
1123 assert!(
1124 cat.components.contains_key(*name),
1125 "built-in '{name}' missing from catalog.components"
1126 );
1127 let spec = &cat.components[*name];
1128 assert_eq!(spec.name, *name);
1129 assert!(
1130 !spec.description.is_empty(),
1131 "'{name}' has empty description"
1132 );
1133 assert!(
1134 spec.props_schema.is_object(),
1135 "'{name}' props_schema is not a JSON object"
1136 );
1137 assert!(!spec.is_plugin);
1138 }
1139 }
1140
1141 #[test]
1142 fn build_card_has_footer_slot() {
1143 let cat = Catalog::build_builtins_only().expect("build succeeds");
1145 let card = &cat.components["Card"];
1146 assert_eq!(card.slot_fields, vec!["footer"]);
1147 }
1148
1149 #[test]
1150 fn build_modal_has_footer_slot() {
1151 let cat = Catalog::build_builtins_only().expect("build succeeds");
1153 let modal = &cat.components["Modal"];
1154 assert_eq!(modal.slot_fields, vec!["footer"]);
1155 }
1156
1157 #[test]
1158 fn build_pageheader_has_actions_slot() {
1159 let cat = Catalog::build_builtins_only().expect("build succeeds");
1161 let ph = &cat.components["PageHeader"];
1162 assert_eq!(ph.slot_fields, vec!["actions"]);
1163 }
1164
1165 #[test]
1166 fn build_text_has_no_slots() {
1167 let cat = Catalog::build_builtins_only().expect("build succeeds");
1169 assert!(cat.components["Text"].slot_fields.is_empty());
1170 }
1171
1172 #[test]
1173 fn build_populates_per_component_schemas() {
1174 let cat = Catalog::build_builtins_only().expect("build succeeds");
1176 assert_eq!(
1177 cat.per_component_schemas.len(),
1178 BUILTIN_SPECS.len() + cat.plugin_components.len()
1179 );
1180 }
1181
1182 #[test]
1183 fn sanitize_schema_rewrites_definitions_to_dollar_defs() {
1184 let raw = serde_json::json!({
1185 "type": "object",
1186 "definitions": { "Foo": { "type": "string" } },
1187 "properties": {
1188 "x": { "$ref": "#/definitions/Foo" }
1189 }
1190 });
1191 let out = sanitize_schema(raw);
1192 assert!(out.get("definitions").is_none());
1193 assert!(out.get("$defs").is_some());
1194 assert_eq!(
1195 out["properties"]["x"]["$ref"].as_str().unwrap(),
1196 "#/$defs/Foo"
1197 );
1198 }
1199
1200 #[test]
1201 fn sanitize_schema_is_idempotent() {
1202 let raw = serde_json::json!({
1203 "type": "object",
1204 "$defs": { "Foo": { "type": "string" } },
1205 "properties": {
1206 "x": { "$ref": "#/$defs/Foo" }
1207 }
1208 });
1209 let once = sanitize_schema(raw.clone());
1210 let twice = sanitize_schema(once.clone());
1211 assert_eq!(once, twice);
1212 assert!(twice.get("definitions").is_none());
1214 assert!(twice.get("$defs").is_some());
1215 }
1216
1217 #[test]
1218 fn json_schema_has_spec_envelope_shape() {
1219 let cat = Catalog::build_builtins_only().expect("build");
1222 let schema = cat.json_schema();
1223 assert_eq!(schema["$id"], "ferro-json-ui/v2");
1224 assert_eq!(schema["type"], "object");
1225 let required: Vec<&str> = schema["required"]
1226 .as_array()
1227 .unwrap()
1228 .iter()
1229 .map(|v| v.as_str().unwrap())
1230 .collect();
1231 assert!(required.contains(&"$schema"));
1232 assert!(required.contains(&"root"));
1233 assert!(required.contains(&"elements"));
1234 }
1235
1236 #[test]
1237 fn json_schema_has_action_and_visibility_defs() {
1238 let cat = Catalog::build_builtins_only().expect("build");
1239 let schema = cat.json_schema();
1240 assert!(
1241 schema["$defs"]["Action"].is_object(),
1242 "$defs/Action missing"
1243 );
1244 assert!(
1245 schema["$defs"]["Visibility"].is_object(),
1246 "$defs/Visibility missing"
1247 );
1248 assert!(
1249 schema["$defs"]["Element"].is_object(),
1250 "$defs/Element missing"
1251 );
1252 }
1253
1254 #[test]
1255 fn json_schema_oneof_covers_all_builtins() {
1256 let cat = Catalog::build_builtins_only().expect("build");
1257 let schema = cat.json_schema();
1258 let one_of = schema["$defs"]["Element"]["oneOf"]
1260 .as_array()
1261 .expect("Element.oneOf is an array");
1262
1263 let mut discriminators: std::collections::HashSet<String> =
1265 std::collections::HashSet::new();
1266 for variant in one_of {
1267 let c = variant["allOf"][0]["properties"]["type"]["const"]
1268 .as_str()
1269 .expect("every variant pins a type const");
1270 discriminators.insert(c.to_string());
1271 }
1272
1273 for name in crate::render::BUILTIN_TYPES.iter() {
1274 assert!(
1275 discriminators.contains(*name),
1276 "oneOf is missing discriminator for '{name}'"
1277 );
1278 }
1279
1280 assert_eq!(
1282 discriminators.len(),
1283 crate::render::BUILTIN_TYPES.len(),
1284 "oneOf variant count mismatch"
1285 );
1286 }
1287
1288 #[test]
1289 fn json_schema_is_valid() {
1290 use jsonschema::draft202012;
1291 let cat = Catalog::build_builtins_only().expect("build");
1292 let schema = cat.json_schema();
1293 assert!(
1294 draft202012::meta::is_valid(schema),
1295 "assembled full_schema did not meta-validate as Draft 2020-12"
1296 );
1297 }
1298
1299 #[test]
1300 fn validator_is_compiled_once_and_usable() {
1301 let cat = Catalog::build_builtins_only().expect("build");
1302 let minimal_valid = serde_json::json!({
1306 "$schema": "ferro-json-ui/v2",
1307 "root": "r",
1308 "elements": {
1309 "r": { "type": "Text", "props": { "content": "hi" } }
1310 }
1311 });
1312 assert!(cat.validator.is_valid(&minimal_valid));
1314 }
1315
1316 #[test]
1317 fn validator_rejects_wrong_schema_version() {
1318 let cat = Catalog::build_builtins_only().expect("build");
1319 let wrong_version = serde_json::json!({
1320 "$schema": "ferro-json-ui/v99-wrong",
1321 "root": "r",
1322 "elements": {
1323 "r": { "type": "Text", "props": { "content": "hi" } }
1324 }
1325 });
1326 assert!(
1327 !cat.validator.is_valid(&wrong_version),
1328 "validator should reject unknown $schema version via const"
1329 );
1330 }
1331
1332 #[test]
1333 fn oneof_variants_are_deterministic_sorted() {
1334 let cat1 = Catalog::build_builtins_only().expect("build 1");
1335 let cat2 = Catalog::build_builtins_only().expect("build 2");
1336 assert_eq!(
1338 serde_json::to_string(cat1.json_schema()).unwrap(),
1339 serde_json::to_string(cat2.json_schema()).unwrap()
1340 );
1341 }
1342
1343 fn test_spec_with(type_name: &str, props: Value) -> crate::spec::Spec {
1347 use crate::spec::{Element, Spec};
1348 use std::collections::HashMap;
1349 let mut elements = HashMap::new();
1350 elements.insert(
1351 "r".to_string(),
1352 Element {
1353 type_name: type_name.to_string(),
1354 props,
1355 children: Vec::new(),
1356 action: None,
1357 visible: None,
1358 each: None,
1359 if_: None,
1360 },
1361 );
1362 Spec {
1363 schema: crate::spec::SCHEMA_VERSION.to_string(),
1364 root: "r".to_string(),
1365 elements,
1366 title: None,
1367 layout: None,
1368 data: Value::Null,
1369 }
1370 }
1371
1372 #[test]
1373 fn validate_positive_per_type() {
1374 let cat = Catalog::build_builtins_only().expect("build");
1377 let cases: Vec<(&str, Value)> = vec![
1378 ("Text", serde_json::json!({ "content": "hi" })),
1379 ("Button", serde_json::json!({ "label": "Save" })),
1380 ("Badge", serde_json::json!({ "label": "New" })),
1381 ("Separator", serde_json::json!({})),
1382 ];
1383 for (ty, props) in cases {
1384 let spec = test_spec_with(ty, props.clone());
1385 match cat.validate(&spec) {
1386 Ok(()) => {}
1387 Err(errs) => panic!("validate({ty}) failed: {errs:?}"),
1388 }
1389 }
1390 }
1391
1392 #[test]
1393 fn validate_unknown_type() {
1394 let cat = Catalog::build_builtins_only().expect("build");
1395 let spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1396 let errs = cat.validate(&spec).expect_err("should fail");
1397 assert!(
1398 errs.iter().any(|e| matches!(
1399 e,
1400 CatalogError::UnknownType { type_name, .. } if type_name == "NotARealComponent"
1401 )),
1402 "expected UnknownType for NotARealComponent; got {errs:?}"
1403 );
1404 }
1405
1406 #[test]
1407 fn validate_missing_required_prop() {
1408 let cat = Catalog::build_builtins_only().expect("build");
1411 let spec = test_spec_with("Card", serde_json::json!({}));
1412 let errs = cat.validate(&spec).expect_err("should fail");
1413 assert!(
1414 errs.iter().any(|e| matches!(
1415 e,
1416 CatalogError::PropsInvalid { type_name, .. } if type_name == "Card"
1417 )),
1418 "expected PropsInvalid for missing required 'title'; got {errs:?}"
1419 );
1420 }
1421
1422 #[test]
1423 fn validate_bad_schema_version() {
1424 let cat = Catalog::build_builtins_only().expect("build");
1425 let mut spec = test_spec_with("Text", serde_json::json!({ "content": "hi" }));
1426 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1427 let errs = cat.validate(&spec).expect_err("should fail");
1428 assert!(
1429 errs.iter()
1430 .any(|e| matches!(e, CatalogError::SpecInvalid { .. })),
1431 "expected SpecInvalid for wrong $schema version; got {errs:?}"
1432 );
1433 }
1434
1435 #[test]
1436 fn validate_pre_dispatch_short_circuits() {
1437 let cat = Catalog::build_builtins_only().expect("build");
1440 let mut spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1441 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1442 let errs = cat.validate(&spec).expect_err("should fail");
1443
1444 let has_unknown = errs
1445 .iter()
1446 .any(|e| matches!(e, CatalogError::UnknownType { .. }));
1447 let has_spec_invalid = errs
1448 .iter()
1449 .any(|e| matches!(e, CatalogError::SpecInvalid { .. }));
1450 let has_props_invalid = errs
1451 .iter()
1452 .any(|e| matches!(e, CatalogError::PropsInvalid { .. }));
1453
1454 assert!(has_unknown, "expected UnknownType");
1455 assert!(
1456 !has_spec_invalid,
1457 "Stage 3 ran despite Stage 1 failing: {errs:?}"
1458 );
1459 assert!(
1460 !has_props_invalid,
1461 "Stage 2 ran despite Stage 1 failing: {errs:?}"
1462 );
1463 }
1464
1465 #[test]
1466 fn validator_is_cached_not_recompiled() {
1467 let cat = Catalog::build_builtins_only().expect("build");
1471 for _ in 0..100 {
1472 let spec = test_spec_with("Text", serde_json::json!({ "content": "x" }));
1473 assert!(cat.validate(&spec).is_ok());
1474 }
1475 }
1476
1477 #[test]
1478 fn validate_accumulates_multiple_errors_across_elements() {
1479 use crate::spec::{Element, Spec};
1481 use std::collections::HashMap;
1482 let cat = Catalog::build_builtins_only().expect("build");
1483 let mut elements = HashMap::new();
1484 elements.insert(
1485 "a".to_string(),
1486 Element {
1487 type_name: "Card".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 elements.insert(
1497 "b".to_string(),
1498 Element {
1499 type_name: "Button".to_string(),
1500 props: serde_json::json!({}), children: Vec::new(),
1502 action: None,
1503 visible: None,
1504 each: None,
1505 if_: None,
1506 },
1507 );
1508 let spec = Spec {
1509 schema: crate::spec::SCHEMA_VERSION.to_string(),
1510 root: "a".to_string(),
1511 elements,
1512 title: None,
1513 layout: None,
1514 data: Value::Null,
1515 };
1516 let errs = cat.validate(&spec).expect_err("should fail");
1517 let props_invalid_count = errs
1518 .iter()
1519 .filter(|e| matches!(e, CatalogError::PropsInvalid { .. }))
1520 .count();
1521 assert!(
1522 props_invalid_count >= 2,
1523 "expected at least 2 PropsInvalid errors; got {errs:?}"
1524 );
1525 }
1526
1527 #[test]
1533 fn build_discovers_plugins_and_rejects_invalid_schema() {
1534 use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
1535
1536 struct GoodPlugin;
1537 impl JsonUiPlugin for GoodPlugin {
1538 fn component_type(&self) -> &str {
1539 "GoodPlugin_117"
1540 }
1541 fn props_schema(&self) -> Value {
1542 serde_json::json!({ "type": "object" })
1543 }
1544 fn render(&self, _: &Value, _: &Value) -> String {
1545 String::new()
1546 }
1547 fn css_assets(&self) -> Vec<Asset> {
1548 vec![]
1549 }
1550 fn js_assets(&self) -> Vec<Asset> {
1551 vec![]
1552 }
1553 fn init_script(&self) -> Option<String> {
1554 None
1555 }
1556 }
1557
1558 register_plugin(GoodPlugin);
1559
1560 let cat = Catalog::build().expect("build succeeds with valid plugin only");
1562 assert!(
1563 cat.plugin_components.contains_key("GoodPlugin_117"),
1564 "plugin 'GoodPlugin_117' should have been discovered"
1565 );
1566 assert!(cat.plugin_components["GoodPlugin_117"].is_plugin);
1567
1568 struct BadPlugin;
1570 impl JsonUiPlugin for BadPlugin {
1571 fn component_type(&self) -> &str {
1572 "BadPlugin_117"
1573 }
1574 fn props_schema(&self) -> Value {
1575 serde_json::json!({ "type": 42 })
1578 }
1579 fn render(&self, _: &Value, _: &Value) -> String {
1580 String::new()
1581 }
1582 fn css_assets(&self) -> Vec<Asset> {
1583 vec![]
1584 }
1585 fn js_assets(&self) -> Vec<Asset> {
1586 vec![]
1587 }
1588 fn init_script(&self) -> Option<String> {
1589 None
1590 }
1591 }
1592
1593 register_plugin(BadPlugin);
1594 match Catalog::build() {
1595 Err(CatalogError::BuildFailed(msg)) => {
1596 assert!(
1597 msg.contains("BadPlugin_117"),
1598 "error should mention plugin name, got: {msg}"
1599 );
1600 }
1601 Err(other) => panic!("expected BuildFailed mentioning BadPlugin_117, got: {other:?}"),
1602 Ok(_) => panic!("expected build to fail due to invalid plugin schema"),
1603 }
1604 }
1605
1606 #[test]
1609 fn component_schema_returns_props_only() {
1610 let cat = Catalog::build_builtins_only().expect("build");
1614 let schema = cat
1615 .component_schema("Card")
1616 .expect("Card is a built-in component");
1617
1618 let obj = schema
1622 .as_object()
1623 .expect("Card props schema is a JSON object");
1624
1625 assert!(
1627 obj.contains_key("type") || obj.contains_key("oneOf") || obj.contains_key("anyOf"),
1628 "CardProps schema should be a structural object schema; got {obj:?}"
1629 );
1630
1631 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
1633 assert!(
1634 props.contains_key("title"),
1635 "CardProps schema.properties should include 'title'; got keys: {:?}",
1636 props.keys().collect::<Vec<_>>()
1637 );
1638 } else {
1639 panic!(
1640 "CardProps schema missing top-level 'properties' map — \
1641 sanitizer or Plan 02 may be wrong. Got: {}",
1642 serde_json::to_string_pretty(schema).unwrap_or_default()
1643 );
1644 }
1645
1646 let is_element_wrapper = obj
1649 .get("properties")
1650 .and_then(|v| v.as_object())
1651 .map(|p| p.contains_key("children") && p.contains_key("props"))
1652 .unwrap_or(false);
1653 assert!(
1654 !is_element_wrapper,
1655 "component_schema('Card') returned an Element wrapper; must be Props-only (CONTEXT D-19)"
1656 );
1657 }
1658
1659 #[test]
1660 fn component_schema_none_for_unknown() {
1661 let cat = Catalog::build_builtins_only().expect("build");
1662 assert!(
1663 cat.component_schema("NotARealComponent_117_05").is_none(),
1664 "unknown component must return None"
1665 );
1666 assert!(cat.component_schema("").is_none());
1668 }
1669
1670 #[test]
1671 fn component_schema_resolves_every_builtin() {
1672 let cat = Catalog::build_builtins_only().expect("build");
1676 for name in crate::render::BUILTIN_TYPES.iter() {
1677 assert!(
1678 cat.component_schema(name).is_some(),
1679 "built-in '{name}' has no per-component schema"
1680 );
1681 }
1682 }
1683
1684 #[test]
1685 fn components_sorted_yields_ascending_by_name() {
1686 let cat = Catalog::build_builtins_only().expect("build");
1687 let names: Vec<String> = cat
1688 .components_sorted()
1689 .map(|spec| spec.name.clone())
1690 .collect();
1691 assert_eq!(names.len(), crate::render::BUILTIN_TYPES.len());
1692 let mut sorted = names.clone();
1693 sorted.sort();
1694 assert_eq!(
1695 names, sorted,
1696 "components_sorted must yield ascending order"
1697 );
1698
1699 let plugin_names: Vec<String> = cat
1701 .plugin_components_sorted()
1702 .map(|spec| spec.name.clone())
1703 .collect();
1704 let mut plugin_sorted = plugin_names.clone();
1705 plugin_sorted.sort();
1706 assert_eq!(
1707 plugin_names, plugin_sorted,
1708 "plugin_components_sorted must yield ascending order"
1709 );
1710 }
1711
1712 #[test]
1715 fn prompt_under_size_budget() {
1716 let cat = Catalog::build_builtins_only().expect("build");
1717 let prompt = cat.prompt();
1718 let bytes = prompt.len();
1719 assert!(
1723 bytes <= 11 * 1024,
1724 "prompt() is {bytes} bytes, exceeds 11 KB budget (CONTEXT D-17)"
1725 );
1726 }
1727
1728 #[test]
1729 fn prompt_mentions_every_builtin() {
1730 let cat = Catalog::build_builtins_only().expect("build");
1731 let prompt = cat.prompt();
1732 for name in crate::render::BUILTIN_TYPES.iter() {
1733 let heading = format!("### {name}\n");
1734 assert!(
1735 prompt.contains(&heading),
1736 "prompt() missing section heading for '{name}'"
1737 );
1738 }
1739 }
1740
1741 #[test]
1742 fn prompt_is_deterministic() {
1743 let cat1 = Catalog::build_builtins_only().expect("build 1");
1744 let cat2 = Catalog::build_builtins_only().expect("build 2");
1745 assert_eq!(
1746 cat1.prompt(),
1747 cat2.prompt(),
1748 "prompt() must be deterministic"
1749 );
1750 }
1751
1752 #[test]
1753 fn prompt_documents_slot_fields() {
1754 let cat = Catalog::build_builtins_only().expect("build");
1757 let prompt = cat.prompt();
1758 let card_start = prompt.find("### Card\n").expect("Card section present");
1759 let card_slice = &prompt[card_start..];
1760 let end = card_slice[3..]
1762 .find("### ")
1763 .map(|i| i + 3)
1764 .unwrap_or(card_slice.len());
1765 let card_section = &card_slice[..end];
1766 assert!(
1767 card_section.contains("Slots: footer"),
1768 "Card section missing 'Slots: footer' line:\n{card_section}"
1769 );
1770 }
1771
1772 #[test]
1773 fn prompt_is_not_raw_json_schema() {
1774 let cat = Catalog::build_builtins_only().expect("build");
1775 let prompt = cat.prompt();
1776 assert!(
1777 prompt.starts_with("## Component Catalog"),
1778 "prompt() should start with Markdown header, not JSON"
1779 );
1780 assert!(
1781 !prompt.contains("\"$schema\""),
1782 "prompt() must not embed raw JSON Schema (ROADMAP caveat)"
1783 );
1784 }
1785
1786 #[test]
1787 fn catalog_contains_checkbox_group() {
1788 let cat = Catalog::build_builtins_only().expect("build");
1789 assert!(
1790 cat.component_schema("CheckboxGroup").is_some(),
1791 "CheckboxGroup must be registered in BUILTIN_SPECS as an alias for CheckboxList"
1792 );
1793 }
1794
1795 #[test]
1796 fn global_catalog_includes_stream_text() {
1797 let cat = Catalog::build_builtins_only().expect("build");
1798 assert!(
1799 cat.components.contains_key("StreamText"),
1800 "catalog must include StreamText"
1801 );
1802 let spec = &cat.components["StreamText"];
1803 assert_eq!(spec.name, "StreamText");
1804 assert!(
1805 spec.description.contains("event: done"),
1806 "StreamText description must mention 'event: done'; got: {}",
1807 spec.description
1808 );
1809 assert!(
1810 spec.props_schema.is_object(),
1811 "StreamText props_schema must be a JSON object"
1812 );
1813 assert!(!spec.is_plugin);
1814 }
1815}