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, ActionGroupProps, AlertProps, AvatarProps, BadgeProps, BreadcrumbProps,
31 ButtonGroupProps, ButtonProps, CalendarCellProps, CardProps, CheckboxListProps, CheckboxProps,
32 ChecklistProps, CollapsibleProps, DataTableProps, DescriptionListProps, DetailPageProps,
33 EmptyStateProps, FilterTabsProps, FormProps, FormSectionProps, GridProps, HeaderProps,
34 ImageProps, InputProps, KanbanBoardProps, LiveFragmentProps, MediaCardGridProps, ModalProps,
35 NotificationDropdownProps, NumpadProps, PageHeaderProps, PaginationProps, ProgressProps,
36 QuantityStepperProps, RawHtmlProps, SegmentedControlProps, SelectProps, SelectionPanelProps,
37 SeparatorProps, SidebarLayoutProps, SidebarProps, SkeletonProps, StatCardProps,
38 StreamTextProps, SwitchProps, TableProps, TabsProps, TextProps, TileGridProps, TileProps,
39 ToastProps,
40};
41
42pub struct ComponentSpec {
49 pub name: String,
51 pub description: String,
53 pub props_schema: Value,
55 pub is_plugin: bool,
57 pub slot_fields: Vec<String>,
62}
63
64pub struct Catalog {
70 pub(crate) components: HashMap<String, ComponentSpec>,
72 pub(crate) plugin_components: HashMap<String, ComponentSpec>,
74 pub(crate) full_schema: Value,
76 pub(crate) per_component_schemas: HashMap<String, Value>,
78 pub(crate) validator: jsonschema::Validator,
80}
81
82#[derive(Debug, thiserror::Error)]
84pub enum CatalogError {
85 #[error("unknown component type '{type_name}' at element '{element_id}'")]
87 UnknownType {
88 element_id: String,
90 type_name: String,
92 },
93 #[error("props invalid for '{type_name}' at element '{element_id}': {errors:?}")]
95 PropsInvalid {
96 element_id: String,
98 type_name: String,
100 errors: Vec<String>,
102 },
103 #[error("spec invalid: {errors:?}")]
105 SpecInvalid {
106 errors: Vec<String>,
108 },
109 #[error("catalog build failed: {0}")]
111 BuildFailed(String),
112 #[error("schema serialization error: {0}")]
114 SchemaSerialization(#[from] serde_json::Error),
115}
116
117type SchemaFn = fn() -> Value;
120
121static BUILTIN_SPECS: &[(&str, &str, SchemaFn, &[&str])] = &[
127 (
129 "Text",
130 "Semantic text element (p / h1 / h2 / h3 / span / div / section).",
131 || to_value(schema_for!(TextProps)).unwrap(),
132 &[],
133 ),
134 (
135 "Button",
136 "Interactive button with variant, size, optional icon, and disabled state.",
137 || to_value(schema_for!(ButtonProps)).unwrap(),
138 &[],
139 ),
140 (
141 "Badge",
142 "Small tone-styled status label.",
143 || to_value(schema_for!(BadgeProps)).unwrap(),
144 &[],
145 ),
146 (
147 "Alert",
148 "Inline notice with neutral / success / warning / destructive tones.",
149 || to_value(schema_for!(AlertProps)).unwrap(),
150 &[],
151 ),
152 (
153 "Separator",
154 "Horizontal or vertical divider between content sections.",
155 || to_value(schema_for!(SeparatorProps)).unwrap(),
156 &[],
157 ),
158 (
159 "Progress",
160 "Progress bar with 0–100 percentage value and optional label.",
161 || to_value(schema_for!(ProgressProps)).unwrap(),
162 &[],
163 ),
164 (
165 "Avatar",
166 "Circular user image with fallback initials and size variants.",
167 || to_value(schema_for!(AvatarProps)).unwrap(),
168 &[],
169 ),
170 (
171 "Image",
172 "Image with optional aspect ratio and skeleton fallback on load error.",
173 || to_value(schema_for!(ImageProps)).unwrap(),
174 &[],
175 ),
176 (
177 "Skeleton",
178 "Loading placeholder with configurable width / height / rounding.",
179 || to_value(schema_for!(SkeletonProps)).unwrap(),
180 &[],
181 ),
182 (
183 "Breadcrumb",
184 "Navigation trail of label + optional URL items.",
185 || to_value(schema_for!(BreadcrumbProps)).unwrap(),
186 &[],
187 ),
188 (
189 "Pagination",
190 "Page navigation for paginated data (current / per_page / total).",
191 || to_value(schema_for!(PaginationProps)).unwrap(),
192 &[],
193 ),
194 (
195 "DescriptionList",
196 "Key-value pairs displayed as a description list with optional format.",
197 || to_value(schema_for!(DescriptionListProps)).unwrap(),
198 &[],
199 ),
200 (
201 "EmptyState",
202 "Standardized empty view with title, description, and optional CTA.",
203 || to_value(schema_for!(EmptyStateProps)).unwrap(),
204 &[],
205 ),
206 (
207 "StatCard",
208 "Live-updatable metric card with label, value, icon, SSE target.",
209 || to_value(schema_for!(StatCardProps)).unwrap(),
210 &[],
211 ),
212 (
213 "Checklist",
214 "Onboarding-style checklist with dismissal and server-side state.",
215 || to_value(schema_for!(ChecklistProps)).unwrap(),
216 &[],
217 ),
218 (
219 "Toast",
220 "Declarative notification intent consumed by the runtime JS via data attributes.",
221 || to_value(schema_for!(ToastProps)).unwrap(),
222 &[],
223 ),
224 (
225 "NotificationDropdown",
226 "Dropdown listing notification items with icons, timestamps, read state.",
227 || to_value(schema_for!(NotificationDropdownProps)).unwrap(),
228 &[],
229 ),
230 (
231 "Sidebar",
232 "Dashboard sidebar with fixed top / bottom items and collapsible nav groups.",
233 || to_value(schema_for!(SidebarProps)).unwrap(),
234 &[],
235 ),
236 (
237 "Header",
238 "Dashboard top bar with business name, notification badge, user menu.",
239 || to_value(schema_for!(HeaderProps)).unwrap(),
240 &[],
241 ),
242 (
243 "CalendarCell",
244 "Single day in a month grid with today highlight, out-of-month muting, event dots.",
245 || to_value(schema_for!(CalendarCellProps)).unwrap(),
246 &[],
247 ),
248 (
249 "ActionCard",
250 "Clickable row with icon, title, description, chevron, and tone-colored left border.",
251 || to_value(schema_for!(ActionCardProps)).unwrap(),
252 &[],
253 ),
254 (
255 "Tile",
256 "Touch-friendly tile with name, price, and +/- quantity controls.",
257 || to_value(schema_for!(TileProps)).unwrap(),
258 &[],
259 ),
260 (
261 "FilterTabs",
262 "Standalone touch filter-tab strip that filters visible tiles client-side by category token; renders an All tab plus one tab per item.",
263 || to_value(schema_for!(FilterTabsProps)).unwrap(),
264 &[],
265 ),
266 (
267 "RawHtml",
268 "Server-injected HTML island. CONSUMER is responsible for sanitization — see docs/src/json-ui/plugins.md.",
269 || to_value(schema_for!(RawHtmlProps)).unwrap(),
270 &[],
271 ),
272 (
273 "StreamText",
274 "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.",
275 || to_value(schema_for!(StreamTextProps)).unwrap(),
276 &[],
277 ),
278 (
279 "QuantityStepper",
280 "Reusable +/- numeric stepper with its own hidden input on the data-qty contract; honors optional min/max/step bounds.",
281 || to_value(schema_for!(QuantityStepperProps)).unwrap(),
282 &[],
283 ),
284 (
285 "Numpad",
286 "Tap-surface numeric keypad (quantity or price mode) writing to a hidden field; never renders a native input so the software keyboard is not triggered.",
287 || to_value(schema_for!(NumpadProps)).unwrap(),
288 &[],
289 ),
290 (
292 "Card",
293 "Content container with title, description, optional badge and subtitle, body children, and optional footer slot.",
294 || to_value(schema_for!(CardProps)).unwrap(),
295 &["footer"],
296 ),
297 (
298 "Modal",
299 "Dialog overlay with title, description, body children, and optional footer slot.",
300 || to_value(schema_for!(ModalProps)).unwrap(),
301 &["footer"],
302 ),
303 (
304 "Tabs",
305 "Tabbed content; per-tab children live in TabsProps.tabs[i].children.",
306 || to_value(schema_for!(TabsProps)).unwrap(),
307 &[],
308 ),
309 (
310 "KanbanBoard",
311 "Horizontally scrollable kanban columns on desktop, tab-switched on mobile.",
312 || to_value(schema_for!(KanbanBoardProps)).unwrap(),
313 &[],
314 ),
315 (
316 "PageHeader",
317 "Page title with optional breadcrumb and action button slot.",
318 || to_value(schema_for!(PageHeaderProps)).unwrap(),
319 &["actions"],
320 ),
321 (
322 "DetailPage",
323 "Canonical resource-detail skeleton: PageHeader chrome, optional info Card slot, and stacked body sections from Element.children.",
324 || to_value(schema_for!(DetailPageProps)).unwrap(),
325 &["actions", "info"],
326 ),
327 (
328 "Grid",
329 "Responsive multi-column grid with configurable breakpoint columns, gap, scroll.",
330 || to_value(schema_for!(GridProps)).unwrap(),
331 &[],
332 ),
333 (
334 "TileGrid",
335 "Touch-first responsive tile grid with integrated category filter strip and client-side text search; Tile children iterate via $each.",
336 || to_value(schema_for!(TileGridProps)).unwrap(),
337 &[],
338 ),
339 (
340 "Collapsible",
341 "Expandable <details> / <summary> section.",
342 || to_value(schema_for!(CollapsibleProps)).unwrap(),
343 &[],
344 ),
345 (
346 "FormSection",
347 "Visual grouping within a form with title, description, and layout variant.",
348 || to_value(schema_for!(FormSectionProps)).unwrap(),
349 &[],
350 ),
351 (
352 "ButtonGroup",
353 "Horizontal button row with configurable gap.",
354 || to_value(schema_for!(ButtonGroupProps)).unwrap(),
355 &[],
356 ),
357 (
358 "SegmentedControl",
359 "Connected button cluster — date scrollers, view toggles, mode pickers. Items via literal or data_path.",
360 || to_value(schema_for!(SegmentedControlProps)).unwrap(),
361 &[],
362 ),
363 (
364 "SidebarLayout",
365 "Two-column layout with sticky vertical nav (left) and main content slot (right). Mobile-collapsing.",
366 || to_value(schema_for!(SidebarLayoutProps)).unwrap(),
367 &[],
368 ),
369 (
370 "ActionGroup",
371 "Ordered action list: inline buttons up to max_inline, trailing overflow kebab for the rest; destructive items forced into the kebab last.",
372 || to_value(schema_for!(ActionGroupProps)).unwrap(),
373 &[],
374 ),
375 (
376 "SelectionPanel",
377 "Live client-side view of the register form state: lines appear as tiles are tapped, each with a per-line stepper + remove, a running total, an EmptyState, and a confirm slot; pins and scrolls under fill_viewport.",
378 || to_value(schema_for!(SelectionPanelProps)).unwrap(),
379 &[],
380 ),
381 (
382 "LiveFragment",
383 "Binds a child template to a ferro-projection per-key snapshot; re-renders \
384 in place on each delta via server-push HTML over the ferro-broadcast WebSocket.",
385 || to_value(schema_for!(LiveFragmentProps)).unwrap(),
386 &[],
387 ),
388 (
390 "Form",
391 "Form container with action binding and field components.",
392 || to_value(schema_for!(FormProps)).unwrap(),
393 &[],
394 ),
395 (
396 "Input",
397 "Text input with type variants, validation error, data_path pre-fill.",
398 || to_value(schema_for!(InputProps)).unwrap(),
399 &[],
400 ),
401 (
402 "Select",
403 "Dropdown select with options, error, data_path pre-fill.",
404 || to_value(schema_for!(SelectProps)).unwrap(),
405 &[],
406 ),
407 (
408 "Checkbox",
409 "Boolean checkbox with label, description, data binding.",
410 || to_value(schema_for!(CheckboxProps)).unwrap(),
411 &[],
412 ),
413 (
414 "Switch",
415 "Toggle switch (visual alternative to Checkbox); auto-submit when `action` set.",
416 || to_value(schema_for!(SwitchProps)).unwrap(),
417 &[],
418 ),
419 (
420 "CheckboxList",
421 "Multi-select checkbox group from static options or data-driven array. \
422 Each checked option submits as field=value.",
423 || to_value(schema_for!(CheckboxListProps)).unwrap(),
424 &[],
425 ),
426 (
427 "CheckboxGroup",
428 "Multi-select checkbox group (alias for CheckboxList). Each checked option \
429 submits as field=value with array-submit semantics. Identical props to \
430 CheckboxList; see that entry for full schema.",
431 || to_value(schema_for!(CheckboxListProps)).unwrap(),
432 &[],
433 ),
434 (
436 "Table",
437 "Data table with columns, row_actions, sorting, empty_message.",
438 || to_value(schema_for!(TableProps)).unwrap(),
439 &[],
440 ),
441 (
442 "DataTable",
443 "Stripe-style alternating-row table with per-row action menu and mobile card fallback.",
444 || to_value(schema_for!(DataTableProps)).unwrap(),
445 &[],
446 ),
447 (
448 "MediaCardGrid",
449 "Responsive card grid backed by a data array. Each card shows an optional screenshot image, title, description, status badge, and per-row dropdown actions.",
450 || to_value(schema_for!(MediaCardGridProps)).unwrap(),
451 &[],
452 ),
453];
454
455fn sanitize_schema(mut schema: Value) -> Value {
463 fn walk(v: &mut Value) {
464 if let Some(obj) = v.as_object_mut() {
465 if let Some(defs) = obj.remove("definitions") {
466 obj.entry("$defs".to_string()).or_insert(defs);
467 }
468 if let Some(Value::String(ref_str)) = obj.get_mut("$ref") {
469 if let Some(suffix) = ref_str.strip_prefix("#/definitions/") {
470 *ref_str = format!("#/$defs/{suffix}");
471 }
472 }
473 let keys: Vec<String> = obj.keys().cloned().collect();
475 for k in keys {
476 if let Some(child) = obj.get_mut(&k) {
477 walk(child);
478 }
479 }
480 } else if let Some(arr) = v.as_array_mut() {
481 for item in arr.iter_mut() {
482 walk(item);
483 }
484 }
485 }
486 walk(&mut schema);
487 schema
488}
489
490fn hoist_defs(schema: &mut Value, shared_defs: &mut serde_json::Map<String, Value>) {
499 if let Some(obj) = schema.as_object_mut() {
500 if let Some(Value::Object(defs)) = obj.remove("$defs") {
501 for (k, v) in defs {
502 shared_defs.entry(k).or_insert(v);
503 }
504 }
505 }
506}
507
508fn assemble_full_schema(per_component: &HashMap<String, Value>) -> Result<Value, CatalogError> {
521 let mut action_schema = sanitize_schema(to_value(schema_for!(crate::action::Action))?);
524 let mut visibility_schema =
525 sanitize_schema(to_value(schema_for!(crate::visibility::Visibility))?);
526 let mut design_schema = sanitize_schema(to_value(schema_for!(crate::spec::DesignMeta))?);
527
528 let mut shared_defs: serde_json::Map<String, Value> = serde_json::Map::new();
530 hoist_defs(&mut action_schema, &mut shared_defs);
531 hoist_defs(&mut visibility_schema, &mut shared_defs);
532 hoist_defs(&mut design_schema, &mut shared_defs);
533
534 let mut names: Vec<&String> = per_component.keys().collect();
538 names.sort();
539 let one_of: Vec<Value> = names
540 .into_iter()
541 .map(|name| {
542 let mut props_schema = per_component[name].clone();
543 hoist_defs(&mut props_schema, &mut shared_defs);
545 serde_json::json!({
546 "allOf": [
547 {
548 "type": "object",
549 "required": ["type"],
550 "properties": {
551 "type": { "const": name }
552 }
553 },
554 {
555 "type": "object",
556 "properties": {
557 "props": props_schema,
558 "children": { "type": "array", "items": { "type": "string" } },
559 "action": { "$ref": "#/$defs/Action" },
560 "visible": { "$ref": "#/$defs/Visibility" }
561 }
562 }
563 ]
564 })
565 })
566 .collect();
567
568 shared_defs
571 .entry("Action".to_string())
572 .or_insert(action_schema);
573 shared_defs
574 .entry("Visibility".to_string())
575 .or_insert(visibility_schema);
576 shared_defs
577 .entry("DesignMeta".to_string())
578 .or_insert(design_schema);
579 shared_defs.insert(
581 "Element".to_string(),
582 serde_json::json!({ "oneOf": one_of }),
583 );
584
585 Ok(serde_json::json!({
586 "$schema": "https://json-schema.org/draft/2020-12/schema",
587 "$id": "ferro-json-ui/v2",
588 "type": "object",
589 "required": ["$schema", "root", "elements"],
590 "properties": {
591 "$schema": { "const": "ferro-json-ui/v2" },
592 "root": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,127}$" },
593 "elements": {
594 "type": "object",
595 "additionalProperties": { "$ref": "#/$defs/Element" }
596 },
597 "title": { "type": ["string", "null"] },
598 "layout": { "type": ["string", "null"] },
599 "fill_viewport": { "type": "boolean", "default": false },
600 "data": true,
601 "design": { "$ref": "#/$defs/DesignMeta" }
602 },
603 "$defs": shared_defs
604 }))
605}
606
607impl Catalog {
610 pub fn build() -> Result<Self, CatalogError> {
621 if BUILTIN_SPECS.len() != crate::render::BUILTIN_TYPES.len() {
625 return Err(CatalogError::BuildFailed(format!(
626 "BUILTIN_SPECS has {} entries but BUILTIN_TYPES has {} — \
627 add an entry to BUILTIN_SPECS or remove from BUILTIN_TYPES",
628 BUILTIN_SPECS.len(),
629 crate::render::BUILTIN_TYPES.len(),
630 )));
631 }
632
633 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
635 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len() * 2);
636 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
637 let raw = schema_fn();
638 let schema = sanitize_schema(raw);
639 per_component_schemas.insert((*name).to_string(), schema.clone());
640 components.insert(
641 (*name).to_string(),
642 ComponentSpec {
643 name: (*name).to_string(),
644 description: (*desc).to_string(),
645 props_schema: schema,
646 is_plugin: false,
647 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
648 },
649 );
650 }
651
652 let mut plugin_components = HashMap::new();
658 for plugin_type in crate::plugin::registered_plugin_types() {
659 if components.contains_key(&plugin_type) {
661 continue;
662 }
663 let raw = crate::plugin::with_plugin(&plugin_type, |p| p.props_schema())
664 .unwrap_or(Value::Null);
665 let schema = sanitize_schema(raw);
666 if jsonschema::validator_for(&schema).is_err() {
668 return Err(CatalogError::BuildFailed(format!(
669 "plugin '{plugin_type}' returned an invalid JSON Schema"
670 )));
671 }
672 per_component_schemas.insert(plugin_type.clone(), schema.clone());
673 plugin_components.insert(
674 plugin_type.clone(),
675 ComponentSpec {
676 name: plugin_type,
677 description: String::from("Plugin component."),
678 props_schema: schema,
679 is_plugin: true,
680 slot_fields: Vec::new(),
681 },
682 );
683 }
684
685 let full_schema = assemble_full_schema(&per_component_schemas)?;
687
688 let validator = jsonschema::validator_for(&full_schema)
690 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
691
692 Ok(Catalog {
693 components,
694 plugin_components,
695 full_schema,
696 per_component_schemas,
697 validator,
698 })
699 }
700
701 pub fn json_schema(&self) -> &Value {
708 &self.full_schema
709 }
710
711 pub fn validate(&self, spec: &crate::spec::Spec) -> Result<(), Vec<CatalogError>> {
738 let mut errors: Vec<CatalogError> = Vec::new();
739
740 for (id, el) in &spec.elements {
742 let known = self.components.contains_key(&el.type_name)
743 || self.plugin_components.contains_key(&el.type_name);
744 if !known {
745 errors.push(CatalogError::UnknownType {
746 element_id: id.clone(),
747 type_name: el.type_name.clone(),
748 });
749 }
750 }
751 if !errors.is_empty() {
756 return Err(errors);
757 }
758
759 for (id, el) in &spec.elements {
761 if let Some(schema) = self.per_component_schemas.get(&el.type_name) {
762 if el.props.is_null() {
767 continue;
768 }
769 if el.each.is_some() {
778 continue;
779 }
780 let v = match jsonschema::validator_for(schema) {
784 Ok(v) => v,
785 Err(e) => {
786 errors.push(CatalogError::BuildFailed(format!(
787 "compiling per-component schema for '{}': {e}",
788 el.type_name
789 )));
790 continue;
791 }
792 };
793 let validation_props = strip_expr_objects(&el.props);
799 let mut per_elem_errs: Vec<String> = Vec::new();
800 for err in v.iter_errors(&validation_props) {
801 per_elem_errs.push(format!("{}: {}", err.instance_path(), err));
802 }
803 if !per_elem_errs.is_empty() {
804 errors.push(CatalogError::PropsInvalid {
805 element_id: id.clone(),
806 type_name: el.type_name.clone(),
807 errors: per_elem_errs,
808 });
809 }
810 }
811 }
812
813 for (id, el) in &spec.elements {
820 let mut renamed: Vec<String> = Vec::new();
821 for (ty, old, new) in RETIRED_PROPS {
822 if el.type_name == *ty && el.props.get(old).is_some() {
823 renamed.push(format!(
824 "/{old}: `{old}` was renamed to `{new}` — update the spec"
825 ));
826 }
827 }
828 collect_retired_action_variants(&el.props, "", &mut renamed);
829 if let Some(action) = &el.action {
830 if let Ok(action_value) = serde_json::to_value(action) {
831 collect_retired_action_variants(&action_value, "/action", &mut renamed);
832 }
833 }
834 if !renamed.is_empty() {
835 errors.push(CatalogError::PropsInvalid {
836 element_id: id.clone(),
837 type_name: el.type_name.clone(),
838 errors: renamed,
839 });
840 }
841 }
842
843 let mut spec_value = match serde_json::to_value(spec) {
845 Ok(v) => v,
846 Err(e) => {
847 errors.push(CatalogError::SchemaSerialization(e));
848 return Err(errors);
849 }
850 };
851 if let Some(elements) = spec_value
859 .get_mut("elements")
860 .and_then(|e| e.as_object_mut())
861 {
862 for (id, el_val) in elements.iter_mut() {
863 let is_template = spec
864 .elements
865 .get(id)
866 .map(|e| e.each.is_some())
867 .unwrap_or(false);
868 if is_template {
869 if let Some(obj) = el_val.as_object_mut() {
870 obj.remove("props");
871 }
872 }
873 }
874 }
875 let stripped_spec_value = strip_expr_objects(&spec_value);
877 let mut envelope_errs: Vec<String> = Vec::new();
878 for err in self.validator.iter_errors(&stripped_spec_value) {
879 envelope_errs.push(format!("{}: {}", err.instance_path(), err));
880 }
881 if !envelope_errs.is_empty() {
882 errors.push(CatalogError::SpecInvalid {
883 errors: envelope_errs,
884 });
885 }
886
887 if errors.is_empty() {
888 Ok(())
889 } else {
890 Err(errors)
891 }
892 }
893
894 pub fn component_schema(&self, type_name: &str) -> Option<&Value> {
908 self.per_component_schemas.get(type_name)
909 }
910
911 pub fn components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
918 let mut entries: Vec<&ComponentSpec> = self.components.values().collect();
919 entries.sort_by(|a, b| a.name.cmp(&b.name));
920 entries.into_iter()
921 }
922
923 pub fn plugin_components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
929 let mut entries: Vec<&ComponentSpec> = self.plugin_components.values().collect();
930 entries.sort_by(|a, b| a.name.cmp(&b.name));
931 entries.into_iter()
932 }
933
934 pub fn prompt(&self) -> String {
950 let mut out = String::with_capacity(8 * 1024);
951 out.push_str("## Component Catalog\n\n");
952 out.push_str("Slot fields are Vec<String> of element IDs; body children come from Element.children.\n\n");
953 for spec in self.components_sorted() {
954 render_component_section(&mut out, spec);
955 }
956 if self.plugin_components.is_empty() {
957 return out;
958 }
959 out.push_str("## Plugin Components\n\n");
960 for spec in self.plugin_components_sorted() {
961 render_component_section(&mut out, spec);
962 }
963 out
964 }
965}
966
967const RETIRED_PROPS: &[(&str, &str, &str)] = &[
972 ("Card", "variant", "appearance"),
973 ("Badge", "variant", "tone"),
974 ("Alert", "variant", "tone"),
975 ("Toast", "variant", "tone"),
976 ("ActionCard", "variant", "tone"),
977 ("MediaCardGrid", "badge_variant_key", "badge_tone_key"),
978];
979
980fn collect_retired_action_variants(value: &Value, path: &str, out: &mut Vec<String>) {
987 match value {
988 Value::Object(map) => {
989 for (key, child) in map {
990 let child_path = format!("{path}/{key}");
991 if let Value::Object(obj) = child {
992 let is_confirm = key == "confirm";
993 let is_notify_outcome = (key == "on_success" || key == "on_error")
994 && obj.get("type").and_then(Value::as_str) == Some("notify");
995 if (is_confirm || is_notify_outcome) && obj.contains_key("variant") {
996 out.push(format!(
997 "{child_path}/variant: `variant` was renamed to `tone` — update the spec"
998 ));
999 }
1000 }
1001 collect_retired_action_variants(child, &child_path, out);
1002 }
1003 }
1004 Value::Array(arr) => {
1005 for (i, child) in arr.iter().enumerate() {
1006 collect_retired_action_variants(child, &format!("{path}/{i}"), out);
1007 }
1008 }
1009 _ => {}
1010 }
1011}
1012
1013fn render_component_section(out: &mut String, spec: &ComponentSpec) {
1030 out.push_str("### ");
1031 out.push_str(&spec.name);
1032 out.push('\n');
1033 out.push_str(&spec.description);
1034 out.push('\n');
1035
1036 let props_line = render_props_line(&spec.props_schema);
1037 if !props_line.is_empty() {
1038 out.push_str("Props: ");
1039 out.push_str(&props_line);
1040 out.push('\n');
1041 }
1042 if !spec.slot_fields.is_empty() {
1043 out.push_str("Slots: ");
1044 out.push_str(&spec.slot_fields.join(", "));
1045 out.push('\n');
1046 }
1047 out.push('\n');
1048}
1049
1050fn render_props_line(schema: &Value) -> String {
1062 let Some(obj) = schema.as_object() else {
1063 return String::new();
1064 };
1065 let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
1066 return String::new();
1067 };
1068 let defs = obj.get("$defs").and_then(|v| v.as_object());
1072 let required: std::collections::HashSet<&str> = obj
1073 .get("required")
1074 .and_then(|v| v.as_array())
1075 .map(|arr| {
1076 arr.iter()
1077 .filter_map(|v| v.as_str())
1078 .collect::<std::collections::HashSet<_>>()
1079 })
1080 .unwrap_or_default();
1081
1082 let parts: Vec<String> = props
1083 .iter()
1084 .map(|(name, field_schema)| {
1085 let ty = render_field_type(field_schema, required.contains(name.as_str()), defs);
1086 format!("{name} ({ty})")
1087 })
1088 .collect();
1089 parts.join(", ")
1090}
1091
1092fn resolve_local_enum_ref<'a>(
1097 schema: &'a Value,
1098 defs: Option<&'a serde_json::Map<String, Value>>,
1099) -> Option<Vec<&'a str>> {
1100 let name = schema.get("$ref")?.as_str()?.strip_prefix("#/$defs/")?;
1101 let target = defs?.get(name)?;
1102 let arr = target.get("enum")?.as_array()?;
1103 Some(arr.iter().filter_map(|v| v.as_str()).collect())
1104}
1105
1106fn render_field_type(
1111 schema: &Value,
1112 is_required: bool,
1113 defs: Option<&serde_json::Map<String, Value>>,
1114) -> String {
1115 if let Some(variants) = schema.get("enum").and_then(|v| v.as_array()) {
1117 let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
1118 let inner = render_enum_inline(&names);
1119 return wrap_optional(inner, is_required);
1120 }
1121 for key in ["anyOf", "oneOf"] {
1123 if let Some(arr) = schema.get(key).and_then(|v| v.as_array()) {
1124 let has_null = arr
1125 .iter()
1126 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
1127 let non_null: Vec<&Value> = arr
1128 .iter()
1129 .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
1130 .collect();
1131 if has_null && non_null.len() == 1 {
1132 let inner = render_field_type(non_null[0], true, defs);
1133 return format!("Option<{inner}>");
1134 }
1135 }
1136 }
1137 if let Some(types) = schema.get("type").and_then(|v| v.as_array()) {
1139 let non_null: Vec<&str> = types
1140 .iter()
1141 .filter_map(|v| v.as_str())
1142 .filter(|s| *s != "null")
1143 .collect();
1144 let has_null = types.iter().any(|v| v.as_str() == Some("null"));
1145 if has_null && non_null.len() == 1 {
1146 return format!("Option<{}>", rust_for_json_type(non_null[0], schema, defs));
1147 }
1148 }
1149 if let Some(t) = schema.get("type").and_then(|v| v.as_str()) {
1151 let inner = rust_for_json_type(t, schema, defs);
1152 return wrap_optional(inner, is_required);
1153 }
1154 if let Some(names) = resolve_local_enum_ref(schema, defs) {
1157 return wrap_optional(render_enum_inline(&names), is_required);
1158 }
1159 wrap_optional("<see schema>".to_string(), is_required)
1161}
1162
1163fn rust_for_json_type(
1165 t: &str,
1166 schema: &Value,
1167 defs: Option<&serde_json::Map<String, Value>>,
1168) -> String {
1169 match t {
1170 "string" => "String".to_string(),
1171 "integer" => "i64".to_string(),
1172 "number" => "f64".to_string(),
1173 "boolean" => "bool".to_string(),
1174 "array" => {
1175 if let Some(items) = schema.get("items") {
1176 let inner = render_field_type(items, true, defs);
1177 format!("Vec<{inner}>")
1178 } else {
1179 "Vec<Value>".to_string()
1180 }
1181 }
1182 "object" => "Object".to_string(),
1183 other => other.to_string(),
1184 }
1185}
1186
1187fn render_enum_inline(variants: &[&str]) -> String {
1189 if variants.len() <= 8 {
1190 variants.join("|")
1191 } else {
1192 format!("one of {} — see schema", variants.len())
1193 }
1194}
1195
1196fn wrap_optional(inner: String, is_required: bool) -> String {
1198 if is_required {
1199 inner
1200 } else {
1201 format!("Option<{inner}>")
1202 }
1203}
1204
1205fn strip_expr_objects(val: &Value) -> Value {
1214 match val {
1215 Value::Object(map) => {
1216 if map.len() == 1 && (map.contains_key("$data") || map.contains_key("$template")) {
1217 Value::String(String::new())
1218 } else {
1219 Value::Object(
1220 map.iter()
1221 .map(|(k, v)| (k.clone(), strip_expr_objects(v)))
1222 .collect(),
1223 )
1224 }
1225 }
1226 Value::Array(arr) => Value::Array(arr.iter().map(strip_expr_objects).collect()),
1227 other => other.clone(),
1228 }
1229}
1230
1231pub fn global_catalog() -> &'static Catalog {
1244 static GLOBAL_CATALOG: OnceLock<Catalog> = OnceLock::new();
1245 GLOBAL_CATALOG.get_or_init(|| {
1246 Catalog::build().expect("catalog build failed — see CatalogError for details")
1247 })
1248}
1249
1250#[cfg(test)]
1253impl Catalog {
1254 pub(crate) fn build_builtins_only() -> Result<Self, CatalogError> {
1259 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
1260 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len());
1261 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
1262 let raw = schema_fn();
1263 let schema = sanitize_schema(raw);
1264 per_component_schemas.insert((*name).to_string(), schema.clone());
1265 components.insert(
1266 (*name).to_string(),
1267 ComponentSpec {
1268 name: (*name).to_string(),
1269 description: (*desc).to_string(),
1270 props_schema: schema,
1271 is_plugin: false,
1272 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
1273 },
1274 );
1275 }
1276 let full_schema = assemble_full_schema(&per_component_schemas)?;
1277 let validator = jsonschema::validator_for(&full_schema)
1278 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
1279 Ok(Catalog {
1280 components,
1281 plugin_components: HashMap::new(),
1282 full_schema,
1283 per_component_schemas,
1284 validator,
1285 })
1286 }
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use super::*;
1292
1293 #[test]
1294 fn builtin_types_count_drift_guard() {
1295 assert_eq!(crate::render::BUILTIN_TYPES.len(), 53);
1304 }
1305
1306 const CANONICAL_VARIANT: &[&str] = &["primary", "secondary", "outline", "ghost", "destructive"];
1314 const CANONICAL_TONE: &[&str] = &["neutral", "success", "warning", "destructive"];
1315 const CANONICAL_SIZE: &[&str] = &["sm", "md", "lg"];
1316
1317 fn canonical_set_for(prop: &str) -> Option<&'static [&'static str]> {
1319 match prop {
1320 "variant" => Some(CANONICAL_VARIANT),
1321 "tone" => Some(CANONICAL_TONE),
1322 "size" => Some(CANONICAL_SIZE),
1323 _ => None,
1324 }
1325 }
1326
1327 fn extract_enum_values<'a>(
1333 schema: &'a Value,
1334 defs: &'a serde_json::Map<String, Value>,
1335 ) -> Option<Vec<&'a str>> {
1336 if let Some(name) = schema
1337 .get("$ref")
1338 .and_then(|v| v.as_str())
1339 .and_then(|r| r.strip_prefix("#/$defs/"))
1340 {
1341 return extract_enum_values(defs.get(name)?, defs);
1342 }
1343 if let Some(arr) = schema.get("enum").and_then(|v| v.as_array()) {
1344 return Some(arr.iter().filter_map(|v| v.as_str()).collect());
1345 }
1346 for key in ["anyOf", "oneOf"] {
1347 let Some(arr) = schema.get(key).and_then(|v| v.as_array()) else {
1348 continue;
1349 };
1350 let non_null: Vec<&Value> = arr
1351 .iter()
1352 .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
1353 .collect();
1354 if non_null.len() == 1 && non_null.len() < arr.len() {
1356 return extract_enum_values(non_null[0], defs);
1357 }
1358 let consts: Vec<&str> = non_null
1360 .iter()
1361 .filter_map(|v| v.get("const").and_then(|c| c.as_str()))
1362 .collect();
1363 if !consts.is_empty() && consts.len() == non_null.len() {
1364 return Some(consts);
1365 }
1366 }
1367 None
1368 }
1369
1370 fn walk_canonical_enum_props(
1376 node: &Value,
1377 defs: &serde_json::Map<String, Value>,
1378 visited: &mut std::collections::HashSet<String>,
1379 checked: &mut usize,
1380 ) {
1381 match node {
1382 Value::Object(obj) => {
1383 if let Some(name) = obj
1384 .get("$ref")
1385 .and_then(|v| v.as_str())
1386 .and_then(|r| r.strip_prefix("#/$defs/"))
1387 {
1388 if visited.insert(name.to_string()) {
1389 if let Some(target) = defs.get(name) {
1390 walk_canonical_enum_props(target, defs, visited, checked);
1391 }
1392 }
1393 }
1394 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
1395 for (prop_name, prop_schema) in props {
1396 let Some(want) = canonical_set_for(prop_name) else {
1397 continue;
1398 };
1399 let got = extract_enum_values(prop_schema, defs).unwrap_or_else(|| {
1400 panic!(
1401 "schema property '{prop_name}' must be enum-typed with the \
1402 canonical vocabulary, got non-enum schema: {prop_schema}"
1403 )
1404 });
1405 assert_eq!(
1406 got.as_slice(),
1407 want,
1408 "schema property '{prop_name}' carries a non-canonical value set \
1409 {got:?} (canonical: {want:?})"
1410 );
1411 *checked += 1;
1412 }
1413 }
1414 for child in obj.values() {
1415 walk_canonical_enum_props(child, defs, visited, checked);
1416 }
1417 }
1418 Value::Array(arr) => {
1419 for item in arr {
1420 walk_canonical_enum_props(item, defs, visited, checked);
1421 }
1422 }
1423 _ => {}
1424 }
1425 }
1426
1427 #[test]
1428 fn variant_tone_size_enum_sets_drift_guard() {
1429 let cat = Catalog::build_builtins_only().expect("build succeeds");
1437 let schema = cat.json_schema();
1438 let defs = schema
1439 .get("$defs")
1440 .and_then(|v| v.as_object())
1441 .expect("assembled schema has a root $defs map");
1442
1443 for (def_name, want) in [
1445 ("Variant", CANONICAL_VARIANT),
1446 ("Tone", CANONICAL_TONE),
1447 ("Size", CANONICAL_SIZE),
1448 ] {
1449 let def = defs
1450 .get(def_name)
1451 .unwrap_or_else(|| panic!("$defs/{def_name} missing from the assembled schema"));
1452 let got = extract_enum_values(def, defs)
1453 .unwrap_or_else(|| panic!("$defs/{def_name} is not an enum schema: {def}"));
1454 assert_eq!(
1455 got.as_slice(),
1456 want,
1457 "$defs/{def_name} value set drifted from the canonical enum"
1458 );
1459 }
1460
1461 let one_of = defs
1463 .get("Element")
1464 .and_then(|e| e.get("oneOf"))
1465 .and_then(|v| v.as_array())
1466 .expect("$defs/Element/oneOf array");
1467 assert_eq!(
1468 one_of.len(),
1469 crate::render::BUILTIN_TYPES.len(),
1470 "oneOf must carry one entry per builtin component"
1471 );
1472 let mut checked = 0usize;
1473 for entry in one_of {
1474 let props = entry
1475 .pointer("/allOf/1/properties/props")
1476 .unwrap_or_else(|| {
1477 panic!("oneOf entry missing allOf[1].properties.props: {entry}")
1478 });
1479 let mut visited = std::collections::HashSet::new();
1480 walk_canonical_enum_props(props, defs, &mut visited, &mut checked);
1481 }
1482
1483 let mut visited = std::collections::HashSet::new();
1488 for def in defs.values() {
1489 walk_canonical_enum_props(def, defs, &mut visited, &mut checked);
1490 }
1491
1492 assert!(
1493 checked >= 10,
1494 "walker asserted only {checked} variant/tone/size properties — \
1495 the schema traversal is broken (expected at least 10 across the catalog)"
1496 );
1497 }
1498
1499 #[test]
1500 fn builtin_specs_len_matches_dispatch() {
1501 assert_eq!(BUILTIN_SPECS.len(), crate::render::BUILTIN_TYPES.len());
1504 }
1505
1506 #[test]
1507 fn builtin_specs_names_match_dispatch() {
1508 use std::collections::HashSet;
1509 let specs: HashSet<&str> = BUILTIN_SPECS.iter().map(|(n, ..)| *n).collect();
1510 let types: HashSet<&str> = crate::render::BUILTIN_TYPES.iter().copied().collect();
1511 assert_eq!(specs, types, "BUILTIN_SPECS names must match BUILTIN_TYPES");
1512 }
1513
1514 #[test]
1515 fn build_populates_all_builtins() {
1516 let cat = Catalog::build_builtins_only().expect("build succeeds");
1518 for name in crate::render::BUILTIN_TYPES.iter() {
1519 assert!(
1520 cat.components.contains_key(*name),
1521 "built-in '{name}' missing from catalog.components"
1522 );
1523 let spec = &cat.components[*name];
1524 assert_eq!(spec.name, *name);
1525 assert!(
1526 !spec.description.is_empty(),
1527 "'{name}' has empty description"
1528 );
1529 assert!(
1530 spec.props_schema.is_object(),
1531 "'{name}' props_schema is not a JSON object"
1532 );
1533 assert!(!spec.is_plugin);
1534 }
1535 }
1536
1537 #[test]
1538 fn build_card_has_footer_slot() {
1539 let cat = Catalog::build_builtins_only().expect("build succeeds");
1541 let card = &cat.components["Card"];
1542 assert_eq!(card.slot_fields, vec!["footer"]);
1543 }
1544
1545 #[test]
1546 fn build_modal_has_footer_slot() {
1547 let cat = Catalog::build_builtins_only().expect("build succeeds");
1549 let modal = &cat.components["Modal"];
1550 assert_eq!(modal.slot_fields, vec!["footer"]);
1551 }
1552
1553 #[test]
1554 fn build_pageheader_has_actions_slot() {
1555 let cat = Catalog::build_builtins_only().expect("build succeeds");
1557 let ph = &cat.components["PageHeader"];
1558 assert_eq!(ph.slot_fields, vec!["actions"]);
1559 }
1560
1561 #[test]
1562 fn build_text_has_no_slots() {
1563 let cat = Catalog::build_builtins_only().expect("build succeeds");
1565 assert!(cat.components["Text"].slot_fields.is_empty());
1566 }
1567
1568 #[test]
1569 fn build_populates_per_component_schemas() {
1570 let cat = Catalog::build_builtins_only().expect("build succeeds");
1572 assert_eq!(
1573 cat.per_component_schemas.len(),
1574 BUILTIN_SPECS.len() + cat.plugin_components.len()
1575 );
1576 }
1577
1578 #[test]
1579 fn sanitize_schema_rewrites_definitions_to_dollar_defs() {
1580 let raw = serde_json::json!({
1581 "type": "object",
1582 "definitions": { "Foo": { "type": "string" } },
1583 "properties": {
1584 "x": { "$ref": "#/definitions/Foo" }
1585 }
1586 });
1587 let out = sanitize_schema(raw);
1588 assert!(out.get("definitions").is_none());
1589 assert!(out.get("$defs").is_some());
1590 assert_eq!(
1591 out["properties"]["x"]["$ref"].as_str().unwrap(),
1592 "#/$defs/Foo"
1593 );
1594 }
1595
1596 #[test]
1597 fn sanitize_schema_is_idempotent() {
1598 let raw = serde_json::json!({
1599 "type": "object",
1600 "$defs": { "Foo": { "type": "string" } },
1601 "properties": {
1602 "x": { "$ref": "#/$defs/Foo" }
1603 }
1604 });
1605 let once = sanitize_schema(raw.clone());
1606 let twice = sanitize_schema(once.clone());
1607 assert_eq!(once, twice);
1608 assert!(twice.get("definitions").is_none());
1610 assert!(twice.get("$defs").is_some());
1611 }
1612
1613 #[test]
1614 fn json_schema_has_spec_envelope_shape() {
1615 let cat = Catalog::build_builtins_only().expect("build");
1618 let schema = cat.json_schema();
1619 assert_eq!(schema["$id"], "ferro-json-ui/v2");
1620 assert_eq!(schema["type"], "object");
1621 let required: Vec<&str> = schema["required"]
1622 .as_array()
1623 .unwrap()
1624 .iter()
1625 .map(|v| v.as_str().unwrap())
1626 .collect();
1627 assert!(required.contains(&"$schema"));
1628 assert!(required.contains(&"root"));
1629 assert!(required.contains(&"elements"));
1630 }
1631
1632 #[test]
1633 fn json_schema_has_action_and_visibility_defs() {
1634 let cat = Catalog::build_builtins_only().expect("build");
1635 let schema = cat.json_schema();
1636 assert!(
1637 schema["$defs"]["Action"].is_object(),
1638 "$defs/Action missing"
1639 );
1640 assert!(
1641 schema["$defs"]["Visibility"].is_object(),
1642 "$defs/Visibility missing"
1643 );
1644 assert!(
1645 schema["$defs"]["Element"].is_object(),
1646 "$defs/Element missing"
1647 );
1648 }
1649
1650 #[test]
1651 fn json_schema_oneof_covers_all_builtins() {
1652 let cat = Catalog::build_builtins_only().expect("build");
1653 let schema = cat.json_schema();
1654 let one_of = schema["$defs"]["Element"]["oneOf"]
1656 .as_array()
1657 .expect("Element.oneOf is an array");
1658
1659 let mut discriminators: std::collections::HashSet<String> =
1661 std::collections::HashSet::new();
1662 for variant in one_of {
1663 let c = variant["allOf"][0]["properties"]["type"]["const"]
1664 .as_str()
1665 .expect("every variant pins a type const");
1666 discriminators.insert(c.to_string());
1667 }
1668
1669 for name in crate::render::BUILTIN_TYPES.iter() {
1670 assert!(
1671 discriminators.contains(*name),
1672 "oneOf is missing discriminator for '{name}'"
1673 );
1674 }
1675
1676 assert_eq!(
1678 discriminators.len(),
1679 crate::render::BUILTIN_TYPES.len(),
1680 "oneOf variant count mismatch"
1681 );
1682 }
1683
1684 #[test]
1685 fn json_schema_is_valid() {
1686 use jsonschema::draft202012;
1687 let cat = Catalog::build_builtins_only().expect("build");
1688 let schema = cat.json_schema();
1689 assert!(
1690 draft202012::meta::is_valid(schema),
1691 "assembled full_schema did not meta-validate as Draft 2020-12"
1692 );
1693 }
1694
1695 #[test]
1696 fn validator_is_compiled_once_and_usable() {
1697 let cat = Catalog::build_builtins_only().expect("build");
1698 let minimal_valid = serde_json::json!({
1702 "$schema": "ferro-json-ui/v2",
1703 "root": "r",
1704 "elements": {
1705 "r": { "type": "Text", "props": { "content": "hi" } }
1706 }
1707 });
1708 assert!(cat.validator.is_valid(&minimal_valid));
1710 }
1711
1712 #[test]
1713 fn validator_rejects_wrong_schema_version() {
1714 let cat = Catalog::build_builtins_only().expect("build");
1715 let wrong_version = serde_json::json!({
1716 "$schema": "ferro-json-ui/v99-wrong",
1717 "root": "r",
1718 "elements": {
1719 "r": { "type": "Text", "props": { "content": "hi" } }
1720 }
1721 });
1722 assert!(
1723 !cat.validator.is_valid(&wrong_version),
1724 "validator should reject unknown $schema version via const"
1725 );
1726 }
1727
1728 #[test]
1729 fn oneof_variants_are_deterministic_sorted() {
1730 let cat1 = Catalog::build_builtins_only().expect("build 1");
1731 let cat2 = Catalog::build_builtins_only().expect("build 2");
1732 assert_eq!(
1734 serde_json::to_string(cat1.json_schema()).unwrap(),
1735 serde_json::to_string(cat2.json_schema()).unwrap()
1736 );
1737 }
1738
1739 fn test_spec_with(type_name: &str, props: Value) -> crate::spec::Spec {
1743 use crate::spec::{Element, Spec};
1744 use std::collections::HashMap;
1745 let mut elements = HashMap::new();
1746 elements.insert(
1747 "r".to_string(),
1748 Element {
1749 type_name: type_name.to_string(),
1750 props,
1751 children: Vec::new(),
1752 action: None,
1753 visible: None,
1754 each: None,
1755 if_: None,
1756 },
1757 );
1758 Spec {
1759 schema: crate::spec::SCHEMA_VERSION.to_string(),
1760 root: "r".to_string(),
1761 elements,
1762 title: None,
1763 layout: None,
1764 fill_viewport: false,
1765 data: Value::Null,
1766 design: None,
1767 }
1768 }
1769
1770 #[test]
1771 fn validate_positive_per_type() {
1772 let cat = Catalog::build_builtins_only().expect("build");
1775 let cases: Vec<(&str, Value)> = vec![
1776 ("Text", serde_json::json!({ "content": "hi" })),
1777 ("Button", serde_json::json!({ "label": "Save" })),
1778 ("Badge", serde_json::json!({ "label": "New" })),
1779 ("Separator", serde_json::json!({})),
1780 ];
1781 for (ty, props) in cases {
1782 let spec = test_spec_with(ty, props.clone());
1783 match cat.validate(&spec) {
1784 Ok(()) => {}
1785 Err(errs) => panic!("validate({ty}) failed: {errs:?}"),
1786 }
1787 }
1788 }
1789
1790 #[test]
1791 fn validate_unknown_type() {
1792 let cat = Catalog::build_builtins_only().expect("build");
1793 let spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1794 let errs = cat.validate(&spec).expect_err("should fail");
1795 assert!(
1796 errs.iter().any(|e| matches!(
1797 e,
1798 CatalogError::UnknownType { type_name, .. } if type_name == "NotARealComponent"
1799 )),
1800 "expected UnknownType for NotARealComponent; got {errs:?}"
1801 );
1802 }
1803
1804 #[test]
1805 fn validate_missing_required_prop() {
1806 let cat = Catalog::build_builtins_only().expect("build");
1809 let spec = test_spec_with("Card", serde_json::json!({}));
1810 let errs = cat.validate(&spec).expect_err("should fail");
1811 assert!(
1812 errs.iter().any(|e| matches!(
1813 e,
1814 CatalogError::PropsInvalid { type_name, .. } if type_name == "Card"
1815 )),
1816 "expected PropsInvalid for missing required 'title'; got {errs:?}"
1817 );
1818 }
1819
1820 #[test]
1821 fn validate_rejects_retired_prop_names() {
1822 let cat = Catalog::build_builtins_only().expect("build");
1825 let cases: Vec<(&str, Value, &str)> = vec![
1826 (
1827 "Badge",
1828 serde_json::json!({ "label": "Paid", "variant": "success" }),
1829 "tone",
1830 ),
1831 (
1832 "Card",
1833 serde_json::json!({ "title": "T", "variant": "elevated" }),
1834 "appearance",
1835 ),
1836 (
1837 "MediaCardGrid",
1838 serde_json::json!({
1839 "data_path": "/rows",
1840 "title_key": "name",
1841 "badge_variant_key": "status"
1842 }),
1843 "badge_tone_key",
1844 ),
1845 ];
1846 for (ty, props, new_name) in cases {
1847 let spec = test_spec_with(ty, props);
1848 let errs = cat.validate(&spec).expect_err("should fail");
1849 assert!(
1850 errs.iter().any(|e| matches!(
1851 e,
1852 CatalogError::PropsInvalid { type_name, errors, .. }
1853 if type_name == ty && errors.iter().any(|m| m.contains(new_name))
1854 )),
1855 "expected retired-prop PropsInvalid for {ty} mentioning `{new_name}`; got {errs:?}"
1856 );
1857 }
1858 }
1859
1860 #[test]
1861 fn validate_rejects_retired_confirm_and_notify_variant() {
1862 let cat = Catalog::build_builtins_only().expect("build");
1865 let spec = test_spec_with(
1866 "DataTable",
1867 serde_json::json!({
1868 "data_path": "/rows",
1869 "columns": [{ "key": "name", "label": "Name" }],
1870 "row_actions": [{
1871 "label": "Delete",
1872 "action": {
1873 "handler": "rows.destroy",
1874 "method": "DELETE",
1875 "confirm": { "title": "Delete?", "variant": "danger" },
1876 "on_success": {
1877 "type": "notify",
1878 "message": "Deleted",
1879 "variant": "error"
1880 }
1881 }
1882 }]
1883 }),
1884 );
1885 let errs = cat.validate(&spec).expect_err("should fail");
1886 let retired_msgs: Vec<&String> = errs
1887 .iter()
1888 .filter_map(|e| match e {
1889 CatalogError::PropsInvalid { errors, .. } => Some(errors),
1890 _ => None,
1891 })
1892 .flatten()
1893 .filter(|m| m.contains("renamed to `tone`"))
1894 .collect();
1895 assert_eq!(
1896 retired_msgs.len(),
1897 2,
1898 "expected confirm + notify retired-variant errors; got {errs:?}"
1899 );
1900 }
1901
1902 #[test]
1903 fn validate_accepts_canonical_prop_names() {
1904 let cat = Catalog::build_builtins_only().expect("build");
1906 let cases: Vec<(&str, Value)> = vec![
1907 (
1908 "Badge",
1909 serde_json::json!({ "label": "Paid", "tone": "success" }),
1910 ),
1911 (
1912 "Card",
1913 serde_json::json!({ "title": "T", "appearance": "elevated" }),
1914 ),
1915 ];
1916 for (ty, props) in cases {
1917 let spec = test_spec_with(ty, props.clone());
1918 if let Err(errs) = cat.validate(&spec) {
1919 panic!("validate({ty}) with canonical props failed: {errs:?}");
1920 }
1921 }
1922 }
1923
1924 #[test]
1925 fn validate_bad_schema_version() {
1926 let cat = Catalog::build_builtins_only().expect("build");
1927 let mut spec = test_spec_with("Text", serde_json::json!({ "content": "hi" }));
1928 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1929 let errs = cat.validate(&spec).expect_err("should fail");
1930 assert!(
1931 errs.iter()
1932 .any(|e| matches!(e, CatalogError::SpecInvalid { .. })),
1933 "expected SpecInvalid for wrong $schema version; got {errs:?}"
1934 );
1935 }
1936
1937 #[test]
1938 fn validate_pre_dispatch_short_circuits() {
1939 let cat = Catalog::build_builtins_only().expect("build");
1942 let mut spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1943 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1944 let errs = cat.validate(&spec).expect_err("should fail");
1945
1946 let has_unknown = errs
1947 .iter()
1948 .any(|e| matches!(e, CatalogError::UnknownType { .. }));
1949 let has_spec_invalid = errs
1950 .iter()
1951 .any(|e| matches!(e, CatalogError::SpecInvalid { .. }));
1952 let has_props_invalid = errs
1953 .iter()
1954 .any(|e| matches!(e, CatalogError::PropsInvalid { .. }));
1955
1956 assert!(has_unknown, "expected UnknownType");
1957 assert!(
1958 !has_spec_invalid,
1959 "Stage 3 ran despite Stage 1 failing: {errs:?}"
1960 );
1961 assert!(
1962 !has_props_invalid,
1963 "Stage 2 ran despite Stage 1 failing: {errs:?}"
1964 );
1965 }
1966
1967 #[test]
1968 fn validator_is_cached_not_recompiled() {
1969 let cat = Catalog::build_builtins_only().expect("build");
1973 for _ in 0..100 {
1974 let spec = test_spec_with("Text", serde_json::json!({ "content": "x" }));
1975 assert!(cat.validate(&spec).is_ok());
1976 }
1977 }
1978
1979 #[test]
1980 fn validate_accumulates_multiple_errors_across_elements() {
1981 use crate::spec::{Element, Spec};
1983 use std::collections::HashMap;
1984 let cat = Catalog::build_builtins_only().expect("build");
1985 let mut elements = HashMap::new();
1986 elements.insert(
1987 "a".to_string(),
1988 Element {
1989 type_name: "Card".to_string(),
1990 props: serde_json::json!({}), children: Vec::new(),
1992 action: None,
1993 visible: None,
1994 each: None,
1995 if_: None,
1996 },
1997 );
1998 elements.insert(
1999 "b".to_string(),
2000 Element {
2001 type_name: "Button".to_string(),
2002 props: serde_json::json!({}), children: Vec::new(),
2004 action: None,
2005 visible: None,
2006 each: None,
2007 if_: None,
2008 },
2009 );
2010 let spec = Spec {
2011 schema: crate::spec::SCHEMA_VERSION.to_string(),
2012 root: "a".to_string(),
2013 elements,
2014 title: None,
2015 layout: None,
2016 fill_viewport: false,
2017 data: Value::Null,
2018 design: None,
2019 };
2020 let errs = cat.validate(&spec).expect_err("should fail");
2021 let props_invalid_count = errs
2022 .iter()
2023 .filter(|e| matches!(e, CatalogError::PropsInvalid { .. }))
2024 .count();
2025 assert!(
2026 props_invalid_count >= 2,
2027 "expected at least 2 PropsInvalid errors; got {errs:?}"
2028 );
2029 }
2030
2031 #[test]
2037 fn build_discovers_plugins_and_rejects_invalid_schema() {
2038 use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
2039
2040 struct GoodPlugin;
2041 impl JsonUiPlugin for GoodPlugin {
2042 fn component_type(&self) -> &str {
2043 "GoodPlugin_117"
2044 }
2045 fn props_schema(&self) -> Value {
2046 serde_json::json!({ "type": "object" })
2047 }
2048 fn render(&self, _: &Value, _: &Value) -> String {
2049 String::new()
2050 }
2051 fn css_assets(&self) -> Vec<Asset> {
2052 vec![]
2053 }
2054 fn js_assets(&self) -> Vec<Asset> {
2055 vec![]
2056 }
2057 fn init_script(&self) -> Option<String> {
2058 None
2059 }
2060 }
2061
2062 register_plugin(GoodPlugin);
2063
2064 let cat = Catalog::build().expect("build succeeds with valid plugin only");
2066 assert!(
2067 cat.plugin_components.contains_key("GoodPlugin_117"),
2068 "plugin 'GoodPlugin_117' should have been discovered"
2069 );
2070 assert!(cat.plugin_components["GoodPlugin_117"].is_plugin);
2071
2072 struct BadPlugin;
2074 impl JsonUiPlugin for BadPlugin {
2075 fn component_type(&self) -> &str {
2076 "BadPlugin_117"
2077 }
2078 fn props_schema(&self) -> Value {
2079 serde_json::json!({ "type": 42 })
2082 }
2083 fn render(&self, _: &Value, _: &Value) -> String {
2084 String::new()
2085 }
2086 fn css_assets(&self) -> Vec<Asset> {
2087 vec![]
2088 }
2089 fn js_assets(&self) -> Vec<Asset> {
2090 vec![]
2091 }
2092 fn init_script(&self) -> Option<String> {
2093 None
2094 }
2095 }
2096
2097 register_plugin(BadPlugin);
2098 match Catalog::build() {
2099 Err(CatalogError::BuildFailed(msg)) => {
2100 assert!(
2101 msg.contains("BadPlugin_117"),
2102 "error should mention plugin name, got: {msg}"
2103 );
2104 }
2105 Err(other) => panic!("expected BuildFailed mentioning BadPlugin_117, got: {other:?}"),
2106 Ok(_) => panic!("expected build to fail due to invalid plugin schema"),
2107 }
2108 }
2109
2110 #[test]
2113 fn component_schema_returns_props_only() {
2114 let cat = Catalog::build_builtins_only().expect("build");
2118 let schema = cat
2119 .component_schema("Card")
2120 .expect("Card is a built-in component");
2121
2122 let obj = schema
2126 .as_object()
2127 .expect("Card props schema is a JSON object");
2128
2129 assert!(
2131 obj.contains_key("type") || obj.contains_key("oneOf") || obj.contains_key("anyOf"),
2132 "CardProps schema should be a structural object schema; got {obj:?}"
2133 );
2134
2135 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
2137 assert!(
2138 props.contains_key("title"),
2139 "CardProps schema.properties should include 'title'; got keys: {:?}",
2140 props.keys().collect::<Vec<_>>()
2141 );
2142 } else {
2143 panic!(
2144 "CardProps schema missing top-level 'properties' map — \
2145 sanitizer or Plan 02 may be wrong. Got: {}",
2146 serde_json::to_string_pretty(schema).unwrap_or_default()
2147 );
2148 }
2149
2150 let is_element_wrapper = obj
2153 .get("properties")
2154 .and_then(|v| v.as_object())
2155 .map(|p| p.contains_key("children") && p.contains_key("props"))
2156 .unwrap_or(false);
2157 assert!(
2158 !is_element_wrapper,
2159 "component_schema('Card') returned an Element wrapper; must be Props-only (CONTEXT D-19)"
2160 );
2161 }
2162
2163 #[test]
2164 fn component_schema_none_for_unknown() {
2165 let cat = Catalog::build_builtins_only().expect("build");
2166 assert!(
2167 cat.component_schema("NotARealComponent_117_05").is_none(),
2168 "unknown component must return None"
2169 );
2170 assert!(cat.component_schema("").is_none());
2172 }
2173
2174 #[test]
2175 fn component_schema_resolves_every_builtin() {
2176 let cat = Catalog::build_builtins_only().expect("build");
2180 for name in crate::render::BUILTIN_TYPES.iter() {
2181 assert!(
2182 cat.component_schema(name).is_some(),
2183 "built-in '{name}' has no per-component schema"
2184 );
2185 }
2186 }
2187
2188 #[test]
2189 fn components_sorted_yields_ascending_by_name() {
2190 let cat = Catalog::build_builtins_only().expect("build");
2191 let names: Vec<String> = cat
2192 .components_sorted()
2193 .map(|spec| spec.name.clone())
2194 .collect();
2195 assert_eq!(names.len(), crate::render::BUILTIN_TYPES.len());
2196 let mut sorted = names.clone();
2197 sorted.sort();
2198 assert_eq!(
2199 names, sorted,
2200 "components_sorted must yield ascending order"
2201 );
2202
2203 let plugin_names: Vec<String> = cat
2205 .plugin_components_sorted()
2206 .map(|spec| spec.name.clone())
2207 .collect();
2208 let mut plugin_sorted = plugin_names.clone();
2209 plugin_sorted.sort();
2210 assert_eq!(
2211 plugin_names, plugin_sorted,
2212 "plugin_components_sorted must yield ascending order"
2213 );
2214 }
2215
2216 #[test]
2219 fn prompt_under_size_budget() {
2220 let cat = Catalog::build_builtins_only().expect("build");
2221 let prompt = cat.prompt();
2222 let bytes = prompt.len();
2223 assert!(
2233 bytes <= 14 * 1024,
2234 "prompt() is {bytes} bytes, exceeds 14 KB budget (CONTEXT D-17)"
2235 );
2236 }
2237
2238 #[test]
2239 fn prompt_mentions_every_builtin() {
2240 let cat = Catalog::build_builtins_only().expect("build");
2241 let prompt = cat.prompt();
2242 for name in crate::render::BUILTIN_TYPES.iter() {
2243 let heading = format!("### {name}\n");
2244 assert!(
2245 prompt.contains(&heading),
2246 "prompt() missing section heading for '{name}'"
2247 );
2248 }
2249 }
2250
2251 #[test]
2252 fn prompt_inlines_canonical_enum_values() {
2253 let cat = Catalog::build_builtins_only().expect("build");
2257 let prompt = cat.prompt();
2258 for values in [
2259 CANONICAL_VARIANT.join("|"),
2260 CANONICAL_TONE.join("|"),
2261 CANONICAL_SIZE.join("|"),
2262 ] {
2263 assert!(
2264 prompt.contains(&values),
2265 "prompt() must inline the canonical enum values '{values}'"
2266 );
2267 }
2268 }
2269
2270 #[test]
2271 fn prompt_is_deterministic() {
2272 let cat1 = Catalog::build_builtins_only().expect("build 1");
2273 let cat2 = Catalog::build_builtins_only().expect("build 2");
2274 assert_eq!(
2275 cat1.prompt(),
2276 cat2.prompt(),
2277 "prompt() must be deterministic"
2278 );
2279 }
2280
2281 #[test]
2282 fn prompt_documents_slot_fields() {
2283 let cat = Catalog::build_builtins_only().expect("build");
2286 let prompt = cat.prompt();
2287 let card_start = prompt.find("### Card\n").expect("Card section present");
2288 let card_slice = &prompt[card_start..];
2289 let end = card_slice[3..]
2291 .find("### ")
2292 .map(|i| i + 3)
2293 .unwrap_or(card_slice.len());
2294 let card_section = &card_slice[..end];
2295 assert!(
2296 card_section.contains("Slots: footer"),
2297 "Card section missing 'Slots: footer' line:\n{card_section}"
2298 );
2299 }
2300
2301 #[test]
2302 fn prompt_is_not_raw_json_schema() {
2303 let cat = Catalog::build_builtins_only().expect("build");
2304 let prompt = cat.prompt();
2305 assert!(
2306 prompt.starts_with("## Component Catalog"),
2307 "prompt() should start with Markdown header, not JSON"
2308 );
2309 assert!(
2310 !prompt.contains("\"$schema\""),
2311 "prompt() must not embed raw JSON Schema (ROADMAP caveat)"
2312 );
2313 }
2314
2315 #[test]
2316 fn catalog_contains_checkbox_group() {
2317 let cat = Catalog::build_builtins_only().expect("build");
2318 assert!(
2319 cat.component_schema("CheckboxGroup").is_some(),
2320 "CheckboxGroup must be registered in BUILTIN_SPECS as an alias for CheckboxList"
2321 );
2322 }
2323
2324 fn spec_from_json_string(json: &str) -> crate::spec::Spec {
2329 crate::spec::Spec::from_json(json).expect("test spec must parse")
2330 }
2331
2332 #[test]
2333 fn validate_rejects_retired_el_action_confirm_variant() {
2334 let cat = Catalog::build_builtins_only().expect("build");
2338 let spec = spec_from_json_string(
2339 r#"{
2340 "$schema": "ferro-json-ui/v2",
2341 "root": "btn",
2342 "elements": {
2343 "btn": {
2344 "type": "Button",
2345 "props": { "label": "Delete" },
2346 "action": {
2347 "handler": "orders.destroy",
2348 "method": "POST",
2349 "confirm": {
2350 "title": "Delete?",
2351 "message": "x",
2352 "variant": "danger"
2353 }
2354 }
2355 }
2356 }
2357 }"#,
2358 );
2359 let errs = cat
2360 .validate(&spec)
2361 .expect_err("should fail: confirm.variant is a retired prop");
2362 assert!(
2363 errs.iter().any(|e| matches!(
2364 e,
2365 CatalogError::PropsInvalid { errors, .. }
2366 if errors.iter().any(|m| m.contains("/action") && m.contains("variant"))
2367 )),
2368 "expected PropsInvalid mentioning /action and variant; got {errs:?}"
2369 );
2370 }
2371
2372 #[test]
2373 fn validate_accepts_canonical_el_action_confirm_tone() {
2374 let cat = Catalog::build_builtins_only().expect("build");
2376 let spec = spec_from_json_string(
2377 r#"{
2378 "$schema": "ferro-json-ui/v2",
2379 "root": "btn",
2380 "elements": {
2381 "btn": {
2382 "type": "Button",
2383 "props": { "label": "Delete" },
2384 "action": {
2385 "handler": "orders.destroy",
2386 "method": "POST",
2387 "confirm": {
2388 "title": "Delete?",
2389 "message": "x",
2390 "tone": "destructive"
2391 }
2392 }
2393 }
2394 }
2395 }"#,
2396 );
2397 if let Err(errs) = cat.validate(&spec) {
2398 panic!("validate with canonical confirm.tone failed: {errs:?}");
2399 }
2400 }
2401
2402 #[test]
2403 fn global_catalog_includes_stream_text() {
2404 let cat = Catalog::build_builtins_only().expect("build");
2405 assert!(
2406 cat.components.contains_key("StreamText"),
2407 "catalog must include StreamText"
2408 );
2409 let spec = &cat.components["StreamText"];
2410 assert_eq!(spec.name, "StreamText");
2411 assert!(
2412 spec.description.contains("event: done"),
2413 "StreamText description must mention 'event: done'; got: {}",
2414 spec.description
2415 );
2416 assert!(
2417 spec.props_schema.is_object(),
2418 "StreamText props_schema must be a JSON object"
2419 );
2420 assert!(!spec.is_plugin);
2421 }
2422
2423 #[test]
2428 fn catalog_each_template_null_data() {
2429 use crate::spec::{Element, Spec};
2430 use serde_json::json;
2431 let cat = Catalog::build_builtins_only().expect("build");
2432 let spec = Spec::builder()
2433 .element(
2434 "grid",
2435 Element::new("TileGrid")
2436 .prop("data_path", "/data/items")
2437 .prop("form_id", "f")
2438 .child("tile_tmpl"),
2439 )
2440 .element(
2441 "tile_tmpl",
2442 Element::new("Tile")
2443 .each("/data/items", "p")
2444 .prop("item_id", json!({"$data": "/p/id"}))
2445 .prop("name", json!({"$data": "/p/name"}))
2446 .prop("price", json!({"$data": "/p/price"}))
2447 .prop("field", json!({"$data": "/p/field"}))
2448 .prop("price_cents", json!({"$data": "/p/price_cents"})),
2449 )
2450 .build()
2451 .expect("spec builds");
2452 if let Err(errs) = cat.validate(&spec) {
2453 panic!("catalog_each_template_null_data failed: {errs:?}");
2454 }
2455 }
2456
2457 #[test]
2463 fn catalog_each_template_populated_data() {
2464 use crate::spec::{Element, Spec};
2465 use serde_json::json;
2466 let cat = Catalog::build_builtins_only().expect("build");
2467 let spec = Spec::builder()
2468 .data(json!({
2469 "data": {
2470 "items": [
2471 {"id": "1", "name": "A", "price": "1,00", "price_cents": 100, "field": "qty_1"}
2472 ]
2473 }
2474 }))
2475 .element(
2476 "grid",
2477 Element::new("TileGrid")
2478 .prop("data_path", "/data/items")
2479 .prop("form_id", "f")
2480 .child("tile_tmpl"),
2481 )
2482 .element(
2483 "tile_tmpl",
2484 Element::new("Tile")
2485 .each("/data/items", "p")
2486 .prop("item_id", json!({"$data": "/p/id"}))
2487 .prop("name", json!({"$data": "/p/name"}))
2488 .prop("price", json!({"$data": "/p/price"}))
2489 .prop("field", json!({"$data": "/p/field"}))
2490 .prop("price_cents", json!({"$data": "/p/price_cents"})),
2491 )
2492 .build()
2493 .expect("spec builds");
2494 if let Err(errs) = cat.validate(&spec) {
2495 panic!("catalog_each_template_populated_data failed: {errs:?}");
2496 }
2497 }
2498
2499 #[test]
2503 fn catalog_each_template_path_not_array_rejected_at_build() {
2504 use crate::spec::{Element, Spec, SpecError};
2505 use serde_json::json;
2506 let err = Spec::builder()
2507 .data(json!({"data": {"items": {"not": "an array"}}}))
2508 .element("tile_tmpl", Element::new("Tile").each("/data/items", "p"))
2509 .build()
2510 .expect_err("non-array $each path must fail spec build");
2511 assert!(
2512 matches!(err, SpecError::EachPathNotArray { .. }),
2513 "expected EachPathNotArray, got: {err:?}"
2514 );
2515 }
2516
2517 #[test]
2522 fn full_schema_root_exposes_all_spec_fields() {
2523 let cat = Catalog::build_builtins_only().expect("build");
2524 let schema = cat.json_schema();
2525 let props = schema
2526 .get("properties")
2527 .and_then(|v| v.as_object())
2528 .expect("root properties object");
2529 for key in [
2530 "$schema",
2531 "root",
2532 "elements",
2533 "title",
2534 "layout",
2535 "fill_viewport",
2536 "data",
2537 "design",
2538 ] {
2539 assert!(props.contains_key(key), "root schema must expose '{key}'");
2540 }
2541 assert!(
2542 schema.pointer("/$defs/DesignMeta").is_some(),
2543 "DesignMeta def must be hoisted into $defs"
2544 );
2545 }
2546}