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, 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 (
383 "Form",
384 "Form container with action binding and field components.",
385 || to_value(schema_for!(FormProps)).unwrap(),
386 &[],
387 ),
388 (
389 "Input",
390 "Text input with type variants, validation error, data_path pre-fill.",
391 || to_value(schema_for!(InputProps)).unwrap(),
392 &[],
393 ),
394 (
395 "Select",
396 "Dropdown select with options, error, data_path pre-fill.",
397 || to_value(schema_for!(SelectProps)).unwrap(),
398 &[],
399 ),
400 (
401 "Checkbox",
402 "Boolean checkbox with label, description, data binding.",
403 || to_value(schema_for!(CheckboxProps)).unwrap(),
404 &[],
405 ),
406 (
407 "Switch",
408 "Toggle switch (visual alternative to Checkbox); auto-submit when `action` set.",
409 || to_value(schema_for!(SwitchProps)).unwrap(),
410 &[],
411 ),
412 (
413 "CheckboxList",
414 "Multi-select checkbox group from static options or data-driven array. \
415 Each checked option submits as field=value.",
416 || to_value(schema_for!(CheckboxListProps)).unwrap(),
417 &[],
418 ),
419 (
420 "CheckboxGroup",
421 "Multi-select checkbox group (alias for CheckboxList). Each checked option \
422 submits as field=value with array-submit semantics. Identical props to \
423 CheckboxList; see that entry for full schema.",
424 || to_value(schema_for!(CheckboxListProps)).unwrap(),
425 &[],
426 ),
427 (
429 "Table",
430 "Data table with columns, row_actions, sorting, empty_message.",
431 || to_value(schema_for!(TableProps)).unwrap(),
432 &[],
433 ),
434 (
435 "DataTable",
436 "Stripe-style alternating-row table with per-row action menu and mobile card fallback.",
437 || to_value(schema_for!(DataTableProps)).unwrap(),
438 &[],
439 ),
440 (
441 "MediaCardGrid",
442 "Responsive card grid backed by a data array. Each card shows an optional screenshot image, title, description, status badge, and per-row dropdown actions.",
443 || to_value(schema_for!(MediaCardGridProps)).unwrap(),
444 &[],
445 ),
446];
447
448fn sanitize_schema(mut schema: Value) -> Value {
456 fn walk(v: &mut Value) {
457 if let Some(obj) = v.as_object_mut() {
458 if let Some(defs) = obj.remove("definitions") {
459 obj.entry("$defs".to_string()).or_insert(defs);
460 }
461 if let Some(Value::String(ref_str)) = obj.get_mut("$ref") {
462 if let Some(suffix) = ref_str.strip_prefix("#/definitions/") {
463 *ref_str = format!("#/$defs/{suffix}");
464 }
465 }
466 let keys: Vec<String> = obj.keys().cloned().collect();
468 for k in keys {
469 if let Some(child) = obj.get_mut(&k) {
470 walk(child);
471 }
472 }
473 } else if let Some(arr) = v.as_array_mut() {
474 for item in arr.iter_mut() {
475 walk(item);
476 }
477 }
478 }
479 walk(&mut schema);
480 schema
481}
482
483fn hoist_defs(schema: &mut Value, shared_defs: &mut serde_json::Map<String, Value>) {
492 if let Some(obj) = schema.as_object_mut() {
493 if let Some(Value::Object(defs)) = obj.remove("$defs") {
494 for (k, v) in defs {
495 shared_defs.entry(k).or_insert(v);
496 }
497 }
498 }
499}
500
501fn assemble_full_schema(per_component: &HashMap<String, Value>) -> Result<Value, CatalogError> {
514 let mut action_schema = sanitize_schema(to_value(schema_for!(crate::action::Action))?);
517 let mut visibility_schema =
518 sanitize_schema(to_value(schema_for!(crate::visibility::Visibility))?);
519 let mut design_schema = sanitize_schema(to_value(schema_for!(crate::spec::DesignMeta))?);
520
521 let mut shared_defs: serde_json::Map<String, Value> = serde_json::Map::new();
523 hoist_defs(&mut action_schema, &mut shared_defs);
524 hoist_defs(&mut visibility_schema, &mut shared_defs);
525 hoist_defs(&mut design_schema, &mut shared_defs);
526
527 let mut names: Vec<&String> = per_component.keys().collect();
531 names.sort();
532 let one_of: Vec<Value> = names
533 .into_iter()
534 .map(|name| {
535 let mut props_schema = per_component[name].clone();
536 hoist_defs(&mut props_schema, &mut shared_defs);
538 serde_json::json!({
539 "allOf": [
540 {
541 "type": "object",
542 "required": ["type"],
543 "properties": {
544 "type": { "const": name }
545 }
546 },
547 {
548 "type": "object",
549 "properties": {
550 "props": props_schema,
551 "children": { "type": "array", "items": { "type": "string" } },
552 "action": { "$ref": "#/$defs/Action" },
553 "visible": { "$ref": "#/$defs/Visibility" }
554 }
555 }
556 ]
557 })
558 })
559 .collect();
560
561 shared_defs
564 .entry("Action".to_string())
565 .or_insert(action_schema);
566 shared_defs
567 .entry("Visibility".to_string())
568 .or_insert(visibility_schema);
569 shared_defs
570 .entry("DesignMeta".to_string())
571 .or_insert(design_schema);
572 shared_defs.insert(
574 "Element".to_string(),
575 serde_json::json!({ "oneOf": one_of }),
576 );
577
578 Ok(serde_json::json!({
579 "$schema": "https://json-schema.org/draft/2020-12/schema",
580 "$id": "ferro-json-ui/v2",
581 "type": "object",
582 "required": ["$schema", "root", "elements"],
583 "properties": {
584 "$schema": { "const": "ferro-json-ui/v2" },
585 "root": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,127}$" },
586 "elements": {
587 "type": "object",
588 "additionalProperties": { "$ref": "#/$defs/Element" }
589 },
590 "title": { "type": ["string", "null"] },
591 "layout": { "type": ["string", "null"] },
592 "fill_viewport": { "type": "boolean", "default": false },
593 "data": true,
594 "design": { "$ref": "#/$defs/DesignMeta" }
595 },
596 "$defs": shared_defs
597 }))
598}
599
600impl Catalog {
603 pub fn build() -> Result<Self, CatalogError> {
614 if BUILTIN_SPECS.len() != crate::render::BUILTIN_TYPES.len() {
618 return Err(CatalogError::BuildFailed(format!(
619 "BUILTIN_SPECS has {} entries but BUILTIN_TYPES has {} — \
620 add an entry to BUILTIN_SPECS or remove from BUILTIN_TYPES",
621 BUILTIN_SPECS.len(),
622 crate::render::BUILTIN_TYPES.len(),
623 )));
624 }
625
626 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
628 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len() * 2);
629 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
630 let raw = schema_fn();
631 let schema = sanitize_schema(raw);
632 per_component_schemas.insert((*name).to_string(), schema.clone());
633 components.insert(
634 (*name).to_string(),
635 ComponentSpec {
636 name: (*name).to_string(),
637 description: (*desc).to_string(),
638 props_schema: schema,
639 is_plugin: false,
640 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
641 },
642 );
643 }
644
645 let mut plugin_components = HashMap::new();
651 for plugin_type in crate::plugin::registered_plugin_types() {
652 if components.contains_key(&plugin_type) {
654 continue;
655 }
656 let raw = crate::plugin::with_plugin(&plugin_type, |p| p.props_schema())
657 .unwrap_or(Value::Null);
658 let schema = sanitize_schema(raw);
659 if jsonschema::validator_for(&schema).is_err() {
661 return Err(CatalogError::BuildFailed(format!(
662 "plugin '{plugin_type}' returned an invalid JSON Schema"
663 )));
664 }
665 per_component_schemas.insert(plugin_type.clone(), schema.clone());
666 plugin_components.insert(
667 plugin_type.clone(),
668 ComponentSpec {
669 name: plugin_type,
670 description: String::from("Plugin component."),
671 props_schema: schema,
672 is_plugin: true,
673 slot_fields: Vec::new(),
674 },
675 );
676 }
677
678 let full_schema = assemble_full_schema(&per_component_schemas)?;
680
681 let validator = jsonschema::validator_for(&full_schema)
683 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
684
685 Ok(Catalog {
686 components,
687 plugin_components,
688 full_schema,
689 per_component_schemas,
690 validator,
691 })
692 }
693
694 pub fn json_schema(&self) -> &Value {
701 &self.full_schema
702 }
703
704 pub fn validate(&self, spec: &crate::spec::Spec) -> Result<(), Vec<CatalogError>> {
731 let mut errors: Vec<CatalogError> = Vec::new();
732
733 for (id, el) in &spec.elements {
735 let known = self.components.contains_key(&el.type_name)
736 || self.plugin_components.contains_key(&el.type_name);
737 if !known {
738 errors.push(CatalogError::UnknownType {
739 element_id: id.clone(),
740 type_name: el.type_name.clone(),
741 });
742 }
743 }
744 if !errors.is_empty() {
749 return Err(errors);
750 }
751
752 for (id, el) in &spec.elements {
754 if let Some(schema) = self.per_component_schemas.get(&el.type_name) {
755 if el.props.is_null() {
760 continue;
761 }
762 if el.each.is_some() {
771 continue;
772 }
773 let v = match jsonschema::validator_for(schema) {
777 Ok(v) => v,
778 Err(e) => {
779 errors.push(CatalogError::BuildFailed(format!(
780 "compiling per-component schema for '{}': {e}",
781 el.type_name
782 )));
783 continue;
784 }
785 };
786 let validation_props = strip_expr_objects(&el.props);
792 let mut per_elem_errs: Vec<String> = Vec::new();
793 for err in v.iter_errors(&validation_props) {
794 per_elem_errs.push(format!("{}: {}", err.instance_path(), err));
795 }
796 if !per_elem_errs.is_empty() {
797 errors.push(CatalogError::PropsInvalid {
798 element_id: id.clone(),
799 type_name: el.type_name.clone(),
800 errors: per_elem_errs,
801 });
802 }
803 }
804 }
805
806 for (id, el) in &spec.elements {
813 let mut renamed: Vec<String> = Vec::new();
814 for (ty, old, new) in RETIRED_PROPS {
815 if el.type_name == *ty && el.props.get(old).is_some() {
816 renamed.push(format!(
817 "/{old}: `{old}` was renamed to `{new}` — update the spec"
818 ));
819 }
820 }
821 collect_retired_action_variants(&el.props, "", &mut renamed);
822 if let Some(action) = &el.action {
823 if let Ok(action_value) = serde_json::to_value(action) {
824 collect_retired_action_variants(&action_value, "/action", &mut renamed);
825 }
826 }
827 if !renamed.is_empty() {
828 errors.push(CatalogError::PropsInvalid {
829 element_id: id.clone(),
830 type_name: el.type_name.clone(),
831 errors: renamed,
832 });
833 }
834 }
835
836 let mut spec_value = match serde_json::to_value(spec) {
838 Ok(v) => v,
839 Err(e) => {
840 errors.push(CatalogError::SchemaSerialization(e));
841 return Err(errors);
842 }
843 };
844 if let Some(elements) = spec_value
852 .get_mut("elements")
853 .and_then(|e| e.as_object_mut())
854 {
855 for (id, el_val) in elements.iter_mut() {
856 let is_template = spec
857 .elements
858 .get(id)
859 .map(|e| e.each.is_some())
860 .unwrap_or(false);
861 if is_template {
862 if let Some(obj) = el_val.as_object_mut() {
863 obj.remove("props");
864 }
865 }
866 }
867 }
868 let stripped_spec_value = strip_expr_objects(&spec_value);
870 let mut envelope_errs: Vec<String> = Vec::new();
871 for err in self.validator.iter_errors(&stripped_spec_value) {
872 envelope_errs.push(format!("{}: {}", err.instance_path(), err));
873 }
874 if !envelope_errs.is_empty() {
875 errors.push(CatalogError::SpecInvalid {
876 errors: envelope_errs,
877 });
878 }
879
880 if errors.is_empty() {
881 Ok(())
882 } else {
883 Err(errors)
884 }
885 }
886
887 pub fn component_schema(&self, type_name: &str) -> Option<&Value> {
901 self.per_component_schemas.get(type_name)
902 }
903
904 pub fn components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
911 let mut entries: Vec<&ComponentSpec> = self.components.values().collect();
912 entries.sort_by(|a, b| a.name.cmp(&b.name));
913 entries.into_iter()
914 }
915
916 pub fn plugin_components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
922 let mut entries: Vec<&ComponentSpec> = self.plugin_components.values().collect();
923 entries.sort_by(|a, b| a.name.cmp(&b.name));
924 entries.into_iter()
925 }
926
927 pub fn prompt(&self) -> String {
943 let mut out = String::with_capacity(8 * 1024);
944 out.push_str("## Component Catalog\n\n");
945 out.push_str("Slot fields are Vec<String> of element IDs; body children come from Element.children.\n\n");
946 for spec in self.components_sorted() {
947 render_component_section(&mut out, spec);
948 }
949 if self.plugin_components.is_empty() {
950 return out;
951 }
952 out.push_str("## Plugin Components\n\n");
953 for spec in self.plugin_components_sorted() {
954 render_component_section(&mut out, spec);
955 }
956 out
957 }
958}
959
960const RETIRED_PROPS: &[(&str, &str, &str)] = &[
965 ("Card", "variant", "appearance"),
966 ("Badge", "variant", "tone"),
967 ("Alert", "variant", "tone"),
968 ("Toast", "variant", "tone"),
969 ("ActionCard", "variant", "tone"),
970 ("MediaCardGrid", "badge_variant_key", "badge_tone_key"),
971];
972
973fn collect_retired_action_variants(value: &Value, path: &str, out: &mut Vec<String>) {
980 match value {
981 Value::Object(map) => {
982 for (key, child) in map {
983 let child_path = format!("{path}/{key}");
984 if let Value::Object(obj) = child {
985 let is_confirm = key == "confirm";
986 let is_notify_outcome = (key == "on_success" || key == "on_error")
987 && obj.get("type").and_then(Value::as_str) == Some("notify");
988 if (is_confirm || is_notify_outcome) && obj.contains_key("variant") {
989 out.push(format!(
990 "{child_path}/variant: `variant` was renamed to `tone` — update the spec"
991 ));
992 }
993 }
994 collect_retired_action_variants(child, &child_path, out);
995 }
996 }
997 Value::Array(arr) => {
998 for (i, child) in arr.iter().enumerate() {
999 collect_retired_action_variants(child, &format!("{path}/{i}"), out);
1000 }
1001 }
1002 _ => {}
1003 }
1004}
1005
1006fn render_component_section(out: &mut String, spec: &ComponentSpec) {
1023 out.push_str("### ");
1024 out.push_str(&spec.name);
1025 out.push('\n');
1026 out.push_str(&spec.description);
1027 out.push('\n');
1028
1029 let props_line = render_props_line(&spec.props_schema);
1030 if !props_line.is_empty() {
1031 out.push_str("Props: ");
1032 out.push_str(&props_line);
1033 out.push('\n');
1034 }
1035 if !spec.slot_fields.is_empty() {
1036 out.push_str("Slots: ");
1037 out.push_str(&spec.slot_fields.join(", "));
1038 out.push('\n');
1039 }
1040 out.push('\n');
1041}
1042
1043fn render_props_line(schema: &Value) -> String {
1055 let Some(obj) = schema.as_object() else {
1056 return String::new();
1057 };
1058 let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
1059 return String::new();
1060 };
1061 let defs = obj.get("$defs").and_then(|v| v.as_object());
1065 let required: std::collections::HashSet<&str> = obj
1066 .get("required")
1067 .and_then(|v| v.as_array())
1068 .map(|arr| {
1069 arr.iter()
1070 .filter_map(|v| v.as_str())
1071 .collect::<std::collections::HashSet<_>>()
1072 })
1073 .unwrap_or_default();
1074
1075 let parts: Vec<String> = props
1076 .iter()
1077 .map(|(name, field_schema)| {
1078 let ty = render_field_type(field_schema, required.contains(name.as_str()), defs);
1079 format!("{name} ({ty})")
1080 })
1081 .collect();
1082 parts.join(", ")
1083}
1084
1085fn resolve_local_enum_ref<'a>(
1090 schema: &'a Value,
1091 defs: Option<&'a serde_json::Map<String, Value>>,
1092) -> Option<Vec<&'a str>> {
1093 let name = schema.get("$ref")?.as_str()?.strip_prefix("#/$defs/")?;
1094 let target = defs?.get(name)?;
1095 let arr = target.get("enum")?.as_array()?;
1096 Some(arr.iter().filter_map(|v| v.as_str()).collect())
1097}
1098
1099fn render_field_type(
1104 schema: &Value,
1105 is_required: bool,
1106 defs: Option<&serde_json::Map<String, Value>>,
1107) -> String {
1108 if let Some(variants) = schema.get("enum").and_then(|v| v.as_array()) {
1110 let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
1111 let inner = render_enum_inline(&names);
1112 return wrap_optional(inner, is_required);
1113 }
1114 for key in ["anyOf", "oneOf"] {
1116 if let Some(arr) = schema.get(key).and_then(|v| v.as_array()) {
1117 let has_null = arr
1118 .iter()
1119 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
1120 let non_null: Vec<&Value> = arr
1121 .iter()
1122 .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
1123 .collect();
1124 if has_null && non_null.len() == 1 {
1125 let inner = render_field_type(non_null[0], true, defs);
1126 return format!("Option<{inner}>");
1127 }
1128 }
1129 }
1130 if let Some(types) = schema.get("type").and_then(|v| v.as_array()) {
1132 let non_null: Vec<&str> = types
1133 .iter()
1134 .filter_map(|v| v.as_str())
1135 .filter(|s| *s != "null")
1136 .collect();
1137 let has_null = types.iter().any(|v| v.as_str() == Some("null"));
1138 if has_null && non_null.len() == 1 {
1139 return format!("Option<{}>", rust_for_json_type(non_null[0], schema, defs));
1140 }
1141 }
1142 if let Some(t) = schema.get("type").and_then(|v| v.as_str()) {
1144 let inner = rust_for_json_type(t, schema, defs);
1145 return wrap_optional(inner, is_required);
1146 }
1147 if let Some(names) = resolve_local_enum_ref(schema, defs) {
1150 return wrap_optional(render_enum_inline(&names), is_required);
1151 }
1152 wrap_optional("<see schema>".to_string(), is_required)
1154}
1155
1156fn rust_for_json_type(
1158 t: &str,
1159 schema: &Value,
1160 defs: Option<&serde_json::Map<String, Value>>,
1161) -> String {
1162 match t {
1163 "string" => "String".to_string(),
1164 "integer" => "i64".to_string(),
1165 "number" => "f64".to_string(),
1166 "boolean" => "bool".to_string(),
1167 "array" => {
1168 if let Some(items) = schema.get("items") {
1169 let inner = render_field_type(items, true, defs);
1170 format!("Vec<{inner}>")
1171 } else {
1172 "Vec<Value>".to_string()
1173 }
1174 }
1175 "object" => "Object".to_string(),
1176 other => other.to_string(),
1177 }
1178}
1179
1180fn render_enum_inline(variants: &[&str]) -> String {
1182 if variants.len() <= 8 {
1183 variants.join("|")
1184 } else {
1185 format!("one of {} — see schema", variants.len())
1186 }
1187}
1188
1189fn wrap_optional(inner: String, is_required: bool) -> String {
1191 if is_required {
1192 inner
1193 } else {
1194 format!("Option<{inner}>")
1195 }
1196}
1197
1198fn strip_expr_objects(val: &Value) -> Value {
1207 match val {
1208 Value::Object(map) => {
1209 if map.len() == 1 && (map.contains_key("$data") || map.contains_key("$template")) {
1210 Value::String(String::new())
1211 } else {
1212 Value::Object(
1213 map.iter()
1214 .map(|(k, v)| (k.clone(), strip_expr_objects(v)))
1215 .collect(),
1216 )
1217 }
1218 }
1219 Value::Array(arr) => Value::Array(arr.iter().map(strip_expr_objects).collect()),
1220 other => other.clone(),
1221 }
1222}
1223
1224pub fn global_catalog() -> &'static Catalog {
1237 static GLOBAL_CATALOG: OnceLock<Catalog> = OnceLock::new();
1238 GLOBAL_CATALOG.get_or_init(|| {
1239 Catalog::build().expect("catalog build failed — see CatalogError for details")
1240 })
1241}
1242
1243#[cfg(test)]
1246impl Catalog {
1247 pub(crate) fn build_builtins_only() -> Result<Self, CatalogError> {
1252 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
1253 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len());
1254 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
1255 let raw = schema_fn();
1256 let schema = sanitize_schema(raw);
1257 per_component_schemas.insert((*name).to_string(), schema.clone());
1258 components.insert(
1259 (*name).to_string(),
1260 ComponentSpec {
1261 name: (*name).to_string(),
1262 description: (*desc).to_string(),
1263 props_schema: schema,
1264 is_plugin: false,
1265 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
1266 },
1267 );
1268 }
1269 let full_schema = assemble_full_schema(&per_component_schemas)?;
1270 let validator = jsonschema::validator_for(&full_schema)
1271 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
1272 Ok(Catalog {
1273 components,
1274 plugin_components: HashMap::new(),
1275 full_schema,
1276 per_component_schemas,
1277 validator,
1278 })
1279 }
1280}
1281
1282#[cfg(test)]
1283mod tests {
1284 use super::*;
1285
1286 #[test]
1287 fn builtin_types_count_drift_guard() {
1288 assert_eq!(crate::render::BUILTIN_TYPES.len(), 52);
1297 }
1298
1299 const CANONICAL_VARIANT: &[&str] = &["primary", "secondary", "outline", "ghost", "destructive"];
1307 const CANONICAL_TONE: &[&str] = &["neutral", "success", "warning", "destructive"];
1308 const CANONICAL_SIZE: &[&str] = &["sm", "md", "lg"];
1309
1310 fn canonical_set_for(prop: &str) -> Option<&'static [&'static str]> {
1312 match prop {
1313 "variant" => Some(CANONICAL_VARIANT),
1314 "tone" => Some(CANONICAL_TONE),
1315 "size" => Some(CANONICAL_SIZE),
1316 _ => None,
1317 }
1318 }
1319
1320 fn extract_enum_values<'a>(
1326 schema: &'a Value,
1327 defs: &'a serde_json::Map<String, Value>,
1328 ) -> Option<Vec<&'a str>> {
1329 if let Some(name) = schema
1330 .get("$ref")
1331 .and_then(|v| v.as_str())
1332 .and_then(|r| r.strip_prefix("#/$defs/"))
1333 {
1334 return extract_enum_values(defs.get(name)?, defs);
1335 }
1336 if let Some(arr) = schema.get("enum").and_then(|v| v.as_array()) {
1337 return Some(arr.iter().filter_map(|v| v.as_str()).collect());
1338 }
1339 for key in ["anyOf", "oneOf"] {
1340 let Some(arr) = schema.get(key).and_then(|v| v.as_array()) else {
1341 continue;
1342 };
1343 let non_null: Vec<&Value> = arr
1344 .iter()
1345 .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
1346 .collect();
1347 if non_null.len() == 1 && non_null.len() < arr.len() {
1349 return extract_enum_values(non_null[0], defs);
1350 }
1351 let consts: Vec<&str> = non_null
1353 .iter()
1354 .filter_map(|v| v.get("const").and_then(|c| c.as_str()))
1355 .collect();
1356 if !consts.is_empty() && consts.len() == non_null.len() {
1357 return Some(consts);
1358 }
1359 }
1360 None
1361 }
1362
1363 fn walk_canonical_enum_props(
1369 node: &Value,
1370 defs: &serde_json::Map<String, Value>,
1371 visited: &mut std::collections::HashSet<String>,
1372 checked: &mut usize,
1373 ) {
1374 match node {
1375 Value::Object(obj) => {
1376 if let Some(name) = obj
1377 .get("$ref")
1378 .and_then(|v| v.as_str())
1379 .and_then(|r| r.strip_prefix("#/$defs/"))
1380 {
1381 if visited.insert(name.to_string()) {
1382 if let Some(target) = defs.get(name) {
1383 walk_canonical_enum_props(target, defs, visited, checked);
1384 }
1385 }
1386 }
1387 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
1388 for (prop_name, prop_schema) in props {
1389 let Some(want) = canonical_set_for(prop_name) else {
1390 continue;
1391 };
1392 let got = extract_enum_values(prop_schema, defs).unwrap_or_else(|| {
1393 panic!(
1394 "schema property '{prop_name}' must be enum-typed with the \
1395 canonical vocabulary, got non-enum schema: {prop_schema}"
1396 )
1397 });
1398 assert_eq!(
1399 got.as_slice(),
1400 want,
1401 "schema property '{prop_name}' carries a non-canonical value set \
1402 {got:?} (canonical: {want:?})"
1403 );
1404 *checked += 1;
1405 }
1406 }
1407 for child in obj.values() {
1408 walk_canonical_enum_props(child, defs, visited, checked);
1409 }
1410 }
1411 Value::Array(arr) => {
1412 for item in arr {
1413 walk_canonical_enum_props(item, defs, visited, checked);
1414 }
1415 }
1416 _ => {}
1417 }
1418 }
1419
1420 #[test]
1421 fn variant_tone_size_enum_sets_drift_guard() {
1422 let cat = Catalog::build_builtins_only().expect("build succeeds");
1430 let schema = cat.json_schema();
1431 let defs = schema
1432 .get("$defs")
1433 .and_then(|v| v.as_object())
1434 .expect("assembled schema has a root $defs map");
1435
1436 for (def_name, want) in [
1438 ("Variant", CANONICAL_VARIANT),
1439 ("Tone", CANONICAL_TONE),
1440 ("Size", CANONICAL_SIZE),
1441 ] {
1442 let def = defs
1443 .get(def_name)
1444 .unwrap_or_else(|| panic!("$defs/{def_name} missing from the assembled schema"));
1445 let got = extract_enum_values(def, defs)
1446 .unwrap_or_else(|| panic!("$defs/{def_name} is not an enum schema: {def}"));
1447 assert_eq!(
1448 got.as_slice(),
1449 want,
1450 "$defs/{def_name} value set drifted from the canonical enum"
1451 );
1452 }
1453
1454 let one_of = defs
1456 .get("Element")
1457 .and_then(|e| e.get("oneOf"))
1458 .and_then(|v| v.as_array())
1459 .expect("$defs/Element/oneOf array");
1460 assert_eq!(
1461 one_of.len(),
1462 crate::render::BUILTIN_TYPES.len(),
1463 "oneOf must carry one entry per builtin component"
1464 );
1465 let mut checked = 0usize;
1466 for entry in one_of {
1467 let props = entry
1468 .pointer("/allOf/1/properties/props")
1469 .unwrap_or_else(|| {
1470 panic!("oneOf entry missing allOf[1].properties.props: {entry}")
1471 });
1472 let mut visited = std::collections::HashSet::new();
1473 walk_canonical_enum_props(props, defs, &mut visited, &mut checked);
1474 }
1475
1476 let mut visited = std::collections::HashSet::new();
1481 for def in defs.values() {
1482 walk_canonical_enum_props(def, defs, &mut visited, &mut checked);
1483 }
1484
1485 assert!(
1486 checked >= 10,
1487 "walker asserted only {checked} variant/tone/size properties — \
1488 the schema traversal is broken (expected at least 10 across the catalog)"
1489 );
1490 }
1491
1492 #[test]
1493 fn builtin_specs_len_matches_dispatch() {
1494 assert_eq!(BUILTIN_SPECS.len(), crate::render::BUILTIN_TYPES.len());
1497 }
1498
1499 #[test]
1500 fn builtin_specs_names_match_dispatch() {
1501 use std::collections::HashSet;
1502 let specs: HashSet<&str> = BUILTIN_SPECS.iter().map(|(n, ..)| *n).collect();
1503 let types: HashSet<&str> = crate::render::BUILTIN_TYPES.iter().copied().collect();
1504 assert_eq!(specs, types, "BUILTIN_SPECS names must match BUILTIN_TYPES");
1505 }
1506
1507 #[test]
1508 fn build_populates_all_builtins() {
1509 let cat = Catalog::build_builtins_only().expect("build succeeds");
1511 for name in crate::render::BUILTIN_TYPES.iter() {
1512 assert!(
1513 cat.components.contains_key(*name),
1514 "built-in '{name}' missing from catalog.components"
1515 );
1516 let spec = &cat.components[*name];
1517 assert_eq!(spec.name, *name);
1518 assert!(
1519 !spec.description.is_empty(),
1520 "'{name}' has empty description"
1521 );
1522 assert!(
1523 spec.props_schema.is_object(),
1524 "'{name}' props_schema is not a JSON object"
1525 );
1526 assert!(!spec.is_plugin);
1527 }
1528 }
1529
1530 #[test]
1531 fn build_card_has_footer_slot() {
1532 let cat = Catalog::build_builtins_only().expect("build succeeds");
1534 let card = &cat.components["Card"];
1535 assert_eq!(card.slot_fields, vec!["footer"]);
1536 }
1537
1538 #[test]
1539 fn build_modal_has_footer_slot() {
1540 let cat = Catalog::build_builtins_only().expect("build succeeds");
1542 let modal = &cat.components["Modal"];
1543 assert_eq!(modal.slot_fields, vec!["footer"]);
1544 }
1545
1546 #[test]
1547 fn build_pageheader_has_actions_slot() {
1548 let cat = Catalog::build_builtins_only().expect("build succeeds");
1550 let ph = &cat.components["PageHeader"];
1551 assert_eq!(ph.slot_fields, vec!["actions"]);
1552 }
1553
1554 #[test]
1555 fn build_text_has_no_slots() {
1556 let cat = Catalog::build_builtins_only().expect("build succeeds");
1558 assert!(cat.components["Text"].slot_fields.is_empty());
1559 }
1560
1561 #[test]
1562 fn build_populates_per_component_schemas() {
1563 let cat = Catalog::build_builtins_only().expect("build succeeds");
1565 assert_eq!(
1566 cat.per_component_schemas.len(),
1567 BUILTIN_SPECS.len() + cat.plugin_components.len()
1568 );
1569 }
1570
1571 #[test]
1572 fn sanitize_schema_rewrites_definitions_to_dollar_defs() {
1573 let raw = serde_json::json!({
1574 "type": "object",
1575 "definitions": { "Foo": { "type": "string" } },
1576 "properties": {
1577 "x": { "$ref": "#/definitions/Foo" }
1578 }
1579 });
1580 let out = sanitize_schema(raw);
1581 assert!(out.get("definitions").is_none());
1582 assert!(out.get("$defs").is_some());
1583 assert_eq!(
1584 out["properties"]["x"]["$ref"].as_str().unwrap(),
1585 "#/$defs/Foo"
1586 );
1587 }
1588
1589 #[test]
1590 fn sanitize_schema_is_idempotent() {
1591 let raw = serde_json::json!({
1592 "type": "object",
1593 "$defs": { "Foo": { "type": "string" } },
1594 "properties": {
1595 "x": { "$ref": "#/$defs/Foo" }
1596 }
1597 });
1598 let once = sanitize_schema(raw.clone());
1599 let twice = sanitize_schema(once.clone());
1600 assert_eq!(once, twice);
1601 assert!(twice.get("definitions").is_none());
1603 assert!(twice.get("$defs").is_some());
1604 }
1605
1606 #[test]
1607 fn json_schema_has_spec_envelope_shape() {
1608 let cat = Catalog::build_builtins_only().expect("build");
1611 let schema = cat.json_schema();
1612 assert_eq!(schema["$id"], "ferro-json-ui/v2");
1613 assert_eq!(schema["type"], "object");
1614 let required: Vec<&str> = schema["required"]
1615 .as_array()
1616 .unwrap()
1617 .iter()
1618 .map(|v| v.as_str().unwrap())
1619 .collect();
1620 assert!(required.contains(&"$schema"));
1621 assert!(required.contains(&"root"));
1622 assert!(required.contains(&"elements"));
1623 }
1624
1625 #[test]
1626 fn json_schema_has_action_and_visibility_defs() {
1627 let cat = Catalog::build_builtins_only().expect("build");
1628 let schema = cat.json_schema();
1629 assert!(
1630 schema["$defs"]["Action"].is_object(),
1631 "$defs/Action missing"
1632 );
1633 assert!(
1634 schema["$defs"]["Visibility"].is_object(),
1635 "$defs/Visibility missing"
1636 );
1637 assert!(
1638 schema["$defs"]["Element"].is_object(),
1639 "$defs/Element missing"
1640 );
1641 }
1642
1643 #[test]
1644 fn json_schema_oneof_covers_all_builtins() {
1645 let cat = Catalog::build_builtins_only().expect("build");
1646 let schema = cat.json_schema();
1647 let one_of = schema["$defs"]["Element"]["oneOf"]
1649 .as_array()
1650 .expect("Element.oneOf is an array");
1651
1652 let mut discriminators: std::collections::HashSet<String> =
1654 std::collections::HashSet::new();
1655 for variant in one_of {
1656 let c = variant["allOf"][0]["properties"]["type"]["const"]
1657 .as_str()
1658 .expect("every variant pins a type const");
1659 discriminators.insert(c.to_string());
1660 }
1661
1662 for name in crate::render::BUILTIN_TYPES.iter() {
1663 assert!(
1664 discriminators.contains(*name),
1665 "oneOf is missing discriminator for '{name}'"
1666 );
1667 }
1668
1669 assert_eq!(
1671 discriminators.len(),
1672 crate::render::BUILTIN_TYPES.len(),
1673 "oneOf variant count mismatch"
1674 );
1675 }
1676
1677 #[test]
1678 fn json_schema_is_valid() {
1679 use jsonschema::draft202012;
1680 let cat = Catalog::build_builtins_only().expect("build");
1681 let schema = cat.json_schema();
1682 assert!(
1683 draft202012::meta::is_valid(schema),
1684 "assembled full_schema did not meta-validate as Draft 2020-12"
1685 );
1686 }
1687
1688 #[test]
1689 fn validator_is_compiled_once_and_usable() {
1690 let cat = Catalog::build_builtins_only().expect("build");
1691 let minimal_valid = serde_json::json!({
1695 "$schema": "ferro-json-ui/v2",
1696 "root": "r",
1697 "elements": {
1698 "r": { "type": "Text", "props": { "content": "hi" } }
1699 }
1700 });
1701 assert!(cat.validator.is_valid(&minimal_valid));
1703 }
1704
1705 #[test]
1706 fn validator_rejects_wrong_schema_version() {
1707 let cat = Catalog::build_builtins_only().expect("build");
1708 let wrong_version = serde_json::json!({
1709 "$schema": "ferro-json-ui/v99-wrong",
1710 "root": "r",
1711 "elements": {
1712 "r": { "type": "Text", "props": { "content": "hi" } }
1713 }
1714 });
1715 assert!(
1716 !cat.validator.is_valid(&wrong_version),
1717 "validator should reject unknown $schema version via const"
1718 );
1719 }
1720
1721 #[test]
1722 fn oneof_variants_are_deterministic_sorted() {
1723 let cat1 = Catalog::build_builtins_only().expect("build 1");
1724 let cat2 = Catalog::build_builtins_only().expect("build 2");
1725 assert_eq!(
1727 serde_json::to_string(cat1.json_schema()).unwrap(),
1728 serde_json::to_string(cat2.json_schema()).unwrap()
1729 );
1730 }
1731
1732 fn test_spec_with(type_name: &str, props: Value) -> crate::spec::Spec {
1736 use crate::spec::{Element, Spec};
1737 use std::collections::HashMap;
1738 let mut elements = HashMap::new();
1739 elements.insert(
1740 "r".to_string(),
1741 Element {
1742 type_name: type_name.to_string(),
1743 props,
1744 children: Vec::new(),
1745 action: None,
1746 visible: None,
1747 each: None,
1748 if_: None,
1749 },
1750 );
1751 Spec {
1752 schema: crate::spec::SCHEMA_VERSION.to_string(),
1753 root: "r".to_string(),
1754 elements,
1755 title: None,
1756 layout: None,
1757 fill_viewport: false,
1758 data: Value::Null,
1759 design: None,
1760 }
1761 }
1762
1763 #[test]
1764 fn validate_positive_per_type() {
1765 let cat = Catalog::build_builtins_only().expect("build");
1768 let cases: Vec<(&str, Value)> = vec![
1769 ("Text", serde_json::json!({ "content": "hi" })),
1770 ("Button", serde_json::json!({ "label": "Save" })),
1771 ("Badge", serde_json::json!({ "label": "New" })),
1772 ("Separator", serde_json::json!({})),
1773 ];
1774 for (ty, props) in cases {
1775 let spec = test_spec_with(ty, props.clone());
1776 match cat.validate(&spec) {
1777 Ok(()) => {}
1778 Err(errs) => panic!("validate({ty}) failed: {errs:?}"),
1779 }
1780 }
1781 }
1782
1783 #[test]
1784 fn validate_unknown_type() {
1785 let cat = Catalog::build_builtins_only().expect("build");
1786 let spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1787 let errs = cat.validate(&spec).expect_err("should fail");
1788 assert!(
1789 errs.iter().any(|e| matches!(
1790 e,
1791 CatalogError::UnknownType { type_name, .. } if type_name == "NotARealComponent"
1792 )),
1793 "expected UnknownType for NotARealComponent; got {errs:?}"
1794 );
1795 }
1796
1797 #[test]
1798 fn validate_missing_required_prop() {
1799 let cat = Catalog::build_builtins_only().expect("build");
1802 let spec = test_spec_with("Card", serde_json::json!({}));
1803 let errs = cat.validate(&spec).expect_err("should fail");
1804 assert!(
1805 errs.iter().any(|e| matches!(
1806 e,
1807 CatalogError::PropsInvalid { type_name, .. } if type_name == "Card"
1808 )),
1809 "expected PropsInvalid for missing required 'title'; got {errs:?}"
1810 );
1811 }
1812
1813 #[test]
1814 fn validate_rejects_retired_prop_names() {
1815 let cat = Catalog::build_builtins_only().expect("build");
1818 let cases: Vec<(&str, Value, &str)> = vec![
1819 (
1820 "Badge",
1821 serde_json::json!({ "label": "Paid", "variant": "success" }),
1822 "tone",
1823 ),
1824 (
1825 "Card",
1826 serde_json::json!({ "title": "T", "variant": "elevated" }),
1827 "appearance",
1828 ),
1829 (
1830 "MediaCardGrid",
1831 serde_json::json!({
1832 "data_path": "/rows",
1833 "title_key": "name",
1834 "badge_variant_key": "status"
1835 }),
1836 "badge_tone_key",
1837 ),
1838 ];
1839 for (ty, props, new_name) in cases {
1840 let spec = test_spec_with(ty, props);
1841 let errs = cat.validate(&spec).expect_err("should fail");
1842 assert!(
1843 errs.iter().any(|e| matches!(
1844 e,
1845 CatalogError::PropsInvalid { type_name, errors, .. }
1846 if type_name == ty && errors.iter().any(|m| m.contains(new_name))
1847 )),
1848 "expected retired-prop PropsInvalid for {ty} mentioning `{new_name}`; got {errs:?}"
1849 );
1850 }
1851 }
1852
1853 #[test]
1854 fn validate_rejects_retired_confirm_and_notify_variant() {
1855 let cat = Catalog::build_builtins_only().expect("build");
1858 let spec = test_spec_with(
1859 "DataTable",
1860 serde_json::json!({
1861 "data_path": "/rows",
1862 "columns": [{ "key": "name", "label": "Name" }],
1863 "row_actions": [{
1864 "label": "Delete",
1865 "action": {
1866 "handler": "rows.destroy",
1867 "method": "DELETE",
1868 "confirm": { "title": "Delete?", "variant": "danger" },
1869 "on_success": {
1870 "type": "notify",
1871 "message": "Deleted",
1872 "variant": "error"
1873 }
1874 }
1875 }]
1876 }),
1877 );
1878 let errs = cat.validate(&spec).expect_err("should fail");
1879 let retired_msgs: Vec<&String> = errs
1880 .iter()
1881 .filter_map(|e| match e {
1882 CatalogError::PropsInvalid { errors, .. } => Some(errors),
1883 _ => None,
1884 })
1885 .flatten()
1886 .filter(|m| m.contains("renamed to `tone`"))
1887 .collect();
1888 assert_eq!(
1889 retired_msgs.len(),
1890 2,
1891 "expected confirm + notify retired-variant errors; got {errs:?}"
1892 );
1893 }
1894
1895 #[test]
1896 fn validate_accepts_canonical_prop_names() {
1897 let cat = Catalog::build_builtins_only().expect("build");
1899 let cases: Vec<(&str, Value)> = vec![
1900 (
1901 "Badge",
1902 serde_json::json!({ "label": "Paid", "tone": "success" }),
1903 ),
1904 (
1905 "Card",
1906 serde_json::json!({ "title": "T", "appearance": "elevated" }),
1907 ),
1908 ];
1909 for (ty, props) in cases {
1910 let spec = test_spec_with(ty, props.clone());
1911 if let Err(errs) = cat.validate(&spec) {
1912 panic!("validate({ty}) with canonical props failed: {errs:?}");
1913 }
1914 }
1915 }
1916
1917 #[test]
1918 fn validate_bad_schema_version() {
1919 let cat = Catalog::build_builtins_only().expect("build");
1920 let mut spec = test_spec_with("Text", serde_json::json!({ "content": "hi" }));
1921 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1922 let errs = cat.validate(&spec).expect_err("should fail");
1923 assert!(
1924 errs.iter()
1925 .any(|e| matches!(e, CatalogError::SpecInvalid { .. })),
1926 "expected SpecInvalid for wrong $schema version; got {errs:?}"
1927 );
1928 }
1929
1930 #[test]
1931 fn validate_pre_dispatch_short_circuits() {
1932 let cat = Catalog::build_builtins_only().expect("build");
1935 let mut spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1936 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1937 let errs = cat.validate(&spec).expect_err("should fail");
1938
1939 let has_unknown = errs
1940 .iter()
1941 .any(|e| matches!(e, CatalogError::UnknownType { .. }));
1942 let has_spec_invalid = errs
1943 .iter()
1944 .any(|e| matches!(e, CatalogError::SpecInvalid { .. }));
1945 let has_props_invalid = errs
1946 .iter()
1947 .any(|e| matches!(e, CatalogError::PropsInvalid { .. }));
1948
1949 assert!(has_unknown, "expected UnknownType");
1950 assert!(
1951 !has_spec_invalid,
1952 "Stage 3 ran despite Stage 1 failing: {errs:?}"
1953 );
1954 assert!(
1955 !has_props_invalid,
1956 "Stage 2 ran despite Stage 1 failing: {errs:?}"
1957 );
1958 }
1959
1960 #[test]
1961 fn validator_is_cached_not_recompiled() {
1962 let cat = Catalog::build_builtins_only().expect("build");
1966 for _ in 0..100 {
1967 let spec = test_spec_with("Text", serde_json::json!({ "content": "x" }));
1968 assert!(cat.validate(&spec).is_ok());
1969 }
1970 }
1971
1972 #[test]
1973 fn validate_accumulates_multiple_errors_across_elements() {
1974 use crate::spec::{Element, Spec};
1976 use std::collections::HashMap;
1977 let cat = Catalog::build_builtins_only().expect("build");
1978 let mut elements = HashMap::new();
1979 elements.insert(
1980 "a".to_string(),
1981 Element {
1982 type_name: "Card".to_string(),
1983 props: serde_json::json!({}), children: Vec::new(),
1985 action: None,
1986 visible: None,
1987 each: None,
1988 if_: None,
1989 },
1990 );
1991 elements.insert(
1992 "b".to_string(),
1993 Element {
1994 type_name: "Button".to_string(),
1995 props: serde_json::json!({}), children: Vec::new(),
1997 action: None,
1998 visible: None,
1999 each: None,
2000 if_: None,
2001 },
2002 );
2003 let spec = Spec {
2004 schema: crate::spec::SCHEMA_VERSION.to_string(),
2005 root: "a".to_string(),
2006 elements,
2007 title: None,
2008 layout: None,
2009 fill_viewport: false,
2010 data: Value::Null,
2011 design: None,
2012 };
2013 let errs = cat.validate(&spec).expect_err("should fail");
2014 let props_invalid_count = errs
2015 .iter()
2016 .filter(|e| matches!(e, CatalogError::PropsInvalid { .. }))
2017 .count();
2018 assert!(
2019 props_invalid_count >= 2,
2020 "expected at least 2 PropsInvalid errors; got {errs:?}"
2021 );
2022 }
2023
2024 #[test]
2030 fn build_discovers_plugins_and_rejects_invalid_schema() {
2031 use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
2032
2033 struct GoodPlugin;
2034 impl JsonUiPlugin for GoodPlugin {
2035 fn component_type(&self) -> &str {
2036 "GoodPlugin_117"
2037 }
2038 fn props_schema(&self) -> Value {
2039 serde_json::json!({ "type": "object" })
2040 }
2041 fn render(&self, _: &Value, _: &Value) -> String {
2042 String::new()
2043 }
2044 fn css_assets(&self) -> Vec<Asset> {
2045 vec![]
2046 }
2047 fn js_assets(&self) -> Vec<Asset> {
2048 vec![]
2049 }
2050 fn init_script(&self) -> Option<String> {
2051 None
2052 }
2053 }
2054
2055 register_plugin(GoodPlugin);
2056
2057 let cat = Catalog::build().expect("build succeeds with valid plugin only");
2059 assert!(
2060 cat.plugin_components.contains_key("GoodPlugin_117"),
2061 "plugin 'GoodPlugin_117' should have been discovered"
2062 );
2063 assert!(cat.plugin_components["GoodPlugin_117"].is_plugin);
2064
2065 struct BadPlugin;
2067 impl JsonUiPlugin for BadPlugin {
2068 fn component_type(&self) -> &str {
2069 "BadPlugin_117"
2070 }
2071 fn props_schema(&self) -> Value {
2072 serde_json::json!({ "type": 42 })
2075 }
2076 fn render(&self, _: &Value, _: &Value) -> String {
2077 String::new()
2078 }
2079 fn css_assets(&self) -> Vec<Asset> {
2080 vec![]
2081 }
2082 fn js_assets(&self) -> Vec<Asset> {
2083 vec![]
2084 }
2085 fn init_script(&self) -> Option<String> {
2086 None
2087 }
2088 }
2089
2090 register_plugin(BadPlugin);
2091 match Catalog::build() {
2092 Err(CatalogError::BuildFailed(msg)) => {
2093 assert!(
2094 msg.contains("BadPlugin_117"),
2095 "error should mention plugin name, got: {msg}"
2096 );
2097 }
2098 Err(other) => panic!("expected BuildFailed mentioning BadPlugin_117, got: {other:?}"),
2099 Ok(_) => panic!("expected build to fail due to invalid plugin schema"),
2100 }
2101 }
2102
2103 #[test]
2106 fn component_schema_returns_props_only() {
2107 let cat = Catalog::build_builtins_only().expect("build");
2111 let schema = cat
2112 .component_schema("Card")
2113 .expect("Card is a built-in component");
2114
2115 let obj = schema
2119 .as_object()
2120 .expect("Card props schema is a JSON object");
2121
2122 assert!(
2124 obj.contains_key("type") || obj.contains_key("oneOf") || obj.contains_key("anyOf"),
2125 "CardProps schema should be a structural object schema; got {obj:?}"
2126 );
2127
2128 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
2130 assert!(
2131 props.contains_key("title"),
2132 "CardProps schema.properties should include 'title'; got keys: {:?}",
2133 props.keys().collect::<Vec<_>>()
2134 );
2135 } else {
2136 panic!(
2137 "CardProps schema missing top-level 'properties' map — \
2138 sanitizer or Plan 02 may be wrong. Got: {}",
2139 serde_json::to_string_pretty(schema).unwrap_or_default()
2140 );
2141 }
2142
2143 let is_element_wrapper = obj
2146 .get("properties")
2147 .and_then(|v| v.as_object())
2148 .map(|p| p.contains_key("children") && p.contains_key("props"))
2149 .unwrap_or(false);
2150 assert!(
2151 !is_element_wrapper,
2152 "component_schema('Card') returned an Element wrapper; must be Props-only (CONTEXT D-19)"
2153 );
2154 }
2155
2156 #[test]
2157 fn component_schema_none_for_unknown() {
2158 let cat = Catalog::build_builtins_only().expect("build");
2159 assert!(
2160 cat.component_schema("NotARealComponent_117_05").is_none(),
2161 "unknown component must return None"
2162 );
2163 assert!(cat.component_schema("").is_none());
2165 }
2166
2167 #[test]
2168 fn component_schema_resolves_every_builtin() {
2169 let cat = Catalog::build_builtins_only().expect("build");
2173 for name in crate::render::BUILTIN_TYPES.iter() {
2174 assert!(
2175 cat.component_schema(name).is_some(),
2176 "built-in '{name}' has no per-component schema"
2177 );
2178 }
2179 }
2180
2181 #[test]
2182 fn components_sorted_yields_ascending_by_name() {
2183 let cat = Catalog::build_builtins_only().expect("build");
2184 let names: Vec<String> = cat
2185 .components_sorted()
2186 .map(|spec| spec.name.clone())
2187 .collect();
2188 assert_eq!(names.len(), crate::render::BUILTIN_TYPES.len());
2189 let mut sorted = names.clone();
2190 sorted.sort();
2191 assert_eq!(
2192 names, sorted,
2193 "components_sorted must yield ascending order"
2194 );
2195
2196 let plugin_names: Vec<String> = cat
2198 .plugin_components_sorted()
2199 .map(|spec| spec.name.clone())
2200 .collect();
2201 let mut plugin_sorted = plugin_names.clone();
2202 plugin_sorted.sort();
2203 assert_eq!(
2204 plugin_names, plugin_sorted,
2205 "plugin_components_sorted must yield ascending order"
2206 );
2207 }
2208
2209 #[test]
2212 fn prompt_under_size_budget() {
2213 let cat = Catalog::build_builtins_only().expect("build");
2214 let prompt = cat.prompt();
2215 let bytes = prompt.len();
2216 assert!(
2224 bytes <= 13 * 1024,
2225 "prompt() is {bytes} bytes, exceeds 13 KB budget (CONTEXT D-17)"
2226 );
2227 }
2228
2229 #[test]
2230 fn prompt_mentions_every_builtin() {
2231 let cat = Catalog::build_builtins_only().expect("build");
2232 let prompt = cat.prompt();
2233 for name in crate::render::BUILTIN_TYPES.iter() {
2234 let heading = format!("### {name}\n");
2235 assert!(
2236 prompt.contains(&heading),
2237 "prompt() missing section heading for '{name}'"
2238 );
2239 }
2240 }
2241
2242 #[test]
2243 fn prompt_inlines_canonical_enum_values() {
2244 let cat = Catalog::build_builtins_only().expect("build");
2248 let prompt = cat.prompt();
2249 for values in [
2250 CANONICAL_VARIANT.join("|"),
2251 CANONICAL_TONE.join("|"),
2252 CANONICAL_SIZE.join("|"),
2253 ] {
2254 assert!(
2255 prompt.contains(&values),
2256 "prompt() must inline the canonical enum values '{values}'"
2257 );
2258 }
2259 }
2260
2261 #[test]
2262 fn prompt_is_deterministic() {
2263 let cat1 = Catalog::build_builtins_only().expect("build 1");
2264 let cat2 = Catalog::build_builtins_only().expect("build 2");
2265 assert_eq!(
2266 cat1.prompt(),
2267 cat2.prompt(),
2268 "prompt() must be deterministic"
2269 );
2270 }
2271
2272 #[test]
2273 fn prompt_documents_slot_fields() {
2274 let cat = Catalog::build_builtins_only().expect("build");
2277 let prompt = cat.prompt();
2278 let card_start = prompt.find("### Card\n").expect("Card section present");
2279 let card_slice = &prompt[card_start..];
2280 let end = card_slice[3..]
2282 .find("### ")
2283 .map(|i| i + 3)
2284 .unwrap_or(card_slice.len());
2285 let card_section = &card_slice[..end];
2286 assert!(
2287 card_section.contains("Slots: footer"),
2288 "Card section missing 'Slots: footer' line:\n{card_section}"
2289 );
2290 }
2291
2292 #[test]
2293 fn prompt_is_not_raw_json_schema() {
2294 let cat = Catalog::build_builtins_only().expect("build");
2295 let prompt = cat.prompt();
2296 assert!(
2297 prompt.starts_with("## Component Catalog"),
2298 "prompt() should start with Markdown header, not JSON"
2299 );
2300 assert!(
2301 !prompt.contains("\"$schema\""),
2302 "prompt() must not embed raw JSON Schema (ROADMAP caveat)"
2303 );
2304 }
2305
2306 #[test]
2307 fn catalog_contains_checkbox_group() {
2308 let cat = Catalog::build_builtins_only().expect("build");
2309 assert!(
2310 cat.component_schema("CheckboxGroup").is_some(),
2311 "CheckboxGroup must be registered in BUILTIN_SPECS as an alias for CheckboxList"
2312 );
2313 }
2314
2315 fn spec_from_json_string(json: &str) -> crate::spec::Spec {
2320 crate::spec::Spec::from_json(json).expect("test spec must parse")
2321 }
2322
2323 #[test]
2324 fn validate_rejects_retired_el_action_confirm_variant() {
2325 let cat = Catalog::build_builtins_only().expect("build");
2329 let spec = spec_from_json_string(
2330 r#"{
2331 "$schema": "ferro-json-ui/v2",
2332 "root": "btn",
2333 "elements": {
2334 "btn": {
2335 "type": "Button",
2336 "props": { "label": "Delete" },
2337 "action": {
2338 "handler": "orders.destroy",
2339 "method": "POST",
2340 "confirm": {
2341 "title": "Delete?",
2342 "message": "x",
2343 "variant": "danger"
2344 }
2345 }
2346 }
2347 }
2348 }"#,
2349 );
2350 let errs = cat
2351 .validate(&spec)
2352 .expect_err("should fail: confirm.variant is a retired prop");
2353 assert!(
2354 errs.iter().any(|e| matches!(
2355 e,
2356 CatalogError::PropsInvalid { errors, .. }
2357 if errors.iter().any(|m| m.contains("/action") && m.contains("variant"))
2358 )),
2359 "expected PropsInvalid mentioning /action and variant; got {errs:?}"
2360 );
2361 }
2362
2363 #[test]
2364 fn validate_accepts_canonical_el_action_confirm_tone() {
2365 let cat = Catalog::build_builtins_only().expect("build");
2367 let spec = spec_from_json_string(
2368 r#"{
2369 "$schema": "ferro-json-ui/v2",
2370 "root": "btn",
2371 "elements": {
2372 "btn": {
2373 "type": "Button",
2374 "props": { "label": "Delete" },
2375 "action": {
2376 "handler": "orders.destroy",
2377 "method": "POST",
2378 "confirm": {
2379 "title": "Delete?",
2380 "message": "x",
2381 "tone": "destructive"
2382 }
2383 }
2384 }
2385 }
2386 }"#,
2387 );
2388 if let Err(errs) = cat.validate(&spec) {
2389 panic!("validate with canonical confirm.tone failed: {errs:?}");
2390 }
2391 }
2392
2393 #[test]
2394 fn global_catalog_includes_stream_text() {
2395 let cat = Catalog::build_builtins_only().expect("build");
2396 assert!(
2397 cat.components.contains_key("StreamText"),
2398 "catalog must include StreamText"
2399 );
2400 let spec = &cat.components["StreamText"];
2401 assert_eq!(spec.name, "StreamText");
2402 assert!(
2403 spec.description.contains("event: done"),
2404 "StreamText description must mention 'event: done'; got: {}",
2405 spec.description
2406 );
2407 assert!(
2408 spec.props_schema.is_object(),
2409 "StreamText props_schema must be a JSON object"
2410 );
2411 assert!(!spec.is_plugin);
2412 }
2413
2414 #[test]
2419 fn catalog_each_template_null_data() {
2420 use crate::spec::{Element, Spec};
2421 use serde_json::json;
2422 let cat = Catalog::build_builtins_only().expect("build");
2423 let spec = Spec::builder()
2424 .element(
2425 "grid",
2426 Element::new("TileGrid")
2427 .prop("data_path", "/data/items")
2428 .prop("form_id", "f")
2429 .child("tile_tmpl"),
2430 )
2431 .element(
2432 "tile_tmpl",
2433 Element::new("Tile")
2434 .each("/data/items", "p")
2435 .prop("item_id", json!({"$data": "/p/id"}))
2436 .prop("name", json!({"$data": "/p/name"}))
2437 .prop("price", json!({"$data": "/p/price"}))
2438 .prop("field", json!({"$data": "/p/field"}))
2439 .prop("price_cents", json!({"$data": "/p/price_cents"})),
2440 )
2441 .build()
2442 .expect("spec builds");
2443 if let Err(errs) = cat.validate(&spec) {
2444 panic!("catalog_each_template_null_data failed: {errs:?}");
2445 }
2446 }
2447
2448 #[test]
2454 fn catalog_each_template_populated_data() {
2455 use crate::spec::{Element, Spec};
2456 use serde_json::json;
2457 let cat = Catalog::build_builtins_only().expect("build");
2458 let spec = Spec::builder()
2459 .data(json!({
2460 "data": {
2461 "items": [
2462 {"id": "1", "name": "A", "price": "1,00", "price_cents": 100, "field": "qty_1"}
2463 ]
2464 }
2465 }))
2466 .element(
2467 "grid",
2468 Element::new("TileGrid")
2469 .prop("data_path", "/data/items")
2470 .prop("form_id", "f")
2471 .child("tile_tmpl"),
2472 )
2473 .element(
2474 "tile_tmpl",
2475 Element::new("Tile")
2476 .each("/data/items", "p")
2477 .prop("item_id", json!({"$data": "/p/id"}))
2478 .prop("name", json!({"$data": "/p/name"}))
2479 .prop("price", json!({"$data": "/p/price"}))
2480 .prop("field", json!({"$data": "/p/field"}))
2481 .prop("price_cents", json!({"$data": "/p/price_cents"})),
2482 )
2483 .build()
2484 .expect("spec builds");
2485 if let Err(errs) = cat.validate(&spec) {
2486 panic!("catalog_each_template_populated_data failed: {errs:?}");
2487 }
2488 }
2489
2490 #[test]
2494 fn catalog_each_template_path_not_array_rejected_at_build() {
2495 use crate::spec::{Element, Spec, SpecError};
2496 use serde_json::json;
2497 let err = Spec::builder()
2498 .data(json!({"data": {"items": {"not": "an array"}}}))
2499 .element("tile_tmpl", Element::new("Tile").each("/data/items", "p"))
2500 .build()
2501 .expect_err("non-array $each path must fail spec build");
2502 assert!(
2503 matches!(err, SpecError::EachPathNotArray { .. }),
2504 "expected EachPathNotArray, got: {err:?}"
2505 );
2506 }
2507
2508 #[test]
2513 fn full_schema_root_exposes_all_spec_fields() {
2514 let cat = Catalog::build_builtins_only().expect("build");
2515 let schema = cat.json_schema();
2516 let props = schema
2517 .get("properties")
2518 .and_then(|v| v.as_object())
2519 .expect("root properties object");
2520 for key in [
2521 "$schema",
2522 "root",
2523 "elements",
2524 "title",
2525 "layout",
2526 "fill_viewport",
2527 "data",
2528 "design",
2529 ] {
2530 assert!(props.contains_key(key), "root schema must expose '{key}'");
2531 }
2532 assert!(
2533 schema.pointer("/$defs/DesignMeta").is_some(),
2534 "DesignMeta def must be hoisted into $defs"
2535 );
2536 }
2537}