1use std::collections::HashMap;
24use std::sync::OnceLock;
25
26use schemars::schema_for;
27use serde_json::{to_value, Value};
28
29use crate::component::{
30 ActionCardProps, AlertProps, AvatarProps, BadgeProps, BreadcrumbProps, ButtonGroupProps,
31 ButtonProps, CalendarCellProps, CardProps, CheckboxListProps, CheckboxProps, ChecklistProps,
32 CollapsibleProps, DataTableProps, DescriptionListProps, DetailPageProps, DropdownMenuProps,
33 EmptyStateProps, FormProps, FormSectionProps, GridProps, HeaderProps, ImageProps, InputProps,
34 KanbanBoardProps, ModalProps, NotificationDropdownProps, PageHeaderProps, PaginationProps,
35 ProductTileProps, ProgressProps, RawHtmlProps, SelectProps, SeparatorProps, SidebarProps,
36 SkeletonProps, StatCardProps, SwitchProps, TableProps, TabsProps, TextProps, ToastProps,
37};
38
39pub struct ComponentSpec {
46 pub name: String,
48 pub description: String,
50 pub props_schema: Value,
52 pub is_plugin: bool,
54 pub slot_fields: Vec<String>,
59}
60
61pub struct Catalog {
67 pub(crate) components: HashMap<String, ComponentSpec>,
69 pub(crate) plugin_components: HashMap<String, ComponentSpec>,
71 pub(crate) full_schema: Value,
73 pub(crate) per_component_schemas: HashMap<String, Value>,
75 pub(crate) validator: jsonschema::Validator,
77}
78
79#[derive(Debug, thiserror::Error)]
81pub enum CatalogError {
82 #[error("unknown component type '{type_name}' at element '{element_id}'")]
84 UnknownType {
85 element_id: String,
87 type_name: String,
89 },
90 #[error("props invalid for '{type_name}' at element '{element_id}': {errors:?}")]
92 PropsInvalid {
93 element_id: String,
95 type_name: String,
97 errors: Vec<String>,
99 },
100 #[error("spec invalid: {errors:?}")]
102 SpecInvalid {
103 errors: Vec<String>,
105 },
106 #[error("catalog build failed: {0}")]
108 BuildFailed(String),
109 #[error("schema serialization error: {0}")]
111 SchemaSerialization(#[from] serde_json::Error),
112}
113
114type SchemaFn = fn() -> Value;
117
118static BUILTIN_SPECS: &[(&str, &str, SchemaFn, &[&str])] = &[
124 (
126 "Text",
127 "Semantic text element (p / h1 / h2 / h3 / span / div / section).",
128 || to_value(schema_for!(TextProps)).unwrap(),
129 &[],
130 ),
131 (
132 "Button",
133 "Interactive button with variant, size, optional icon, and disabled state.",
134 || to_value(schema_for!(ButtonProps)).unwrap(),
135 &[],
136 ),
137 (
138 "Badge",
139 "Small variant-styled label.",
140 || to_value(schema_for!(BadgeProps)).unwrap(),
141 &[],
142 ),
143 (
144 "Alert",
145 "Inline notice with info / success / warning / error variants.",
146 || to_value(schema_for!(AlertProps)).unwrap(),
147 &[],
148 ),
149 (
150 "Separator",
151 "Horizontal or vertical divider between content sections.",
152 || to_value(schema_for!(SeparatorProps)).unwrap(),
153 &[],
154 ),
155 (
156 "Progress",
157 "Progress bar with 0–100 percentage value and optional label.",
158 || to_value(schema_for!(ProgressProps)).unwrap(),
159 &[],
160 ),
161 (
162 "Avatar",
163 "Circular user image with fallback initials and size variants.",
164 || to_value(schema_for!(AvatarProps)).unwrap(),
165 &[],
166 ),
167 (
168 "Image",
169 "Image with optional aspect ratio and skeleton fallback on load error.",
170 || to_value(schema_for!(ImageProps)).unwrap(),
171 &[],
172 ),
173 (
174 "Skeleton",
175 "Loading placeholder with configurable width / height / rounding.",
176 || to_value(schema_for!(SkeletonProps)).unwrap(),
177 &[],
178 ),
179 (
180 "Breadcrumb",
181 "Navigation trail of label + optional URL items.",
182 || to_value(schema_for!(BreadcrumbProps)).unwrap(),
183 &[],
184 ),
185 (
186 "Pagination",
187 "Page navigation for paginated data (current / per_page / total).",
188 || to_value(schema_for!(PaginationProps)).unwrap(),
189 &[],
190 ),
191 (
192 "DescriptionList",
193 "Key-value pairs displayed as a description list with optional format.",
194 || to_value(schema_for!(DescriptionListProps)).unwrap(),
195 &[],
196 ),
197 (
198 "EmptyState",
199 "Standardized empty view with title, description, and optional CTA.",
200 || to_value(schema_for!(EmptyStateProps)).unwrap(),
201 &[],
202 ),
203 (
204 "StatCard",
205 "Live-updatable metric card with label, value, icon, SSE target.",
206 || to_value(schema_for!(StatCardProps)).unwrap(),
207 &[],
208 ),
209 (
210 "Checklist",
211 "Onboarding-style checklist with dismissal and server-side state.",
212 || to_value(schema_for!(ChecklistProps)).unwrap(),
213 &[],
214 ),
215 (
216 "Toast",
217 "Declarative notification intent consumed by the runtime JS via data attributes.",
218 || to_value(schema_for!(ToastProps)).unwrap(),
219 &[],
220 ),
221 (
222 "NotificationDropdown",
223 "Dropdown listing notification items with icons, timestamps, read state.",
224 || to_value(schema_for!(NotificationDropdownProps)).unwrap(),
225 &[],
226 ),
227 (
228 "Sidebar",
229 "Dashboard sidebar with fixed top / bottom items and collapsible nav groups.",
230 || to_value(schema_for!(SidebarProps)).unwrap(),
231 &[],
232 ),
233 (
234 "Header",
235 "Dashboard top bar with business name, notification badge, user menu.",
236 || to_value(schema_for!(HeaderProps)).unwrap(),
237 &[],
238 ),
239 (
240 "DropdownMenu",
241 "Trigger button with an absolutely-positioned kebab-style action panel.",
242 || to_value(schema_for!(DropdownMenuProps)).unwrap(),
243 &[],
244 ),
245 (
246 "CalendarCell",
247 "Single day in a month grid with today highlight, out-of-month muting, event dots.",
248 || to_value(schema_for!(CalendarCellProps)).unwrap(),
249 &[],
250 ),
251 (
252 "ActionCard",
253 "Clickable row with icon, title, description, chevron, and variant-colored border.",
254 || to_value(schema_for!(ActionCardProps)).unwrap(),
255 &[],
256 ),
257 (
258 "ProductTile",
259 "Touch-friendly POS tile with name, price, and +/- quantity controls.",
260 || to_value(schema_for!(ProductTileProps)).unwrap(),
261 &[],
262 ),
263 (
264 "RawHtml",
265 "Server-injected HTML island. CONSUMER is responsible for sanitization — see docs/src/json-ui/plugins.md.",
266 || to_value(schema_for!(RawHtmlProps)).unwrap(),
267 &[],
268 ),
269 (
271 "Card",
272 "Content container with title, description, optional badge and subtitle, body children, and optional footer slot.",
273 || to_value(schema_for!(CardProps)).unwrap(),
274 &["footer"],
275 ),
276 (
277 "Modal",
278 "Dialog overlay with title, description, body children, and optional footer slot.",
279 || to_value(schema_for!(ModalProps)).unwrap(),
280 &["footer"],
281 ),
282 (
283 "Tabs",
284 "Tabbed content; per-tab children live in TabsProps.tabs[i].children.",
285 || to_value(schema_for!(TabsProps)).unwrap(),
286 &[],
287 ),
288 (
289 "KanbanBoard",
290 "Horizontally scrollable kanban columns on desktop, tab-switched on mobile.",
291 || to_value(schema_for!(KanbanBoardProps)).unwrap(),
292 &[],
293 ),
294 (
295 "PageHeader",
296 "Page title with optional breadcrumb and action button slot.",
297 || to_value(schema_for!(PageHeaderProps)).unwrap(),
298 &["actions"],
299 ),
300 (
301 "DetailPage",
302 "Canonical resource-detail skeleton: PageHeader chrome, optional info Card slot, and stacked body sections from Element.children.",
303 || to_value(schema_for!(DetailPageProps)).unwrap(),
304 &["actions", "info"],
305 ),
306 (
307 "Grid",
308 "Responsive multi-column grid with configurable breakpoint columns, gap, scroll.",
309 || to_value(schema_for!(GridProps)).unwrap(),
310 &[],
311 ),
312 (
313 "Collapsible",
314 "Expandable <details> / <summary> section.",
315 || to_value(schema_for!(CollapsibleProps)).unwrap(),
316 &[],
317 ),
318 (
319 "FormSection",
320 "Visual grouping within a form with title, description, and layout variant.",
321 || to_value(schema_for!(FormSectionProps)).unwrap(),
322 &[],
323 ),
324 (
325 "ButtonGroup",
326 "Horizontal button row with configurable gap.",
327 || to_value(schema_for!(ButtonGroupProps)).unwrap(),
328 &[],
329 ),
330 (
332 "Form",
333 "Form container with action binding and field components.",
334 || to_value(schema_for!(FormProps)).unwrap(),
335 &[],
336 ),
337 (
338 "Input",
339 "Text input with type variants, validation error, data_path pre-fill.",
340 || to_value(schema_for!(InputProps)).unwrap(),
341 &[],
342 ),
343 (
344 "Select",
345 "Dropdown select with options, error, data_path pre-fill.",
346 || to_value(schema_for!(SelectProps)).unwrap(),
347 &[],
348 ),
349 (
350 "Checkbox",
351 "Boolean checkbox with label, description, data binding.",
352 || to_value(schema_for!(CheckboxProps)).unwrap(),
353 &[],
354 ),
355 (
356 "Switch",
357 "Toggle switch (visual alternative to Checkbox); auto-submit when `action` set.",
358 || to_value(schema_for!(SwitchProps)).unwrap(),
359 &[],
360 ),
361 (
362 "CheckboxList",
363 "Multi-select checkbox group from static options or data-driven array. \
364 Each checked option submits as field=value.",
365 || to_value(schema_for!(CheckboxListProps)).unwrap(),
366 &[],
367 ),
368 (
369 "CheckboxGroup",
370 "Multi-select checkbox group (alias for CheckboxList). Each checked option \
371 submits as field=value with array-submit semantics. Identical props to \
372 CheckboxList; see that entry for full schema.",
373 || to_value(schema_for!(CheckboxListProps)).unwrap(),
374 &[],
375 ),
376 (
378 "Table",
379 "Data table with columns, row_actions, sorting, empty_message.",
380 || to_value(schema_for!(TableProps)).unwrap(),
381 &[],
382 ),
383 (
384 "DataTable",
385 "Stripe-style alternating-row table with per-row DropdownMenu and mobile card fallback.",
386 || to_value(schema_for!(DataTableProps)).unwrap(),
387 &[],
388 ),
389];
390
391fn sanitize_schema(mut schema: Value) -> Value {
399 fn walk(v: &mut Value) {
400 if let Some(obj) = v.as_object_mut() {
401 if let Some(defs) = obj.remove("definitions") {
402 obj.entry("$defs".to_string()).or_insert(defs);
403 }
404 if let Some(Value::String(ref_str)) = obj.get_mut("$ref") {
405 if let Some(suffix) = ref_str.strip_prefix("#/definitions/") {
406 *ref_str = format!("#/$defs/{suffix}");
407 }
408 }
409 let keys: Vec<String> = obj.keys().cloned().collect();
411 for k in keys {
412 if let Some(child) = obj.get_mut(&k) {
413 walk(child);
414 }
415 }
416 } else if let Some(arr) = v.as_array_mut() {
417 for item in arr.iter_mut() {
418 walk(item);
419 }
420 }
421 }
422 walk(&mut schema);
423 schema
424}
425
426fn hoist_defs(schema: &mut Value, shared_defs: &mut serde_json::Map<String, Value>) {
435 if let Some(obj) = schema.as_object_mut() {
436 if let Some(Value::Object(defs)) = obj.remove("$defs") {
437 for (k, v) in defs {
438 shared_defs.entry(k).or_insert(v);
439 }
440 }
441 }
442}
443
444fn assemble_full_schema(per_component: &HashMap<String, Value>) -> Result<Value, CatalogError> {
456 let mut action_schema = sanitize_schema(to_value(schema_for!(crate::action::Action))?);
458 let mut visibility_schema =
459 sanitize_schema(to_value(schema_for!(crate::visibility::Visibility))?);
460
461 let mut shared_defs: serde_json::Map<String, Value> = serde_json::Map::new();
463 hoist_defs(&mut action_schema, &mut shared_defs);
464 hoist_defs(&mut visibility_schema, &mut shared_defs);
465
466 let mut names: Vec<&String> = per_component.keys().collect();
470 names.sort();
471 let one_of: Vec<Value> = names
472 .into_iter()
473 .map(|name| {
474 let mut props_schema = per_component[name].clone();
475 hoist_defs(&mut props_schema, &mut shared_defs);
477 serde_json::json!({
478 "allOf": [
479 {
480 "type": "object",
481 "required": ["type"],
482 "properties": {
483 "type": { "const": name }
484 }
485 },
486 {
487 "type": "object",
488 "properties": {
489 "props": props_schema,
490 "children": { "type": "array", "items": { "type": "string" } },
491 "action": { "$ref": "#/$defs/Action" },
492 "visible": { "$ref": "#/$defs/Visibility" }
493 }
494 }
495 ]
496 })
497 })
498 .collect();
499
500 shared_defs
503 .entry("Action".to_string())
504 .or_insert(action_schema);
505 shared_defs
506 .entry("Visibility".to_string())
507 .or_insert(visibility_schema);
508 shared_defs.insert(
510 "Element".to_string(),
511 serde_json::json!({ "oneOf": one_of }),
512 );
513
514 Ok(serde_json::json!({
515 "$schema": "https://json-schema.org/draft/2020-12/schema",
516 "$id": "ferro-json-ui/v2",
517 "type": "object",
518 "required": ["$schema", "root", "elements"],
519 "properties": {
520 "$schema": { "const": "ferro-json-ui/v2" },
521 "root": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,127}$" },
522 "elements": {
523 "type": "object",
524 "additionalProperties": { "$ref": "#/$defs/Element" }
525 },
526 "title": { "type": ["string", "null"] },
527 "layout": { "type": ["string", "null"] },
528 "data": true
529 },
530 "$defs": shared_defs
531 }))
532}
533
534impl Catalog {
537 pub fn build() -> Result<Self, CatalogError> {
548 if BUILTIN_SPECS.len() != crate::render::BUILTIN_TYPES.len() {
552 return Err(CatalogError::BuildFailed(format!(
553 "BUILTIN_SPECS has {} entries but BUILTIN_TYPES has {} — \
554 add an entry to BUILTIN_SPECS or remove from BUILTIN_TYPES",
555 BUILTIN_SPECS.len(),
556 crate::render::BUILTIN_TYPES.len(),
557 )));
558 }
559
560 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
562 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len() * 2);
563 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
564 let raw = schema_fn();
565 let schema = sanitize_schema(raw);
566 per_component_schemas.insert((*name).to_string(), schema.clone());
567 components.insert(
568 (*name).to_string(),
569 ComponentSpec {
570 name: (*name).to_string(),
571 description: (*desc).to_string(),
572 props_schema: schema,
573 is_plugin: false,
574 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
575 },
576 );
577 }
578
579 let mut plugin_components = HashMap::new();
585 for plugin_type in crate::plugin::registered_plugin_types() {
586 if components.contains_key(&plugin_type) {
588 continue;
589 }
590 let raw = crate::plugin::with_plugin(&plugin_type, |p| p.props_schema())
591 .unwrap_or(Value::Null);
592 let schema = sanitize_schema(raw);
593 if jsonschema::validator_for(&schema).is_err() {
595 return Err(CatalogError::BuildFailed(format!(
596 "plugin '{plugin_type}' returned an invalid JSON Schema"
597 )));
598 }
599 per_component_schemas.insert(plugin_type.clone(), schema.clone());
600 plugin_components.insert(
601 plugin_type.clone(),
602 ComponentSpec {
603 name: plugin_type,
604 description: String::from("Plugin component."),
605 props_schema: schema,
606 is_plugin: true,
607 slot_fields: Vec::new(),
608 },
609 );
610 }
611
612 let full_schema = assemble_full_schema(&per_component_schemas)?;
614
615 let validator = jsonschema::validator_for(&full_schema)
617 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
618
619 Ok(Catalog {
620 components,
621 plugin_components,
622 full_schema,
623 per_component_schemas,
624 validator,
625 })
626 }
627
628 pub fn json_schema(&self) -> &Value {
635 &self.full_schema
636 }
637
638 pub fn validate(&self, spec: &crate::spec::Spec) -> Result<(), Vec<CatalogError>> {
658 let mut errors: Vec<CatalogError> = Vec::new();
659
660 for (id, el) in &spec.elements {
662 let known = self.components.contains_key(&el.type_name)
663 || self.plugin_components.contains_key(&el.type_name);
664 if !known {
665 errors.push(CatalogError::UnknownType {
666 element_id: id.clone(),
667 type_name: el.type_name.clone(),
668 });
669 }
670 }
671 if !errors.is_empty() {
676 return Err(errors);
677 }
678
679 for (id, el) in &spec.elements {
681 if let Some(schema) = self.per_component_schemas.get(&el.type_name) {
682 if el.props.is_null() {
687 continue;
688 }
689 let v = match jsonschema::validator_for(schema) {
693 Ok(v) => v,
694 Err(e) => {
695 errors.push(CatalogError::BuildFailed(format!(
696 "compiling per-component schema for '{}': {e}",
697 el.type_name
698 )));
699 continue;
700 }
701 };
702 let validation_props = strip_expr_objects(&el.props);
708 let mut per_elem_errs: Vec<String> = Vec::new();
709 for err in v.iter_errors(&validation_props) {
710 per_elem_errs.push(format!("{}: {}", err.instance_path(), err));
711 }
712 if !per_elem_errs.is_empty() {
713 errors.push(CatalogError::PropsInvalid {
714 element_id: id.clone(),
715 type_name: el.type_name.clone(),
716 errors: per_elem_errs,
717 });
718 }
719 }
720 }
721
722 let spec_value = match serde_json::to_value(spec) {
724 Ok(v) => v,
725 Err(e) => {
726 errors.push(CatalogError::SchemaSerialization(e));
727 return Err(errors);
728 }
729 };
730 let stripped_spec_value = strip_expr_objects(&spec_value);
732 let mut envelope_errs: Vec<String> = Vec::new();
733 for err in self.validator.iter_errors(&stripped_spec_value) {
734 envelope_errs.push(format!("{}: {}", err.instance_path(), err));
735 }
736 if !envelope_errs.is_empty() {
737 errors.push(CatalogError::SpecInvalid {
738 errors: envelope_errs,
739 });
740 }
741
742 if errors.is_empty() {
743 Ok(())
744 } else {
745 Err(errors)
746 }
747 }
748
749 pub fn component_schema(&self, type_name: &str) -> Option<&Value> {
763 self.per_component_schemas.get(type_name)
764 }
765
766 pub fn components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
773 let mut entries: Vec<&ComponentSpec> = self.components.values().collect();
774 entries.sort_by(|a, b| a.name.cmp(&b.name));
775 entries.into_iter()
776 }
777
778 pub fn plugin_components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
784 let mut entries: Vec<&ComponentSpec> = self.plugin_components.values().collect();
785 entries.sort_by(|a, b| a.name.cmp(&b.name));
786 entries.into_iter()
787 }
788
789 pub fn prompt(&self) -> String {
804 let mut out = String::with_capacity(8 * 1024);
805 out.push_str("## Component Catalog\n\n");
806 for spec in self.components_sorted() {
807 render_component_section(&mut out, spec);
808 }
809 if self.plugin_components.is_empty() {
810 return out;
811 }
812 out.push_str("## Plugin Components\n\n");
813 for spec in self.plugin_components_sorted() {
814 render_component_section(&mut out, spec);
815 }
816 out
817 }
818}
819
820fn render_component_section(out: &mut String, spec: &ComponentSpec) {
833 out.push_str("### ");
834 out.push_str(&spec.name);
835 out.push('\n');
836 out.push_str(&spec.description);
837 out.push('\n');
838
839 let props_line = render_props_line(&spec.props_schema);
840 if !props_line.is_empty() {
841 out.push_str("Props: ");
842 out.push_str(&props_line);
843 out.push('\n');
844 }
845 if !spec.slot_fields.is_empty() {
846 out.push_str("Slots: ");
847 out.push_str(&spec.slot_fields.join(", "));
848 out.push_str(" (Vec<String> of element IDs) — body children come from Element.children.\n");
849 }
850 out.push('\n');
851}
852
853fn render_props_line(schema: &Value) -> String {
864 let Some(obj) = schema.as_object() else {
865 return String::new();
866 };
867 let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
868 return String::new();
869 };
870 let required: std::collections::HashSet<&str> = obj
871 .get("required")
872 .and_then(|v| v.as_array())
873 .map(|arr| {
874 arr.iter()
875 .filter_map(|v| v.as_str())
876 .collect::<std::collections::HashSet<_>>()
877 })
878 .unwrap_or_default();
879
880 let parts: Vec<String> = props
881 .iter()
882 .map(|(name, field_schema)| {
883 let ty = render_field_type(field_schema, required.contains(name.as_str()));
884 format!("{name} ({ty})")
885 })
886 .collect();
887 parts.join(", ")
888}
889
890fn render_field_type(schema: &Value, is_required: bool) -> String {
892 if let Some(variants) = schema.get("enum").and_then(|v| v.as_array()) {
894 let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
895 let inner = render_enum_inline(&names);
896 return wrap_optional(inner, is_required);
897 }
898 for key in ["anyOf", "oneOf"] {
900 if let Some(arr) = schema.get(key).and_then(|v| v.as_array()) {
901 let has_null = arr
902 .iter()
903 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
904 let non_null: Vec<&Value> = arr
905 .iter()
906 .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
907 .collect();
908 if has_null && non_null.len() == 1 {
909 let inner = render_field_type(non_null[0], true);
910 return format!("Option<{inner}>");
911 }
912 }
913 }
914 if let Some(types) = schema.get("type").and_then(|v| v.as_array()) {
916 let non_null: Vec<&str> = types
917 .iter()
918 .filter_map(|v| v.as_str())
919 .filter(|s| *s != "null")
920 .collect();
921 let has_null = types.iter().any(|v| v.as_str() == Some("null"));
922 if has_null && non_null.len() == 1 {
923 return format!("Option<{}>", rust_for_json_type(non_null[0], schema));
924 }
925 }
926 if let Some(t) = schema.get("type").and_then(|v| v.as_str()) {
928 let inner = rust_for_json_type(t, schema);
929 return wrap_optional(inner, is_required);
930 }
931 wrap_optional("<see schema>".to_string(), is_required)
933}
934
935fn rust_for_json_type(t: &str, schema: &Value) -> String {
937 match t {
938 "string" => "String".to_string(),
939 "integer" => "i64".to_string(),
940 "number" => "f64".to_string(),
941 "boolean" => "bool".to_string(),
942 "array" => {
943 if let Some(items) = schema.get("items") {
944 let inner = render_field_type(items, true);
945 format!("Vec<{inner}>")
946 } else {
947 "Vec<Value>".to_string()
948 }
949 }
950 "object" => "Object".to_string(),
951 other => other.to_string(),
952 }
953}
954
955fn render_enum_inline(variants: &[&str]) -> String {
957 if variants.len() <= 8 {
958 variants.join("|")
959 } else {
960 format!("one of {} — see schema", variants.len())
961 }
962}
963
964fn wrap_optional(inner: String, is_required: bool) -> String {
966 if is_required {
967 inner
968 } else {
969 format!("Option<{inner}>")
970 }
971}
972
973fn strip_expr_objects(val: &Value) -> Value {
982 match val {
983 Value::Object(map) => {
984 if map.len() == 1 && (map.contains_key("$data") || map.contains_key("$template")) {
985 Value::String(String::new())
986 } else {
987 Value::Object(
988 map.iter()
989 .map(|(k, v)| (k.clone(), strip_expr_objects(v)))
990 .collect(),
991 )
992 }
993 }
994 Value::Array(arr) => Value::Array(arr.iter().map(strip_expr_objects).collect()),
995 other => other.clone(),
996 }
997}
998
999pub fn global_catalog() -> &'static Catalog {
1012 static GLOBAL_CATALOG: OnceLock<Catalog> = OnceLock::new();
1013 GLOBAL_CATALOG.get_or_init(|| {
1014 Catalog::build().expect("catalog build failed — see CatalogError for details")
1015 })
1016}
1017
1018#[cfg(test)]
1021impl Catalog {
1022 pub(crate) fn build_builtins_only() -> Result<Self, CatalogError> {
1027 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
1028 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len());
1029 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
1030 let raw = schema_fn();
1031 let schema = sanitize_schema(raw);
1032 per_component_schemas.insert((*name).to_string(), schema.clone());
1033 components.insert(
1034 (*name).to_string(),
1035 ComponentSpec {
1036 name: (*name).to_string(),
1037 description: (*desc).to_string(),
1038 props_schema: schema,
1039 is_plugin: false,
1040 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
1041 },
1042 );
1043 }
1044 let full_schema = assemble_full_schema(&per_component_schemas)?;
1045 let validator = jsonschema::validator_for(&full_schema)
1046 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
1047 Ok(Catalog {
1048 components,
1049 plugin_components: HashMap::new(),
1050 full_schema,
1051 per_component_schemas,
1052 validator,
1053 })
1054 }
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::*;
1060
1061 #[test]
1062 fn builtin_types_count_is_39() {
1063 assert_eq!(crate::render::BUILTIN_TYPES.len(), 43);
1069 }
1070
1071 #[test]
1072 fn builtin_specs_len_matches_dispatch() {
1073 assert_eq!(BUILTIN_SPECS.len(), crate::render::BUILTIN_TYPES.len());
1074 assert_eq!(BUILTIN_SPECS.len(), 43);
1075 }
1076
1077 #[test]
1078 fn builtin_specs_names_match_dispatch() {
1079 use std::collections::HashSet;
1080 let specs: HashSet<&str> = BUILTIN_SPECS.iter().map(|(n, ..)| *n).collect();
1081 let types: HashSet<&str> = crate::render::BUILTIN_TYPES.iter().copied().collect();
1082 assert_eq!(specs, types, "BUILTIN_SPECS names must match BUILTIN_TYPES");
1083 }
1084
1085 #[test]
1086 fn build_populates_all_builtins() {
1087 let cat = Catalog::build_builtins_only().expect("build succeeds");
1089 for name in crate::render::BUILTIN_TYPES.iter() {
1090 assert!(
1091 cat.components.contains_key(*name),
1092 "built-in '{name}' missing from catalog.components"
1093 );
1094 let spec = &cat.components[*name];
1095 assert_eq!(spec.name, *name);
1096 assert!(
1097 !spec.description.is_empty(),
1098 "'{name}' has empty description"
1099 );
1100 assert!(
1101 spec.props_schema.is_object(),
1102 "'{name}' props_schema is not a JSON object"
1103 );
1104 assert!(!spec.is_plugin);
1105 }
1106 }
1107
1108 #[test]
1109 fn build_card_has_footer_slot() {
1110 let cat = Catalog::build_builtins_only().expect("build succeeds");
1112 let card = &cat.components["Card"];
1113 assert_eq!(card.slot_fields, vec!["footer"]);
1114 }
1115
1116 #[test]
1117 fn build_modal_has_footer_slot() {
1118 let cat = Catalog::build_builtins_only().expect("build succeeds");
1120 let modal = &cat.components["Modal"];
1121 assert_eq!(modal.slot_fields, vec!["footer"]);
1122 }
1123
1124 #[test]
1125 fn build_pageheader_has_actions_slot() {
1126 let cat = Catalog::build_builtins_only().expect("build succeeds");
1128 let ph = &cat.components["PageHeader"];
1129 assert_eq!(ph.slot_fields, vec!["actions"]);
1130 }
1131
1132 #[test]
1133 fn build_text_has_no_slots() {
1134 let cat = Catalog::build_builtins_only().expect("build succeeds");
1136 assert!(cat.components["Text"].slot_fields.is_empty());
1137 }
1138
1139 #[test]
1140 fn build_populates_per_component_schemas() {
1141 let cat = Catalog::build_builtins_only().expect("build succeeds");
1143 assert_eq!(
1144 cat.per_component_schemas.len(),
1145 BUILTIN_SPECS.len() + cat.plugin_components.len()
1146 );
1147 }
1148
1149 #[test]
1150 fn sanitize_schema_rewrites_definitions_to_dollar_defs() {
1151 let raw = serde_json::json!({
1152 "type": "object",
1153 "definitions": { "Foo": { "type": "string" } },
1154 "properties": {
1155 "x": { "$ref": "#/definitions/Foo" }
1156 }
1157 });
1158 let out = sanitize_schema(raw);
1159 assert!(out.get("definitions").is_none());
1160 assert!(out.get("$defs").is_some());
1161 assert_eq!(
1162 out["properties"]["x"]["$ref"].as_str().unwrap(),
1163 "#/$defs/Foo"
1164 );
1165 }
1166
1167 #[test]
1168 fn sanitize_schema_is_idempotent() {
1169 let raw = serde_json::json!({
1170 "type": "object",
1171 "$defs": { "Foo": { "type": "string" } },
1172 "properties": {
1173 "x": { "$ref": "#/$defs/Foo" }
1174 }
1175 });
1176 let once = sanitize_schema(raw.clone());
1177 let twice = sanitize_schema(once.clone());
1178 assert_eq!(once, twice);
1179 assert!(twice.get("definitions").is_none());
1181 assert!(twice.get("$defs").is_some());
1182 }
1183
1184 #[test]
1185 fn json_schema_has_spec_envelope_shape() {
1186 let cat = Catalog::build_builtins_only().expect("build");
1189 let schema = cat.json_schema();
1190 assert_eq!(schema["$id"], "ferro-json-ui/v2");
1191 assert_eq!(schema["type"], "object");
1192 let required: Vec<&str> = schema["required"]
1193 .as_array()
1194 .unwrap()
1195 .iter()
1196 .map(|v| v.as_str().unwrap())
1197 .collect();
1198 assert!(required.contains(&"$schema"));
1199 assert!(required.contains(&"root"));
1200 assert!(required.contains(&"elements"));
1201 }
1202
1203 #[test]
1204 fn json_schema_has_action_and_visibility_defs() {
1205 let cat = Catalog::build_builtins_only().expect("build");
1206 let schema = cat.json_schema();
1207 assert!(
1208 schema["$defs"]["Action"].is_object(),
1209 "$defs/Action missing"
1210 );
1211 assert!(
1212 schema["$defs"]["Visibility"].is_object(),
1213 "$defs/Visibility missing"
1214 );
1215 assert!(
1216 schema["$defs"]["Element"].is_object(),
1217 "$defs/Element missing"
1218 );
1219 }
1220
1221 #[test]
1222 fn json_schema_oneof_covers_all_builtins() {
1223 let cat = Catalog::build_builtins_only().expect("build");
1224 let schema = cat.json_schema();
1225 let one_of = schema["$defs"]["Element"]["oneOf"]
1227 .as_array()
1228 .expect("Element.oneOf is an array");
1229
1230 let mut discriminators: std::collections::HashSet<String> =
1232 std::collections::HashSet::new();
1233 for variant in one_of {
1234 let c = variant["allOf"][0]["properties"]["type"]["const"]
1235 .as_str()
1236 .expect("every variant pins a type const");
1237 discriminators.insert(c.to_string());
1238 }
1239
1240 for name in crate::render::BUILTIN_TYPES.iter() {
1241 assert!(
1242 discriminators.contains(*name),
1243 "oneOf is missing discriminator for '{name}'"
1244 );
1245 }
1246
1247 assert_eq!(
1249 discriminators.len(),
1250 crate::render::BUILTIN_TYPES.len(),
1251 "oneOf variant count mismatch"
1252 );
1253 }
1254
1255 #[test]
1256 fn json_schema_is_valid() {
1257 use jsonschema::draft202012;
1258 let cat = Catalog::build_builtins_only().expect("build");
1259 let schema = cat.json_schema();
1260 assert!(
1261 draft202012::meta::is_valid(schema),
1262 "assembled full_schema did not meta-validate as Draft 2020-12"
1263 );
1264 }
1265
1266 #[test]
1267 fn validator_is_compiled_once_and_usable() {
1268 let cat = Catalog::build_builtins_only().expect("build");
1269 let minimal_valid = serde_json::json!({
1273 "$schema": "ferro-json-ui/v2",
1274 "root": "r",
1275 "elements": {
1276 "r": { "type": "Text", "props": { "content": "hi" } }
1277 }
1278 });
1279 assert!(cat.validator.is_valid(&minimal_valid));
1281 }
1282
1283 #[test]
1284 fn validator_rejects_wrong_schema_version() {
1285 let cat = Catalog::build_builtins_only().expect("build");
1286 let wrong_version = serde_json::json!({
1287 "$schema": "ferro-json-ui/v99-wrong",
1288 "root": "r",
1289 "elements": {
1290 "r": { "type": "Text", "props": { "content": "hi" } }
1291 }
1292 });
1293 assert!(
1294 !cat.validator.is_valid(&wrong_version),
1295 "validator should reject unknown $schema version via const"
1296 );
1297 }
1298
1299 #[test]
1300 fn oneof_variants_are_deterministic_sorted() {
1301 let cat1 = Catalog::build_builtins_only().expect("build 1");
1302 let cat2 = Catalog::build_builtins_only().expect("build 2");
1303 assert_eq!(
1305 serde_json::to_string(cat1.json_schema()).unwrap(),
1306 serde_json::to_string(cat2.json_schema()).unwrap()
1307 );
1308 }
1309
1310 fn test_spec_with(type_name: &str, props: Value) -> crate::spec::Spec {
1314 use crate::spec::{Element, Spec};
1315 use std::collections::HashMap;
1316 let mut elements = HashMap::new();
1317 elements.insert(
1318 "r".to_string(),
1319 Element {
1320 type_name: type_name.to_string(),
1321 props,
1322 children: Vec::new(),
1323 action: None,
1324 visible: None,
1325 each: None,
1326 if_: None,
1327 },
1328 );
1329 Spec {
1330 schema: crate::spec::SCHEMA_VERSION.to_string(),
1331 root: "r".to_string(),
1332 elements,
1333 title: None,
1334 layout: None,
1335 data: Value::Null,
1336 }
1337 }
1338
1339 #[test]
1340 fn validate_positive_per_type() {
1341 let cat = Catalog::build_builtins_only().expect("build");
1344 let cases: Vec<(&str, Value)> = vec![
1345 ("Text", serde_json::json!({ "content": "hi" })),
1346 ("Button", serde_json::json!({ "label": "Save" })),
1347 ("Badge", serde_json::json!({ "label": "New" })),
1348 ("Separator", serde_json::json!({})),
1349 ];
1350 for (ty, props) in cases {
1351 let spec = test_spec_with(ty, props.clone());
1352 match cat.validate(&spec) {
1353 Ok(()) => {}
1354 Err(errs) => panic!("validate({ty}) failed: {errs:?}"),
1355 }
1356 }
1357 }
1358
1359 #[test]
1360 fn validate_unknown_type() {
1361 let cat = Catalog::build_builtins_only().expect("build");
1362 let spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1363 let errs = cat.validate(&spec).expect_err("should fail");
1364 assert!(
1365 errs.iter().any(|e| matches!(
1366 e,
1367 CatalogError::UnknownType { type_name, .. } if type_name == "NotARealComponent"
1368 )),
1369 "expected UnknownType for NotARealComponent; got {errs:?}"
1370 );
1371 }
1372
1373 #[test]
1374 fn validate_missing_required_prop() {
1375 let cat = Catalog::build_builtins_only().expect("build");
1378 let spec = test_spec_with("Card", serde_json::json!({}));
1379 let errs = cat.validate(&spec).expect_err("should fail");
1380 assert!(
1381 errs.iter().any(|e| matches!(
1382 e,
1383 CatalogError::PropsInvalid { type_name, .. } if type_name == "Card"
1384 )),
1385 "expected PropsInvalid for missing required 'title'; got {errs:?}"
1386 );
1387 }
1388
1389 #[test]
1390 fn validate_bad_schema_version() {
1391 let cat = Catalog::build_builtins_only().expect("build");
1392 let mut spec = test_spec_with("Text", serde_json::json!({ "content": "hi" }));
1393 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1394 let errs = cat.validate(&spec).expect_err("should fail");
1395 assert!(
1396 errs.iter()
1397 .any(|e| matches!(e, CatalogError::SpecInvalid { .. })),
1398 "expected SpecInvalid for wrong $schema version; got {errs:?}"
1399 );
1400 }
1401
1402 #[test]
1403 fn validate_pre_dispatch_short_circuits() {
1404 let cat = Catalog::build_builtins_only().expect("build");
1407 let mut spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1408 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1409 let errs = cat.validate(&spec).expect_err("should fail");
1410
1411 let has_unknown = errs
1412 .iter()
1413 .any(|e| matches!(e, CatalogError::UnknownType { .. }));
1414 let has_spec_invalid = errs
1415 .iter()
1416 .any(|e| matches!(e, CatalogError::SpecInvalid { .. }));
1417 let has_props_invalid = errs
1418 .iter()
1419 .any(|e| matches!(e, CatalogError::PropsInvalid { .. }));
1420
1421 assert!(has_unknown, "expected UnknownType");
1422 assert!(
1423 !has_spec_invalid,
1424 "Stage 3 ran despite Stage 1 failing: {errs:?}"
1425 );
1426 assert!(
1427 !has_props_invalid,
1428 "Stage 2 ran despite Stage 1 failing: {errs:?}"
1429 );
1430 }
1431
1432 #[test]
1433 fn validator_is_cached_not_recompiled() {
1434 let cat = Catalog::build_builtins_only().expect("build");
1438 for _ in 0..100 {
1439 let spec = test_spec_with("Text", serde_json::json!({ "content": "x" }));
1440 assert!(cat.validate(&spec).is_ok());
1441 }
1442 }
1443
1444 #[test]
1445 fn validate_accumulates_multiple_errors_across_elements() {
1446 use crate::spec::{Element, Spec};
1448 use std::collections::HashMap;
1449 let cat = Catalog::build_builtins_only().expect("build");
1450 let mut elements = HashMap::new();
1451 elements.insert(
1452 "a".to_string(),
1453 Element {
1454 type_name: "Card".to_string(),
1455 props: serde_json::json!({}), children: Vec::new(),
1457 action: None,
1458 visible: None,
1459 each: None,
1460 if_: None,
1461 },
1462 );
1463 elements.insert(
1464 "b".to_string(),
1465 Element {
1466 type_name: "Button".to_string(),
1467 props: serde_json::json!({}), children: Vec::new(),
1469 action: None,
1470 visible: None,
1471 each: None,
1472 if_: None,
1473 },
1474 );
1475 let spec = Spec {
1476 schema: crate::spec::SCHEMA_VERSION.to_string(),
1477 root: "a".to_string(),
1478 elements,
1479 title: None,
1480 layout: None,
1481 data: Value::Null,
1482 };
1483 let errs = cat.validate(&spec).expect_err("should fail");
1484 let props_invalid_count = errs
1485 .iter()
1486 .filter(|e| matches!(e, CatalogError::PropsInvalid { .. }))
1487 .count();
1488 assert!(
1489 props_invalid_count >= 2,
1490 "expected at least 2 PropsInvalid errors; got {errs:?}"
1491 );
1492 }
1493
1494 #[test]
1500 fn build_discovers_plugins_and_rejects_invalid_schema() {
1501 use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
1502
1503 struct GoodPlugin;
1504 impl JsonUiPlugin for GoodPlugin {
1505 fn component_type(&self) -> &str {
1506 "GoodPlugin_117"
1507 }
1508 fn props_schema(&self) -> Value {
1509 serde_json::json!({ "type": "object" })
1510 }
1511 fn render(&self, _: &Value, _: &Value) -> String {
1512 String::new()
1513 }
1514 fn css_assets(&self) -> Vec<Asset> {
1515 vec![]
1516 }
1517 fn js_assets(&self) -> Vec<Asset> {
1518 vec![]
1519 }
1520 fn init_script(&self) -> Option<String> {
1521 None
1522 }
1523 }
1524
1525 register_plugin(GoodPlugin);
1526
1527 let cat = Catalog::build().expect("build succeeds with valid plugin only");
1529 assert!(
1530 cat.plugin_components.contains_key("GoodPlugin_117"),
1531 "plugin 'GoodPlugin_117' should have been discovered"
1532 );
1533 assert!(cat.plugin_components["GoodPlugin_117"].is_plugin);
1534
1535 struct BadPlugin;
1537 impl JsonUiPlugin for BadPlugin {
1538 fn component_type(&self) -> &str {
1539 "BadPlugin_117"
1540 }
1541 fn props_schema(&self) -> Value {
1542 serde_json::json!({ "type": 42 })
1545 }
1546 fn render(&self, _: &Value, _: &Value) -> String {
1547 String::new()
1548 }
1549 fn css_assets(&self) -> Vec<Asset> {
1550 vec![]
1551 }
1552 fn js_assets(&self) -> Vec<Asset> {
1553 vec![]
1554 }
1555 fn init_script(&self) -> Option<String> {
1556 None
1557 }
1558 }
1559
1560 register_plugin(BadPlugin);
1561 match Catalog::build() {
1562 Err(CatalogError::BuildFailed(msg)) => {
1563 assert!(
1564 msg.contains("BadPlugin_117"),
1565 "error should mention plugin name, got: {msg}"
1566 );
1567 }
1568 Err(other) => panic!("expected BuildFailed mentioning BadPlugin_117, got: {other:?}"),
1569 Ok(_) => panic!("expected build to fail due to invalid plugin schema"),
1570 }
1571 }
1572
1573 #[test]
1576 fn component_schema_returns_props_only() {
1577 let cat = Catalog::build_builtins_only().expect("build");
1581 let schema = cat
1582 .component_schema("Card")
1583 .expect("Card is a built-in component");
1584
1585 let obj = schema
1589 .as_object()
1590 .expect("Card props schema is a JSON object");
1591
1592 assert!(
1594 obj.contains_key("type") || obj.contains_key("oneOf") || obj.contains_key("anyOf"),
1595 "CardProps schema should be a structural object schema; got {obj:?}"
1596 );
1597
1598 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
1600 assert!(
1601 props.contains_key("title"),
1602 "CardProps schema.properties should include 'title'; got keys: {:?}",
1603 props.keys().collect::<Vec<_>>()
1604 );
1605 } else {
1606 panic!(
1607 "CardProps schema missing top-level 'properties' map — \
1608 sanitizer or Plan 02 may be wrong. Got: {}",
1609 serde_json::to_string_pretty(schema).unwrap_or_default()
1610 );
1611 }
1612
1613 let is_element_wrapper = obj
1616 .get("properties")
1617 .and_then(|v| v.as_object())
1618 .map(|p| p.contains_key("children") && p.contains_key("props"))
1619 .unwrap_or(false);
1620 assert!(
1621 !is_element_wrapper,
1622 "component_schema('Card') returned an Element wrapper; must be Props-only (CONTEXT D-19)"
1623 );
1624 }
1625
1626 #[test]
1627 fn component_schema_none_for_unknown() {
1628 let cat = Catalog::build_builtins_only().expect("build");
1629 assert!(
1630 cat.component_schema("NotARealComponent_117_05").is_none(),
1631 "unknown component must return None"
1632 );
1633 assert!(cat.component_schema("").is_none());
1635 }
1636
1637 #[test]
1638 fn component_schema_resolves_every_builtin() {
1639 let cat = Catalog::build_builtins_only().expect("build");
1643 for name in crate::render::BUILTIN_TYPES.iter() {
1644 assert!(
1645 cat.component_schema(name).is_some(),
1646 "built-in '{name}' has no per-component schema"
1647 );
1648 }
1649 }
1650
1651 #[test]
1652 fn components_sorted_yields_ascending_by_name() {
1653 let cat = Catalog::build_builtins_only().expect("build");
1654 let names: Vec<String> = cat
1655 .components_sorted()
1656 .map(|spec| spec.name.clone())
1657 .collect();
1658 assert_eq!(names.len(), crate::render::BUILTIN_TYPES.len());
1659 let mut sorted = names.clone();
1660 sorted.sort();
1661 assert_eq!(
1662 names, sorted,
1663 "components_sorted must yield ascending order"
1664 );
1665
1666 let plugin_names: Vec<String> = cat
1668 .plugin_components_sorted()
1669 .map(|spec| spec.name.clone())
1670 .collect();
1671 let mut plugin_sorted = plugin_names.clone();
1672 plugin_sorted.sort();
1673 assert_eq!(
1674 plugin_names, plugin_sorted,
1675 "plugin_components_sorted must yield ascending order"
1676 );
1677 }
1678
1679 #[test]
1682 fn prompt_under_size_budget() {
1683 let cat = Catalog::build_builtins_only().expect("build");
1684 let prompt = cat.prompt();
1685 let bytes = prompt.len();
1686 assert!(
1689 bytes <= 10 * 1024,
1690 "prompt() is {bytes} bytes, exceeds 10 KB budget (CONTEXT D-17)"
1691 );
1692 }
1693
1694 #[test]
1695 fn prompt_mentions_every_builtin() {
1696 let cat = Catalog::build_builtins_only().expect("build");
1697 let prompt = cat.prompt();
1698 for name in crate::render::BUILTIN_TYPES.iter() {
1699 let heading = format!("### {name}\n");
1700 assert!(
1701 prompt.contains(&heading),
1702 "prompt() missing section heading for '{name}'"
1703 );
1704 }
1705 }
1706
1707 #[test]
1708 fn prompt_is_deterministic() {
1709 let cat1 = Catalog::build_builtins_only().expect("build 1");
1710 let cat2 = Catalog::build_builtins_only().expect("build 2");
1711 assert_eq!(
1712 cat1.prompt(),
1713 cat2.prompt(),
1714 "prompt() must be deterministic"
1715 );
1716 }
1717
1718 #[test]
1719 fn prompt_documents_slot_fields() {
1720 let cat = Catalog::build_builtins_only().expect("build");
1723 let prompt = cat.prompt();
1724 let card_start = prompt.find("### Card\n").expect("Card section present");
1725 let card_slice = &prompt[card_start..];
1726 let end = card_slice[3..]
1728 .find("### ")
1729 .map(|i| i + 3)
1730 .unwrap_or(card_slice.len());
1731 let card_section = &card_slice[..end];
1732 assert!(
1733 card_section.contains("Slots: footer"),
1734 "Card section missing 'Slots: footer' line:\n{card_section}"
1735 );
1736 }
1737
1738 #[test]
1739 fn prompt_is_not_raw_json_schema() {
1740 let cat = Catalog::build_builtins_only().expect("build");
1741 let prompt = cat.prompt();
1742 assert!(
1743 prompt.starts_with("## Component Catalog"),
1744 "prompt() should start with Markdown header, not JSON"
1745 );
1746 assert!(
1747 !prompt.contains("\"$schema\""),
1748 "prompt() must not embed raw JSON Schema (ROADMAP caveat)"
1749 );
1750 }
1751
1752 #[test]
1753 fn catalog_contains_checkbox_group() {
1754 let cat = Catalog::build_builtins_only().expect("build");
1755 assert!(
1756 cat.component_schema("CheckboxGroup").is_some(),
1757 "CheckboxGroup must be registered in BUILTIN_SPECS as an alias for CheckboxList"
1758 );
1759 }
1760}