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, FormProps, FormSectionProps, GridProps, HeaderProps, ImageProps, InputProps,
34 KanbanBoardProps, MediaCardGridProps, ModalProps, NotificationDropdownProps, PageHeaderProps,
35 PaginationProps, ProductTileProps, ProgressProps, RawHtmlProps, SegmentedControlProps,
36 SelectProps, SeparatorProps, SidebarLayoutProps, SidebarProps, SkeletonProps, StatCardProps,
37 StreamTextProps, SwitchProps, TableProps, TabsProps, TextProps, ToastProps,
38};
39
40pub struct ComponentSpec {
47 pub name: String,
49 pub description: String,
51 pub props_schema: Value,
53 pub is_plugin: bool,
55 pub slot_fields: Vec<String>,
60}
61
62pub struct Catalog {
68 pub(crate) components: HashMap<String, ComponentSpec>,
70 pub(crate) plugin_components: HashMap<String, ComponentSpec>,
72 pub(crate) full_schema: Value,
74 pub(crate) per_component_schemas: HashMap<String, Value>,
76 pub(crate) validator: jsonschema::Validator,
78}
79
80#[derive(Debug, thiserror::Error)]
82pub enum CatalogError {
83 #[error("unknown component type '{type_name}' at element '{element_id}'")]
85 UnknownType {
86 element_id: String,
88 type_name: String,
90 },
91 #[error("props invalid for '{type_name}' at element '{element_id}': {errors:?}")]
93 PropsInvalid {
94 element_id: String,
96 type_name: String,
98 errors: Vec<String>,
100 },
101 #[error("spec invalid: {errors:?}")]
103 SpecInvalid {
104 errors: Vec<String>,
106 },
107 #[error("catalog build failed: {0}")]
109 BuildFailed(String),
110 #[error("schema serialization error: {0}")]
112 SchemaSerialization(#[from] serde_json::Error),
113}
114
115type SchemaFn = fn() -> Value;
118
119static BUILTIN_SPECS: &[(&str, &str, SchemaFn, &[&str])] = &[
125 (
127 "Text",
128 "Semantic text element (p / h1 / h2 / h3 / span / div / section).",
129 || to_value(schema_for!(TextProps)).unwrap(),
130 &[],
131 ),
132 (
133 "Button",
134 "Interactive button with variant, size, optional icon, and disabled state.",
135 || to_value(schema_for!(ButtonProps)).unwrap(),
136 &[],
137 ),
138 (
139 "Badge",
140 "Small tone-styled status label.",
141 || to_value(schema_for!(BadgeProps)).unwrap(),
142 &[],
143 ),
144 (
145 "Alert",
146 "Inline notice with neutral / success / warning / destructive tones.",
147 || to_value(schema_for!(AlertProps)).unwrap(),
148 &[],
149 ),
150 (
151 "Separator",
152 "Horizontal or vertical divider between content sections.",
153 || to_value(schema_for!(SeparatorProps)).unwrap(),
154 &[],
155 ),
156 (
157 "Progress",
158 "Progress bar with 0–100 percentage value and optional label.",
159 || to_value(schema_for!(ProgressProps)).unwrap(),
160 &[],
161 ),
162 (
163 "Avatar",
164 "Circular user image with fallback initials and size variants.",
165 || to_value(schema_for!(AvatarProps)).unwrap(),
166 &[],
167 ),
168 (
169 "Image",
170 "Image with optional aspect ratio and skeleton fallback on load error.",
171 || to_value(schema_for!(ImageProps)).unwrap(),
172 &[],
173 ),
174 (
175 "Skeleton",
176 "Loading placeholder with configurable width / height / rounding.",
177 || to_value(schema_for!(SkeletonProps)).unwrap(),
178 &[],
179 ),
180 (
181 "Breadcrumb",
182 "Navigation trail of label + optional URL items.",
183 || to_value(schema_for!(BreadcrumbProps)).unwrap(),
184 &[],
185 ),
186 (
187 "Pagination",
188 "Page navigation for paginated data (current / per_page / total).",
189 || to_value(schema_for!(PaginationProps)).unwrap(),
190 &[],
191 ),
192 (
193 "DescriptionList",
194 "Key-value pairs displayed as a description list with optional format.",
195 || to_value(schema_for!(DescriptionListProps)).unwrap(),
196 &[],
197 ),
198 (
199 "EmptyState",
200 "Standardized empty view with title, description, and optional CTA.",
201 || to_value(schema_for!(EmptyStateProps)).unwrap(),
202 &[],
203 ),
204 (
205 "StatCard",
206 "Live-updatable metric card with label, value, icon, SSE target.",
207 || to_value(schema_for!(StatCardProps)).unwrap(),
208 &[],
209 ),
210 (
211 "Checklist",
212 "Onboarding-style checklist with dismissal and server-side state.",
213 || to_value(schema_for!(ChecklistProps)).unwrap(),
214 &[],
215 ),
216 (
217 "Toast",
218 "Declarative notification intent consumed by the runtime JS via data attributes.",
219 || to_value(schema_for!(ToastProps)).unwrap(),
220 &[],
221 ),
222 (
223 "NotificationDropdown",
224 "Dropdown listing notification items with icons, timestamps, read state.",
225 || to_value(schema_for!(NotificationDropdownProps)).unwrap(),
226 &[],
227 ),
228 (
229 "Sidebar",
230 "Dashboard sidebar with fixed top / bottom items and collapsible nav groups.",
231 || to_value(schema_for!(SidebarProps)).unwrap(),
232 &[],
233 ),
234 (
235 "Header",
236 "Dashboard top bar with business name, notification badge, user menu.",
237 || to_value(schema_for!(HeaderProps)).unwrap(),
238 &[],
239 ),
240 (
241 "CalendarCell",
242 "Single day in a month grid with today highlight, out-of-month muting, event dots.",
243 || to_value(schema_for!(CalendarCellProps)).unwrap(),
244 &[],
245 ),
246 (
247 "ActionCard",
248 "Clickable row with icon, title, description, chevron, and tone-colored left border.",
249 || to_value(schema_for!(ActionCardProps)).unwrap(),
250 &[],
251 ),
252 (
253 "ProductTile",
254 "Touch-friendly POS tile with name, price, and +/- quantity controls.",
255 || to_value(schema_for!(ProductTileProps)).unwrap(),
256 &[],
257 ),
258 (
259 "RawHtml",
260 "Server-injected HTML island. CONSUMER is responsible for sanitization — see docs/src/json-ui/plugins.md.",
261 || to_value(schema_for!(RawHtmlProps)).unwrap(),
262 &[],
263 ),
264 (
265 "StreamText",
266 "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.",
267 || to_value(schema_for!(StreamTextProps)).unwrap(),
268 &[],
269 ),
270 (
272 "Card",
273 "Content container with title, description, optional badge and subtitle, body children, and optional footer slot.",
274 || to_value(schema_for!(CardProps)).unwrap(),
275 &["footer"],
276 ),
277 (
278 "Modal",
279 "Dialog overlay with title, description, body children, and optional footer slot.",
280 || to_value(schema_for!(ModalProps)).unwrap(),
281 &["footer"],
282 ),
283 (
284 "Tabs",
285 "Tabbed content; per-tab children live in TabsProps.tabs[i].children.",
286 || to_value(schema_for!(TabsProps)).unwrap(),
287 &[],
288 ),
289 (
290 "KanbanBoard",
291 "Horizontally scrollable kanban columns on desktop, tab-switched on mobile.",
292 || to_value(schema_for!(KanbanBoardProps)).unwrap(),
293 &[],
294 ),
295 (
296 "PageHeader",
297 "Page title with optional breadcrumb and action button slot.",
298 || to_value(schema_for!(PageHeaderProps)).unwrap(),
299 &["actions"],
300 ),
301 (
302 "DetailPage",
303 "Canonical resource-detail skeleton: PageHeader chrome, optional info Card slot, and stacked body sections from Element.children.",
304 || to_value(schema_for!(DetailPageProps)).unwrap(),
305 &["actions", "info"],
306 ),
307 (
308 "Grid",
309 "Responsive multi-column grid with configurable breakpoint columns, gap, scroll.",
310 || to_value(schema_for!(GridProps)).unwrap(),
311 &[],
312 ),
313 (
314 "Collapsible",
315 "Expandable <details> / <summary> section.",
316 || to_value(schema_for!(CollapsibleProps)).unwrap(),
317 &[],
318 ),
319 (
320 "FormSection",
321 "Visual grouping within a form with title, description, and layout variant.",
322 || to_value(schema_for!(FormSectionProps)).unwrap(),
323 &[],
324 ),
325 (
326 "ButtonGroup",
327 "Horizontal button row with configurable gap.",
328 || to_value(schema_for!(ButtonGroupProps)).unwrap(),
329 &[],
330 ),
331 (
332 "SegmentedControl",
333 "Connected button cluster — date scrollers, view toggles, mode pickers. Items via literal or data_path.",
334 || to_value(schema_for!(SegmentedControlProps)).unwrap(),
335 &[],
336 ),
337 (
338 "SidebarLayout",
339 "Two-column layout with sticky vertical nav (left) and main content slot (right). Mobile-collapsing.",
340 || to_value(schema_for!(SidebarLayoutProps)).unwrap(),
341 &[],
342 ),
343 (
344 "ActionGroup",
345 "Ordered action list: inline buttons up to max_inline, trailing overflow kebab for the rest; destructive items forced into the kebab last.",
346 || to_value(schema_for!(ActionGroupProps)).unwrap(),
347 &[],
348 ),
349 (
351 "Form",
352 "Form container with action binding and field components.",
353 || to_value(schema_for!(FormProps)).unwrap(),
354 &[],
355 ),
356 (
357 "Input",
358 "Text input with type variants, validation error, data_path pre-fill.",
359 || to_value(schema_for!(InputProps)).unwrap(),
360 &[],
361 ),
362 (
363 "Select",
364 "Dropdown select with options, error, data_path pre-fill.",
365 || to_value(schema_for!(SelectProps)).unwrap(),
366 &[],
367 ),
368 (
369 "Checkbox",
370 "Boolean checkbox with label, description, data binding.",
371 || to_value(schema_for!(CheckboxProps)).unwrap(),
372 &[],
373 ),
374 (
375 "Switch",
376 "Toggle switch (visual alternative to Checkbox); auto-submit when `action` set.",
377 || to_value(schema_for!(SwitchProps)).unwrap(),
378 &[],
379 ),
380 (
381 "CheckboxList",
382 "Multi-select checkbox group from static options or data-driven array. \
383 Each checked option submits as field=value.",
384 || to_value(schema_for!(CheckboxListProps)).unwrap(),
385 &[],
386 ),
387 (
388 "CheckboxGroup",
389 "Multi-select checkbox group (alias for CheckboxList). Each checked option \
390 submits as field=value with array-submit semantics. Identical props to \
391 CheckboxList; see that entry for full schema.",
392 || to_value(schema_for!(CheckboxListProps)).unwrap(),
393 &[],
394 ),
395 (
397 "Table",
398 "Data table with columns, row_actions, sorting, empty_message.",
399 || to_value(schema_for!(TableProps)).unwrap(),
400 &[],
401 ),
402 (
403 "DataTable",
404 "Stripe-style alternating-row table with per-row action menu and mobile card fallback.",
405 || to_value(schema_for!(DataTableProps)).unwrap(),
406 &[],
407 ),
408 (
409 "MediaCardGrid",
410 "Responsive card grid backed by a data array. Each card shows an optional screenshot image, title, description, status badge, and per-row dropdown actions.",
411 || to_value(schema_for!(MediaCardGridProps)).unwrap(),
412 &[],
413 ),
414];
415
416fn sanitize_schema(mut schema: Value) -> Value {
424 fn walk(v: &mut Value) {
425 if let Some(obj) = v.as_object_mut() {
426 if let Some(defs) = obj.remove("definitions") {
427 obj.entry("$defs".to_string()).or_insert(defs);
428 }
429 if let Some(Value::String(ref_str)) = obj.get_mut("$ref") {
430 if let Some(suffix) = ref_str.strip_prefix("#/definitions/") {
431 *ref_str = format!("#/$defs/{suffix}");
432 }
433 }
434 let keys: Vec<String> = obj.keys().cloned().collect();
436 for k in keys {
437 if let Some(child) = obj.get_mut(&k) {
438 walk(child);
439 }
440 }
441 } else if let Some(arr) = v.as_array_mut() {
442 for item in arr.iter_mut() {
443 walk(item);
444 }
445 }
446 }
447 walk(&mut schema);
448 schema
449}
450
451fn hoist_defs(schema: &mut Value, shared_defs: &mut serde_json::Map<String, Value>) {
460 if let Some(obj) = schema.as_object_mut() {
461 if let Some(Value::Object(defs)) = obj.remove("$defs") {
462 for (k, v) in defs {
463 shared_defs.entry(k).or_insert(v);
464 }
465 }
466 }
467}
468
469fn assemble_full_schema(per_component: &HashMap<String, Value>) -> Result<Value, CatalogError> {
481 let mut action_schema = sanitize_schema(to_value(schema_for!(crate::action::Action))?);
483 let mut visibility_schema =
484 sanitize_schema(to_value(schema_for!(crate::visibility::Visibility))?);
485
486 let mut shared_defs: serde_json::Map<String, Value> = serde_json::Map::new();
488 hoist_defs(&mut action_schema, &mut shared_defs);
489 hoist_defs(&mut visibility_schema, &mut shared_defs);
490
491 let mut names: Vec<&String> = per_component.keys().collect();
495 names.sort();
496 let one_of: Vec<Value> = names
497 .into_iter()
498 .map(|name| {
499 let mut props_schema = per_component[name].clone();
500 hoist_defs(&mut props_schema, &mut shared_defs);
502 serde_json::json!({
503 "allOf": [
504 {
505 "type": "object",
506 "required": ["type"],
507 "properties": {
508 "type": { "const": name }
509 }
510 },
511 {
512 "type": "object",
513 "properties": {
514 "props": props_schema,
515 "children": { "type": "array", "items": { "type": "string" } },
516 "action": { "$ref": "#/$defs/Action" },
517 "visible": { "$ref": "#/$defs/Visibility" }
518 }
519 }
520 ]
521 })
522 })
523 .collect();
524
525 shared_defs
528 .entry("Action".to_string())
529 .or_insert(action_schema);
530 shared_defs
531 .entry("Visibility".to_string())
532 .or_insert(visibility_schema);
533 shared_defs.insert(
535 "Element".to_string(),
536 serde_json::json!({ "oneOf": one_of }),
537 );
538
539 Ok(serde_json::json!({
540 "$schema": "https://json-schema.org/draft/2020-12/schema",
541 "$id": "ferro-json-ui/v2",
542 "type": "object",
543 "required": ["$schema", "root", "elements"],
544 "properties": {
545 "$schema": { "const": "ferro-json-ui/v2" },
546 "root": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,127}$" },
547 "elements": {
548 "type": "object",
549 "additionalProperties": { "$ref": "#/$defs/Element" }
550 },
551 "title": { "type": ["string", "null"] },
552 "layout": { "type": ["string", "null"] },
553 "data": true
554 },
555 "$defs": shared_defs
556 }))
557}
558
559impl Catalog {
562 pub fn build() -> Result<Self, CatalogError> {
573 if BUILTIN_SPECS.len() != crate::render::BUILTIN_TYPES.len() {
577 return Err(CatalogError::BuildFailed(format!(
578 "BUILTIN_SPECS has {} entries but BUILTIN_TYPES has {} — \
579 add an entry to BUILTIN_SPECS or remove from BUILTIN_TYPES",
580 BUILTIN_SPECS.len(),
581 crate::render::BUILTIN_TYPES.len(),
582 )));
583 }
584
585 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
587 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len() * 2);
588 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
589 let raw = schema_fn();
590 let schema = sanitize_schema(raw);
591 per_component_schemas.insert((*name).to_string(), schema.clone());
592 components.insert(
593 (*name).to_string(),
594 ComponentSpec {
595 name: (*name).to_string(),
596 description: (*desc).to_string(),
597 props_schema: schema,
598 is_plugin: false,
599 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
600 },
601 );
602 }
603
604 let mut plugin_components = HashMap::new();
610 for plugin_type in crate::plugin::registered_plugin_types() {
611 if components.contains_key(&plugin_type) {
613 continue;
614 }
615 let raw = crate::plugin::with_plugin(&plugin_type, |p| p.props_schema())
616 .unwrap_or(Value::Null);
617 let schema = sanitize_schema(raw);
618 if jsonschema::validator_for(&schema).is_err() {
620 return Err(CatalogError::BuildFailed(format!(
621 "plugin '{plugin_type}' returned an invalid JSON Schema"
622 )));
623 }
624 per_component_schemas.insert(plugin_type.clone(), schema.clone());
625 plugin_components.insert(
626 plugin_type.clone(),
627 ComponentSpec {
628 name: plugin_type,
629 description: String::from("Plugin component."),
630 props_schema: schema,
631 is_plugin: true,
632 slot_fields: Vec::new(),
633 },
634 );
635 }
636
637 let full_schema = assemble_full_schema(&per_component_schemas)?;
639
640 let validator = jsonschema::validator_for(&full_schema)
642 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
643
644 Ok(Catalog {
645 components,
646 plugin_components,
647 full_schema,
648 per_component_schemas,
649 validator,
650 })
651 }
652
653 pub fn json_schema(&self) -> &Value {
660 &self.full_schema
661 }
662
663 pub fn validate(&self, spec: &crate::spec::Spec) -> Result<(), Vec<CatalogError>> {
690 let mut errors: Vec<CatalogError> = Vec::new();
691
692 for (id, el) in &spec.elements {
694 let known = self.components.contains_key(&el.type_name)
695 || self.plugin_components.contains_key(&el.type_name);
696 if !known {
697 errors.push(CatalogError::UnknownType {
698 element_id: id.clone(),
699 type_name: el.type_name.clone(),
700 });
701 }
702 }
703 if !errors.is_empty() {
708 return Err(errors);
709 }
710
711 for (id, el) in &spec.elements {
713 if let Some(schema) = self.per_component_schemas.get(&el.type_name) {
714 if el.props.is_null() {
719 continue;
720 }
721 let v = match jsonschema::validator_for(schema) {
725 Ok(v) => v,
726 Err(e) => {
727 errors.push(CatalogError::BuildFailed(format!(
728 "compiling per-component schema for '{}': {e}",
729 el.type_name
730 )));
731 continue;
732 }
733 };
734 let validation_props = strip_expr_objects(&el.props);
740 let mut per_elem_errs: Vec<String> = Vec::new();
741 for err in v.iter_errors(&validation_props) {
742 per_elem_errs.push(format!("{}: {}", err.instance_path(), err));
743 }
744 if !per_elem_errs.is_empty() {
745 errors.push(CatalogError::PropsInvalid {
746 element_id: id.clone(),
747 type_name: el.type_name.clone(),
748 errors: per_elem_errs,
749 });
750 }
751 }
752 }
753
754 for (id, el) in &spec.elements {
761 let mut renamed: Vec<String> = Vec::new();
762 for (ty, old, new) in RETIRED_PROPS {
763 if el.type_name == *ty && el.props.get(old).is_some() {
764 renamed.push(format!(
765 "/{old}: `{old}` was renamed to `{new}` — update the spec"
766 ));
767 }
768 }
769 collect_retired_action_variants(&el.props, "", &mut renamed);
770 if let Some(action) = &el.action {
771 if let Ok(action_value) = serde_json::to_value(action) {
772 collect_retired_action_variants(&action_value, "/action", &mut renamed);
773 }
774 }
775 if !renamed.is_empty() {
776 errors.push(CatalogError::PropsInvalid {
777 element_id: id.clone(),
778 type_name: el.type_name.clone(),
779 errors: renamed,
780 });
781 }
782 }
783
784 let spec_value = match serde_json::to_value(spec) {
786 Ok(v) => v,
787 Err(e) => {
788 errors.push(CatalogError::SchemaSerialization(e));
789 return Err(errors);
790 }
791 };
792 let stripped_spec_value = strip_expr_objects(&spec_value);
794 let mut envelope_errs: Vec<String> = Vec::new();
795 for err in self.validator.iter_errors(&stripped_spec_value) {
796 envelope_errs.push(format!("{}: {}", err.instance_path(), err));
797 }
798 if !envelope_errs.is_empty() {
799 errors.push(CatalogError::SpecInvalid {
800 errors: envelope_errs,
801 });
802 }
803
804 if errors.is_empty() {
805 Ok(())
806 } else {
807 Err(errors)
808 }
809 }
810
811 pub fn component_schema(&self, type_name: &str) -> Option<&Value> {
825 self.per_component_schemas.get(type_name)
826 }
827
828 pub fn components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
835 let mut entries: Vec<&ComponentSpec> = self.components.values().collect();
836 entries.sort_by(|a, b| a.name.cmp(&b.name));
837 entries.into_iter()
838 }
839
840 pub fn plugin_components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
846 let mut entries: Vec<&ComponentSpec> = self.plugin_components.values().collect();
847 entries.sort_by(|a, b| a.name.cmp(&b.name));
848 entries.into_iter()
849 }
850
851 pub fn prompt(&self) -> String {
867 let mut out = String::with_capacity(8 * 1024);
868 out.push_str("## Component Catalog\n\n");
869 out.push_str("Slot fields are Vec<String> of element IDs; body children come from Element.children.\n\n");
870 for spec in self.components_sorted() {
871 render_component_section(&mut out, spec);
872 }
873 if self.plugin_components.is_empty() {
874 return out;
875 }
876 out.push_str("## Plugin Components\n\n");
877 for spec in self.plugin_components_sorted() {
878 render_component_section(&mut out, spec);
879 }
880 out
881 }
882}
883
884const RETIRED_PROPS: &[(&str, &str, &str)] = &[
889 ("Card", "variant", "appearance"),
890 ("Badge", "variant", "tone"),
891 ("Alert", "variant", "tone"),
892 ("Toast", "variant", "tone"),
893 ("ActionCard", "variant", "tone"),
894 ("MediaCardGrid", "badge_variant_key", "badge_tone_key"),
895];
896
897fn collect_retired_action_variants(value: &Value, path: &str, out: &mut Vec<String>) {
904 match value {
905 Value::Object(map) => {
906 for (key, child) in map {
907 let child_path = format!("{path}/{key}");
908 if let Value::Object(obj) = child {
909 let is_confirm = key == "confirm";
910 let is_notify_outcome = (key == "on_success" || key == "on_error")
911 && obj.get("type").and_then(Value::as_str) == Some("notify");
912 if (is_confirm || is_notify_outcome) && obj.contains_key("variant") {
913 out.push(format!(
914 "{child_path}/variant: `variant` was renamed to `tone` — update the spec"
915 ));
916 }
917 }
918 collect_retired_action_variants(child, &child_path, out);
919 }
920 }
921 Value::Array(arr) => {
922 for (i, child) in arr.iter().enumerate() {
923 collect_retired_action_variants(child, &format!("{path}/{i}"), out);
924 }
925 }
926 _ => {}
927 }
928}
929
930fn render_component_section(out: &mut String, spec: &ComponentSpec) {
947 out.push_str("### ");
948 out.push_str(&spec.name);
949 out.push('\n');
950 out.push_str(&spec.description);
951 out.push('\n');
952
953 let props_line = render_props_line(&spec.props_schema);
954 if !props_line.is_empty() {
955 out.push_str("Props: ");
956 out.push_str(&props_line);
957 out.push('\n');
958 }
959 if !spec.slot_fields.is_empty() {
960 out.push_str("Slots: ");
961 out.push_str(&spec.slot_fields.join(", "));
962 out.push('\n');
963 }
964 out.push('\n');
965}
966
967fn render_props_line(schema: &Value) -> String {
979 let Some(obj) = schema.as_object() else {
980 return String::new();
981 };
982 let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
983 return String::new();
984 };
985 let defs = obj.get("$defs").and_then(|v| v.as_object());
989 let required: std::collections::HashSet<&str> = obj
990 .get("required")
991 .and_then(|v| v.as_array())
992 .map(|arr| {
993 arr.iter()
994 .filter_map(|v| v.as_str())
995 .collect::<std::collections::HashSet<_>>()
996 })
997 .unwrap_or_default();
998
999 let parts: Vec<String> = props
1000 .iter()
1001 .map(|(name, field_schema)| {
1002 let ty = render_field_type(field_schema, required.contains(name.as_str()), defs);
1003 format!("{name} ({ty})")
1004 })
1005 .collect();
1006 parts.join(", ")
1007}
1008
1009fn resolve_local_enum_ref<'a>(
1014 schema: &'a Value,
1015 defs: Option<&'a serde_json::Map<String, Value>>,
1016) -> Option<Vec<&'a str>> {
1017 let name = schema.get("$ref")?.as_str()?.strip_prefix("#/$defs/")?;
1018 let target = defs?.get(name)?;
1019 let arr = target.get("enum")?.as_array()?;
1020 Some(arr.iter().filter_map(|v| v.as_str()).collect())
1021}
1022
1023fn render_field_type(
1028 schema: &Value,
1029 is_required: bool,
1030 defs: Option<&serde_json::Map<String, Value>>,
1031) -> String {
1032 if let Some(variants) = schema.get("enum").and_then(|v| v.as_array()) {
1034 let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
1035 let inner = render_enum_inline(&names);
1036 return wrap_optional(inner, is_required);
1037 }
1038 for key in ["anyOf", "oneOf"] {
1040 if let Some(arr) = schema.get(key).and_then(|v| v.as_array()) {
1041 let has_null = arr
1042 .iter()
1043 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
1044 let non_null: Vec<&Value> = arr
1045 .iter()
1046 .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
1047 .collect();
1048 if has_null && non_null.len() == 1 {
1049 let inner = render_field_type(non_null[0], true, defs);
1050 return format!("Option<{inner}>");
1051 }
1052 }
1053 }
1054 if let Some(types) = schema.get("type").and_then(|v| v.as_array()) {
1056 let non_null: Vec<&str> = types
1057 .iter()
1058 .filter_map(|v| v.as_str())
1059 .filter(|s| *s != "null")
1060 .collect();
1061 let has_null = types.iter().any(|v| v.as_str() == Some("null"));
1062 if has_null && non_null.len() == 1 {
1063 return format!("Option<{}>", rust_for_json_type(non_null[0], schema, defs));
1064 }
1065 }
1066 if let Some(t) = schema.get("type").and_then(|v| v.as_str()) {
1068 let inner = rust_for_json_type(t, schema, defs);
1069 return wrap_optional(inner, is_required);
1070 }
1071 if let Some(names) = resolve_local_enum_ref(schema, defs) {
1074 return wrap_optional(render_enum_inline(&names), is_required);
1075 }
1076 wrap_optional("<see schema>".to_string(), is_required)
1078}
1079
1080fn rust_for_json_type(
1082 t: &str,
1083 schema: &Value,
1084 defs: Option<&serde_json::Map<String, Value>>,
1085) -> String {
1086 match t {
1087 "string" => "String".to_string(),
1088 "integer" => "i64".to_string(),
1089 "number" => "f64".to_string(),
1090 "boolean" => "bool".to_string(),
1091 "array" => {
1092 if let Some(items) = schema.get("items") {
1093 let inner = render_field_type(items, true, defs);
1094 format!("Vec<{inner}>")
1095 } else {
1096 "Vec<Value>".to_string()
1097 }
1098 }
1099 "object" => "Object".to_string(),
1100 other => other.to_string(),
1101 }
1102}
1103
1104fn render_enum_inline(variants: &[&str]) -> String {
1106 if variants.len() <= 8 {
1107 variants.join("|")
1108 } else {
1109 format!("one of {} — see schema", variants.len())
1110 }
1111}
1112
1113fn wrap_optional(inner: String, is_required: bool) -> String {
1115 if is_required {
1116 inner
1117 } else {
1118 format!("Option<{inner}>")
1119 }
1120}
1121
1122fn strip_expr_objects(val: &Value) -> Value {
1131 match val {
1132 Value::Object(map) => {
1133 if map.len() == 1 && (map.contains_key("$data") || map.contains_key("$template")) {
1134 Value::String(String::new())
1135 } else {
1136 Value::Object(
1137 map.iter()
1138 .map(|(k, v)| (k.clone(), strip_expr_objects(v)))
1139 .collect(),
1140 )
1141 }
1142 }
1143 Value::Array(arr) => Value::Array(arr.iter().map(strip_expr_objects).collect()),
1144 other => other.clone(),
1145 }
1146}
1147
1148pub fn global_catalog() -> &'static Catalog {
1161 static GLOBAL_CATALOG: OnceLock<Catalog> = OnceLock::new();
1162 GLOBAL_CATALOG.get_or_init(|| {
1163 Catalog::build().expect("catalog build failed — see CatalogError for details")
1164 })
1165}
1166
1167#[cfg(test)]
1170impl Catalog {
1171 pub(crate) fn build_builtins_only() -> Result<Self, CatalogError> {
1176 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
1177 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len());
1178 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
1179 let raw = schema_fn();
1180 let schema = sanitize_schema(raw);
1181 per_component_schemas.insert((*name).to_string(), schema.clone());
1182 components.insert(
1183 (*name).to_string(),
1184 ComponentSpec {
1185 name: (*name).to_string(),
1186 description: (*desc).to_string(),
1187 props_schema: schema,
1188 is_plugin: false,
1189 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
1190 },
1191 );
1192 }
1193 let full_schema = assemble_full_schema(&per_component_schemas)?;
1194 let validator = jsonschema::validator_for(&full_schema)
1195 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
1196 Ok(Catalog {
1197 components,
1198 plugin_components: HashMap::new(),
1199 full_schema,
1200 per_component_schemas,
1201 validator,
1202 })
1203 }
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::*;
1209
1210 #[test]
1211 fn builtin_types_count_drift_guard() {
1212 assert_eq!(crate::render::BUILTIN_TYPES.len(), 47);
1220 }
1221
1222 const CANONICAL_VARIANT: &[&str] = &["primary", "secondary", "outline", "ghost", "destructive"];
1230 const CANONICAL_TONE: &[&str] = &["neutral", "success", "warning", "destructive"];
1231 const CANONICAL_SIZE: &[&str] = &["sm", "md", "lg"];
1232
1233 fn canonical_set_for(prop: &str) -> Option<&'static [&'static str]> {
1235 match prop {
1236 "variant" => Some(CANONICAL_VARIANT),
1237 "tone" => Some(CANONICAL_TONE),
1238 "size" => Some(CANONICAL_SIZE),
1239 _ => None,
1240 }
1241 }
1242
1243 fn extract_enum_values<'a>(
1249 schema: &'a Value,
1250 defs: &'a serde_json::Map<String, Value>,
1251 ) -> Option<Vec<&'a str>> {
1252 if let Some(name) = schema
1253 .get("$ref")
1254 .and_then(|v| v.as_str())
1255 .and_then(|r| r.strip_prefix("#/$defs/"))
1256 {
1257 return extract_enum_values(defs.get(name)?, defs);
1258 }
1259 if let Some(arr) = schema.get("enum").and_then(|v| v.as_array()) {
1260 return Some(arr.iter().filter_map(|v| v.as_str()).collect());
1261 }
1262 for key in ["anyOf", "oneOf"] {
1263 let Some(arr) = schema.get(key).and_then(|v| v.as_array()) else {
1264 continue;
1265 };
1266 let non_null: Vec<&Value> = arr
1267 .iter()
1268 .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
1269 .collect();
1270 if non_null.len() == 1 && non_null.len() < arr.len() {
1272 return extract_enum_values(non_null[0], defs);
1273 }
1274 let consts: Vec<&str> = non_null
1276 .iter()
1277 .filter_map(|v| v.get("const").and_then(|c| c.as_str()))
1278 .collect();
1279 if !consts.is_empty() && consts.len() == non_null.len() {
1280 return Some(consts);
1281 }
1282 }
1283 None
1284 }
1285
1286 fn walk_canonical_enum_props(
1292 node: &Value,
1293 defs: &serde_json::Map<String, Value>,
1294 visited: &mut std::collections::HashSet<String>,
1295 checked: &mut usize,
1296 ) {
1297 match node {
1298 Value::Object(obj) => {
1299 if let Some(name) = obj
1300 .get("$ref")
1301 .and_then(|v| v.as_str())
1302 .and_then(|r| r.strip_prefix("#/$defs/"))
1303 {
1304 if visited.insert(name.to_string()) {
1305 if let Some(target) = defs.get(name) {
1306 walk_canonical_enum_props(target, defs, visited, checked);
1307 }
1308 }
1309 }
1310 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
1311 for (prop_name, prop_schema) in props {
1312 let Some(want) = canonical_set_for(prop_name) else {
1313 continue;
1314 };
1315 let got = extract_enum_values(prop_schema, defs).unwrap_or_else(|| {
1316 panic!(
1317 "schema property '{prop_name}' must be enum-typed with the \
1318 canonical vocabulary, got non-enum schema: {prop_schema}"
1319 )
1320 });
1321 assert_eq!(
1322 got.as_slice(),
1323 want,
1324 "schema property '{prop_name}' carries a non-canonical value set \
1325 {got:?} (canonical: {want:?})"
1326 );
1327 *checked += 1;
1328 }
1329 }
1330 for child in obj.values() {
1331 walk_canonical_enum_props(child, defs, visited, checked);
1332 }
1333 }
1334 Value::Array(arr) => {
1335 for item in arr {
1336 walk_canonical_enum_props(item, defs, visited, checked);
1337 }
1338 }
1339 _ => {}
1340 }
1341 }
1342
1343 #[test]
1344 fn variant_tone_size_enum_sets_drift_guard() {
1345 let cat = Catalog::build_builtins_only().expect("build succeeds");
1353 let schema = cat.json_schema();
1354 let defs = schema
1355 .get("$defs")
1356 .and_then(|v| v.as_object())
1357 .expect("assembled schema has a root $defs map");
1358
1359 for (def_name, want) in [
1361 ("Variant", CANONICAL_VARIANT),
1362 ("Tone", CANONICAL_TONE),
1363 ("Size", CANONICAL_SIZE),
1364 ] {
1365 let def = defs
1366 .get(def_name)
1367 .unwrap_or_else(|| panic!("$defs/{def_name} missing from the assembled schema"));
1368 let got = extract_enum_values(def, defs)
1369 .unwrap_or_else(|| panic!("$defs/{def_name} is not an enum schema: {def}"));
1370 assert_eq!(
1371 got.as_slice(),
1372 want,
1373 "$defs/{def_name} value set drifted from the canonical enum"
1374 );
1375 }
1376
1377 let one_of = defs
1379 .get("Element")
1380 .and_then(|e| e.get("oneOf"))
1381 .and_then(|v| v.as_array())
1382 .expect("$defs/Element/oneOf array");
1383 assert_eq!(
1384 one_of.len(),
1385 crate::render::BUILTIN_TYPES.len(),
1386 "oneOf must carry one entry per builtin component"
1387 );
1388 let mut checked = 0usize;
1389 for entry in one_of {
1390 let props = entry
1391 .pointer("/allOf/1/properties/props")
1392 .unwrap_or_else(|| {
1393 panic!("oneOf entry missing allOf[1].properties.props: {entry}")
1394 });
1395 let mut visited = std::collections::HashSet::new();
1396 walk_canonical_enum_props(props, defs, &mut visited, &mut checked);
1397 }
1398
1399 let mut visited = std::collections::HashSet::new();
1404 for def in defs.values() {
1405 walk_canonical_enum_props(def, defs, &mut visited, &mut checked);
1406 }
1407
1408 assert!(
1409 checked >= 10,
1410 "walker asserted only {checked} variant/tone/size properties — \
1411 the schema traversal is broken (expected at least 10 across the catalog)"
1412 );
1413 }
1414
1415 #[test]
1416 fn builtin_specs_len_matches_dispatch() {
1417 assert_eq!(BUILTIN_SPECS.len(), crate::render::BUILTIN_TYPES.len());
1420 }
1421
1422 #[test]
1423 fn builtin_specs_names_match_dispatch() {
1424 use std::collections::HashSet;
1425 let specs: HashSet<&str> = BUILTIN_SPECS.iter().map(|(n, ..)| *n).collect();
1426 let types: HashSet<&str> = crate::render::BUILTIN_TYPES.iter().copied().collect();
1427 assert_eq!(specs, types, "BUILTIN_SPECS names must match BUILTIN_TYPES");
1428 }
1429
1430 #[test]
1431 fn build_populates_all_builtins() {
1432 let cat = Catalog::build_builtins_only().expect("build succeeds");
1434 for name in crate::render::BUILTIN_TYPES.iter() {
1435 assert!(
1436 cat.components.contains_key(*name),
1437 "built-in '{name}' missing from catalog.components"
1438 );
1439 let spec = &cat.components[*name];
1440 assert_eq!(spec.name, *name);
1441 assert!(
1442 !spec.description.is_empty(),
1443 "'{name}' has empty description"
1444 );
1445 assert!(
1446 spec.props_schema.is_object(),
1447 "'{name}' props_schema is not a JSON object"
1448 );
1449 assert!(!spec.is_plugin);
1450 }
1451 }
1452
1453 #[test]
1454 fn build_card_has_footer_slot() {
1455 let cat = Catalog::build_builtins_only().expect("build succeeds");
1457 let card = &cat.components["Card"];
1458 assert_eq!(card.slot_fields, vec!["footer"]);
1459 }
1460
1461 #[test]
1462 fn build_modal_has_footer_slot() {
1463 let cat = Catalog::build_builtins_only().expect("build succeeds");
1465 let modal = &cat.components["Modal"];
1466 assert_eq!(modal.slot_fields, vec!["footer"]);
1467 }
1468
1469 #[test]
1470 fn build_pageheader_has_actions_slot() {
1471 let cat = Catalog::build_builtins_only().expect("build succeeds");
1473 let ph = &cat.components["PageHeader"];
1474 assert_eq!(ph.slot_fields, vec!["actions"]);
1475 }
1476
1477 #[test]
1478 fn build_text_has_no_slots() {
1479 let cat = Catalog::build_builtins_only().expect("build succeeds");
1481 assert!(cat.components["Text"].slot_fields.is_empty());
1482 }
1483
1484 #[test]
1485 fn build_populates_per_component_schemas() {
1486 let cat = Catalog::build_builtins_only().expect("build succeeds");
1488 assert_eq!(
1489 cat.per_component_schemas.len(),
1490 BUILTIN_SPECS.len() + cat.plugin_components.len()
1491 );
1492 }
1493
1494 #[test]
1495 fn sanitize_schema_rewrites_definitions_to_dollar_defs() {
1496 let raw = serde_json::json!({
1497 "type": "object",
1498 "definitions": { "Foo": { "type": "string" } },
1499 "properties": {
1500 "x": { "$ref": "#/definitions/Foo" }
1501 }
1502 });
1503 let out = sanitize_schema(raw);
1504 assert!(out.get("definitions").is_none());
1505 assert!(out.get("$defs").is_some());
1506 assert_eq!(
1507 out["properties"]["x"]["$ref"].as_str().unwrap(),
1508 "#/$defs/Foo"
1509 );
1510 }
1511
1512 #[test]
1513 fn sanitize_schema_is_idempotent() {
1514 let raw = serde_json::json!({
1515 "type": "object",
1516 "$defs": { "Foo": { "type": "string" } },
1517 "properties": {
1518 "x": { "$ref": "#/$defs/Foo" }
1519 }
1520 });
1521 let once = sanitize_schema(raw.clone());
1522 let twice = sanitize_schema(once.clone());
1523 assert_eq!(once, twice);
1524 assert!(twice.get("definitions").is_none());
1526 assert!(twice.get("$defs").is_some());
1527 }
1528
1529 #[test]
1530 fn json_schema_has_spec_envelope_shape() {
1531 let cat = Catalog::build_builtins_only().expect("build");
1534 let schema = cat.json_schema();
1535 assert_eq!(schema["$id"], "ferro-json-ui/v2");
1536 assert_eq!(schema["type"], "object");
1537 let required: Vec<&str> = schema["required"]
1538 .as_array()
1539 .unwrap()
1540 .iter()
1541 .map(|v| v.as_str().unwrap())
1542 .collect();
1543 assert!(required.contains(&"$schema"));
1544 assert!(required.contains(&"root"));
1545 assert!(required.contains(&"elements"));
1546 }
1547
1548 #[test]
1549 fn json_schema_has_action_and_visibility_defs() {
1550 let cat = Catalog::build_builtins_only().expect("build");
1551 let schema = cat.json_schema();
1552 assert!(
1553 schema["$defs"]["Action"].is_object(),
1554 "$defs/Action missing"
1555 );
1556 assert!(
1557 schema["$defs"]["Visibility"].is_object(),
1558 "$defs/Visibility missing"
1559 );
1560 assert!(
1561 schema["$defs"]["Element"].is_object(),
1562 "$defs/Element missing"
1563 );
1564 }
1565
1566 #[test]
1567 fn json_schema_oneof_covers_all_builtins() {
1568 let cat = Catalog::build_builtins_only().expect("build");
1569 let schema = cat.json_schema();
1570 let one_of = schema["$defs"]["Element"]["oneOf"]
1572 .as_array()
1573 .expect("Element.oneOf is an array");
1574
1575 let mut discriminators: std::collections::HashSet<String> =
1577 std::collections::HashSet::new();
1578 for variant in one_of {
1579 let c = variant["allOf"][0]["properties"]["type"]["const"]
1580 .as_str()
1581 .expect("every variant pins a type const");
1582 discriminators.insert(c.to_string());
1583 }
1584
1585 for name in crate::render::BUILTIN_TYPES.iter() {
1586 assert!(
1587 discriminators.contains(*name),
1588 "oneOf is missing discriminator for '{name}'"
1589 );
1590 }
1591
1592 assert_eq!(
1594 discriminators.len(),
1595 crate::render::BUILTIN_TYPES.len(),
1596 "oneOf variant count mismatch"
1597 );
1598 }
1599
1600 #[test]
1601 fn json_schema_is_valid() {
1602 use jsonschema::draft202012;
1603 let cat = Catalog::build_builtins_only().expect("build");
1604 let schema = cat.json_schema();
1605 assert!(
1606 draft202012::meta::is_valid(schema),
1607 "assembled full_schema did not meta-validate as Draft 2020-12"
1608 );
1609 }
1610
1611 #[test]
1612 fn validator_is_compiled_once_and_usable() {
1613 let cat = Catalog::build_builtins_only().expect("build");
1614 let minimal_valid = serde_json::json!({
1618 "$schema": "ferro-json-ui/v2",
1619 "root": "r",
1620 "elements": {
1621 "r": { "type": "Text", "props": { "content": "hi" } }
1622 }
1623 });
1624 assert!(cat.validator.is_valid(&minimal_valid));
1626 }
1627
1628 #[test]
1629 fn validator_rejects_wrong_schema_version() {
1630 let cat = Catalog::build_builtins_only().expect("build");
1631 let wrong_version = serde_json::json!({
1632 "$schema": "ferro-json-ui/v99-wrong",
1633 "root": "r",
1634 "elements": {
1635 "r": { "type": "Text", "props": { "content": "hi" } }
1636 }
1637 });
1638 assert!(
1639 !cat.validator.is_valid(&wrong_version),
1640 "validator should reject unknown $schema version via const"
1641 );
1642 }
1643
1644 #[test]
1645 fn oneof_variants_are_deterministic_sorted() {
1646 let cat1 = Catalog::build_builtins_only().expect("build 1");
1647 let cat2 = Catalog::build_builtins_only().expect("build 2");
1648 assert_eq!(
1650 serde_json::to_string(cat1.json_schema()).unwrap(),
1651 serde_json::to_string(cat2.json_schema()).unwrap()
1652 );
1653 }
1654
1655 fn test_spec_with(type_name: &str, props: Value) -> crate::spec::Spec {
1659 use crate::spec::{Element, Spec};
1660 use std::collections::HashMap;
1661 let mut elements = HashMap::new();
1662 elements.insert(
1663 "r".to_string(),
1664 Element {
1665 type_name: type_name.to_string(),
1666 props,
1667 children: Vec::new(),
1668 action: None,
1669 visible: None,
1670 each: None,
1671 if_: None,
1672 },
1673 );
1674 Spec {
1675 schema: crate::spec::SCHEMA_VERSION.to_string(),
1676 root: "r".to_string(),
1677 elements,
1678 title: None,
1679 layout: None,
1680 fill_viewport: false,
1681 data: Value::Null,
1682 design: None,
1683 }
1684 }
1685
1686 #[test]
1687 fn validate_positive_per_type() {
1688 let cat = Catalog::build_builtins_only().expect("build");
1691 let cases: Vec<(&str, Value)> = vec![
1692 ("Text", serde_json::json!({ "content": "hi" })),
1693 ("Button", serde_json::json!({ "label": "Save" })),
1694 ("Badge", serde_json::json!({ "label": "New" })),
1695 ("Separator", serde_json::json!({})),
1696 ];
1697 for (ty, props) in cases {
1698 let spec = test_spec_with(ty, props.clone());
1699 match cat.validate(&spec) {
1700 Ok(()) => {}
1701 Err(errs) => panic!("validate({ty}) failed: {errs:?}"),
1702 }
1703 }
1704 }
1705
1706 #[test]
1707 fn validate_unknown_type() {
1708 let cat = Catalog::build_builtins_only().expect("build");
1709 let spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1710 let errs = cat.validate(&spec).expect_err("should fail");
1711 assert!(
1712 errs.iter().any(|e| matches!(
1713 e,
1714 CatalogError::UnknownType { type_name, .. } if type_name == "NotARealComponent"
1715 )),
1716 "expected UnknownType for NotARealComponent; got {errs:?}"
1717 );
1718 }
1719
1720 #[test]
1721 fn validate_missing_required_prop() {
1722 let cat = Catalog::build_builtins_only().expect("build");
1725 let spec = test_spec_with("Card", serde_json::json!({}));
1726 let errs = cat.validate(&spec).expect_err("should fail");
1727 assert!(
1728 errs.iter().any(|e| matches!(
1729 e,
1730 CatalogError::PropsInvalid { type_name, .. } if type_name == "Card"
1731 )),
1732 "expected PropsInvalid for missing required 'title'; got {errs:?}"
1733 );
1734 }
1735
1736 #[test]
1737 fn validate_rejects_retired_prop_names() {
1738 let cat = Catalog::build_builtins_only().expect("build");
1741 let cases: Vec<(&str, Value, &str)> = vec![
1742 (
1743 "Badge",
1744 serde_json::json!({ "label": "Paid", "variant": "success" }),
1745 "tone",
1746 ),
1747 (
1748 "Card",
1749 serde_json::json!({ "title": "T", "variant": "elevated" }),
1750 "appearance",
1751 ),
1752 (
1753 "MediaCardGrid",
1754 serde_json::json!({
1755 "data_path": "/rows",
1756 "title_key": "name",
1757 "badge_variant_key": "status"
1758 }),
1759 "badge_tone_key",
1760 ),
1761 ];
1762 for (ty, props, new_name) in cases {
1763 let spec = test_spec_with(ty, props);
1764 let errs = cat.validate(&spec).expect_err("should fail");
1765 assert!(
1766 errs.iter().any(|e| matches!(
1767 e,
1768 CatalogError::PropsInvalid { type_name, errors, .. }
1769 if type_name == ty && errors.iter().any(|m| m.contains(new_name))
1770 )),
1771 "expected retired-prop PropsInvalid for {ty} mentioning `{new_name}`; got {errs:?}"
1772 );
1773 }
1774 }
1775
1776 #[test]
1777 fn validate_rejects_retired_confirm_and_notify_variant() {
1778 let cat = Catalog::build_builtins_only().expect("build");
1781 let spec = test_spec_with(
1782 "DataTable",
1783 serde_json::json!({
1784 "data_path": "/rows",
1785 "columns": [{ "key": "name", "label": "Name" }],
1786 "row_actions": [{
1787 "label": "Delete",
1788 "action": {
1789 "handler": "rows.destroy",
1790 "method": "DELETE",
1791 "confirm": { "title": "Delete?", "variant": "danger" },
1792 "on_success": {
1793 "type": "notify",
1794 "message": "Deleted",
1795 "variant": "error"
1796 }
1797 }
1798 }]
1799 }),
1800 );
1801 let errs = cat.validate(&spec).expect_err("should fail");
1802 let retired_msgs: Vec<&String> = errs
1803 .iter()
1804 .filter_map(|e| match e {
1805 CatalogError::PropsInvalid { errors, .. } => Some(errors),
1806 _ => None,
1807 })
1808 .flatten()
1809 .filter(|m| m.contains("renamed to `tone`"))
1810 .collect();
1811 assert_eq!(
1812 retired_msgs.len(),
1813 2,
1814 "expected confirm + notify retired-variant errors; got {errs:?}"
1815 );
1816 }
1817
1818 #[test]
1819 fn validate_accepts_canonical_prop_names() {
1820 let cat = Catalog::build_builtins_only().expect("build");
1822 let cases: Vec<(&str, Value)> = vec![
1823 (
1824 "Badge",
1825 serde_json::json!({ "label": "Paid", "tone": "success" }),
1826 ),
1827 (
1828 "Card",
1829 serde_json::json!({ "title": "T", "appearance": "elevated" }),
1830 ),
1831 ];
1832 for (ty, props) in cases {
1833 let spec = test_spec_with(ty, props.clone());
1834 if let Err(errs) = cat.validate(&spec) {
1835 panic!("validate({ty}) with canonical props failed: {errs:?}");
1836 }
1837 }
1838 }
1839
1840 #[test]
1841 fn validate_bad_schema_version() {
1842 let cat = Catalog::build_builtins_only().expect("build");
1843 let mut spec = test_spec_with("Text", serde_json::json!({ "content": "hi" }));
1844 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1845 let errs = cat.validate(&spec).expect_err("should fail");
1846 assert!(
1847 errs.iter()
1848 .any(|e| matches!(e, CatalogError::SpecInvalid { .. })),
1849 "expected SpecInvalid for wrong $schema version; got {errs:?}"
1850 );
1851 }
1852
1853 #[test]
1854 fn validate_pre_dispatch_short_circuits() {
1855 let cat = Catalog::build_builtins_only().expect("build");
1858 let mut spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1859 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1860 let errs = cat.validate(&spec).expect_err("should fail");
1861
1862 let has_unknown = errs
1863 .iter()
1864 .any(|e| matches!(e, CatalogError::UnknownType { .. }));
1865 let has_spec_invalid = errs
1866 .iter()
1867 .any(|e| matches!(e, CatalogError::SpecInvalid { .. }));
1868 let has_props_invalid = errs
1869 .iter()
1870 .any(|e| matches!(e, CatalogError::PropsInvalid { .. }));
1871
1872 assert!(has_unknown, "expected UnknownType");
1873 assert!(
1874 !has_spec_invalid,
1875 "Stage 3 ran despite Stage 1 failing: {errs:?}"
1876 );
1877 assert!(
1878 !has_props_invalid,
1879 "Stage 2 ran despite Stage 1 failing: {errs:?}"
1880 );
1881 }
1882
1883 #[test]
1884 fn validator_is_cached_not_recompiled() {
1885 let cat = Catalog::build_builtins_only().expect("build");
1889 for _ in 0..100 {
1890 let spec = test_spec_with("Text", serde_json::json!({ "content": "x" }));
1891 assert!(cat.validate(&spec).is_ok());
1892 }
1893 }
1894
1895 #[test]
1896 fn validate_accumulates_multiple_errors_across_elements() {
1897 use crate::spec::{Element, Spec};
1899 use std::collections::HashMap;
1900 let cat = Catalog::build_builtins_only().expect("build");
1901 let mut elements = HashMap::new();
1902 elements.insert(
1903 "a".to_string(),
1904 Element {
1905 type_name: "Card".to_string(),
1906 props: serde_json::json!({}), children: Vec::new(),
1908 action: None,
1909 visible: None,
1910 each: None,
1911 if_: None,
1912 },
1913 );
1914 elements.insert(
1915 "b".to_string(),
1916 Element {
1917 type_name: "Button".to_string(),
1918 props: serde_json::json!({}), children: Vec::new(),
1920 action: None,
1921 visible: None,
1922 each: None,
1923 if_: None,
1924 },
1925 );
1926 let spec = Spec {
1927 schema: crate::spec::SCHEMA_VERSION.to_string(),
1928 root: "a".to_string(),
1929 elements,
1930 title: None,
1931 layout: None,
1932 fill_viewport: false,
1933 data: Value::Null,
1934 design: None,
1935 };
1936 let errs = cat.validate(&spec).expect_err("should fail");
1937 let props_invalid_count = errs
1938 .iter()
1939 .filter(|e| matches!(e, CatalogError::PropsInvalid { .. }))
1940 .count();
1941 assert!(
1942 props_invalid_count >= 2,
1943 "expected at least 2 PropsInvalid errors; got {errs:?}"
1944 );
1945 }
1946
1947 #[test]
1953 fn build_discovers_plugins_and_rejects_invalid_schema() {
1954 use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
1955
1956 struct GoodPlugin;
1957 impl JsonUiPlugin for GoodPlugin {
1958 fn component_type(&self) -> &str {
1959 "GoodPlugin_117"
1960 }
1961 fn props_schema(&self) -> Value {
1962 serde_json::json!({ "type": "object" })
1963 }
1964 fn render(&self, _: &Value, _: &Value) -> String {
1965 String::new()
1966 }
1967 fn css_assets(&self) -> Vec<Asset> {
1968 vec![]
1969 }
1970 fn js_assets(&self) -> Vec<Asset> {
1971 vec![]
1972 }
1973 fn init_script(&self) -> Option<String> {
1974 None
1975 }
1976 }
1977
1978 register_plugin(GoodPlugin);
1979
1980 let cat = Catalog::build().expect("build succeeds with valid plugin only");
1982 assert!(
1983 cat.plugin_components.contains_key("GoodPlugin_117"),
1984 "plugin 'GoodPlugin_117' should have been discovered"
1985 );
1986 assert!(cat.plugin_components["GoodPlugin_117"].is_plugin);
1987
1988 struct BadPlugin;
1990 impl JsonUiPlugin for BadPlugin {
1991 fn component_type(&self) -> &str {
1992 "BadPlugin_117"
1993 }
1994 fn props_schema(&self) -> Value {
1995 serde_json::json!({ "type": 42 })
1998 }
1999 fn render(&self, _: &Value, _: &Value) -> String {
2000 String::new()
2001 }
2002 fn css_assets(&self) -> Vec<Asset> {
2003 vec![]
2004 }
2005 fn js_assets(&self) -> Vec<Asset> {
2006 vec![]
2007 }
2008 fn init_script(&self) -> Option<String> {
2009 None
2010 }
2011 }
2012
2013 register_plugin(BadPlugin);
2014 match Catalog::build() {
2015 Err(CatalogError::BuildFailed(msg)) => {
2016 assert!(
2017 msg.contains("BadPlugin_117"),
2018 "error should mention plugin name, got: {msg}"
2019 );
2020 }
2021 Err(other) => panic!("expected BuildFailed mentioning BadPlugin_117, got: {other:?}"),
2022 Ok(_) => panic!("expected build to fail due to invalid plugin schema"),
2023 }
2024 }
2025
2026 #[test]
2029 fn component_schema_returns_props_only() {
2030 let cat = Catalog::build_builtins_only().expect("build");
2034 let schema = cat
2035 .component_schema("Card")
2036 .expect("Card is a built-in component");
2037
2038 let obj = schema
2042 .as_object()
2043 .expect("Card props schema is a JSON object");
2044
2045 assert!(
2047 obj.contains_key("type") || obj.contains_key("oneOf") || obj.contains_key("anyOf"),
2048 "CardProps schema should be a structural object schema; got {obj:?}"
2049 );
2050
2051 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
2053 assert!(
2054 props.contains_key("title"),
2055 "CardProps schema.properties should include 'title'; got keys: {:?}",
2056 props.keys().collect::<Vec<_>>()
2057 );
2058 } else {
2059 panic!(
2060 "CardProps schema missing top-level 'properties' map — \
2061 sanitizer or Plan 02 may be wrong. Got: {}",
2062 serde_json::to_string_pretty(schema).unwrap_or_default()
2063 );
2064 }
2065
2066 let is_element_wrapper = obj
2069 .get("properties")
2070 .and_then(|v| v.as_object())
2071 .map(|p| p.contains_key("children") && p.contains_key("props"))
2072 .unwrap_or(false);
2073 assert!(
2074 !is_element_wrapper,
2075 "component_schema('Card') returned an Element wrapper; must be Props-only (CONTEXT D-19)"
2076 );
2077 }
2078
2079 #[test]
2080 fn component_schema_none_for_unknown() {
2081 let cat = Catalog::build_builtins_only().expect("build");
2082 assert!(
2083 cat.component_schema("NotARealComponent_117_05").is_none(),
2084 "unknown component must return None"
2085 );
2086 assert!(cat.component_schema("").is_none());
2088 }
2089
2090 #[test]
2091 fn component_schema_resolves_every_builtin() {
2092 let cat = Catalog::build_builtins_only().expect("build");
2096 for name in crate::render::BUILTIN_TYPES.iter() {
2097 assert!(
2098 cat.component_schema(name).is_some(),
2099 "built-in '{name}' has no per-component schema"
2100 );
2101 }
2102 }
2103
2104 #[test]
2105 fn components_sorted_yields_ascending_by_name() {
2106 let cat = Catalog::build_builtins_only().expect("build");
2107 let names: Vec<String> = cat
2108 .components_sorted()
2109 .map(|spec| spec.name.clone())
2110 .collect();
2111 assert_eq!(names.len(), crate::render::BUILTIN_TYPES.len());
2112 let mut sorted = names.clone();
2113 sorted.sort();
2114 assert_eq!(
2115 names, sorted,
2116 "components_sorted must yield ascending order"
2117 );
2118
2119 let plugin_names: Vec<String> = cat
2121 .plugin_components_sorted()
2122 .map(|spec| spec.name.clone())
2123 .collect();
2124 let mut plugin_sorted = plugin_names.clone();
2125 plugin_sorted.sort();
2126 assert_eq!(
2127 plugin_names, plugin_sorted,
2128 "plugin_components_sorted must yield ascending order"
2129 );
2130 }
2131
2132 #[test]
2135 fn prompt_under_size_budget() {
2136 let cat = Catalog::build_builtins_only().expect("build");
2137 let prompt = cat.prompt();
2138 let bytes = prompt.len();
2139 assert!(
2145 bytes <= 12 * 1024,
2146 "prompt() is {bytes} bytes, exceeds 12 KB budget (CONTEXT D-17)"
2147 );
2148 }
2149
2150 #[test]
2151 fn prompt_mentions_every_builtin() {
2152 let cat = Catalog::build_builtins_only().expect("build");
2153 let prompt = cat.prompt();
2154 for name in crate::render::BUILTIN_TYPES.iter() {
2155 let heading = format!("### {name}\n");
2156 assert!(
2157 prompt.contains(&heading),
2158 "prompt() missing section heading for '{name}'"
2159 );
2160 }
2161 }
2162
2163 #[test]
2164 fn prompt_inlines_canonical_enum_values() {
2165 let cat = Catalog::build_builtins_only().expect("build");
2169 let prompt = cat.prompt();
2170 for values in [
2171 CANONICAL_VARIANT.join("|"),
2172 CANONICAL_TONE.join("|"),
2173 CANONICAL_SIZE.join("|"),
2174 ] {
2175 assert!(
2176 prompt.contains(&values),
2177 "prompt() must inline the canonical enum values '{values}'"
2178 );
2179 }
2180 }
2181
2182 #[test]
2183 fn prompt_is_deterministic() {
2184 let cat1 = Catalog::build_builtins_only().expect("build 1");
2185 let cat2 = Catalog::build_builtins_only().expect("build 2");
2186 assert_eq!(
2187 cat1.prompt(),
2188 cat2.prompt(),
2189 "prompt() must be deterministic"
2190 );
2191 }
2192
2193 #[test]
2194 fn prompt_documents_slot_fields() {
2195 let cat = Catalog::build_builtins_only().expect("build");
2198 let prompt = cat.prompt();
2199 let card_start = prompt.find("### Card\n").expect("Card section present");
2200 let card_slice = &prompt[card_start..];
2201 let end = card_slice[3..]
2203 .find("### ")
2204 .map(|i| i + 3)
2205 .unwrap_or(card_slice.len());
2206 let card_section = &card_slice[..end];
2207 assert!(
2208 card_section.contains("Slots: footer"),
2209 "Card section missing 'Slots: footer' line:\n{card_section}"
2210 );
2211 }
2212
2213 #[test]
2214 fn prompt_is_not_raw_json_schema() {
2215 let cat = Catalog::build_builtins_only().expect("build");
2216 let prompt = cat.prompt();
2217 assert!(
2218 prompt.starts_with("## Component Catalog"),
2219 "prompt() should start with Markdown header, not JSON"
2220 );
2221 assert!(
2222 !prompt.contains("\"$schema\""),
2223 "prompt() must not embed raw JSON Schema (ROADMAP caveat)"
2224 );
2225 }
2226
2227 #[test]
2228 fn catalog_contains_checkbox_group() {
2229 let cat = Catalog::build_builtins_only().expect("build");
2230 assert!(
2231 cat.component_schema("CheckboxGroup").is_some(),
2232 "CheckboxGroup must be registered in BUILTIN_SPECS as an alias for CheckboxList"
2233 );
2234 }
2235
2236 fn spec_from_json_string(json: &str) -> crate::spec::Spec {
2241 crate::spec::Spec::from_json(json).expect("test spec must parse")
2242 }
2243
2244 #[test]
2245 fn validate_rejects_retired_el_action_confirm_variant() {
2246 let cat = Catalog::build_builtins_only().expect("build");
2250 let spec = spec_from_json_string(
2251 r#"{
2252 "$schema": "ferro-json-ui/v2",
2253 "root": "btn",
2254 "elements": {
2255 "btn": {
2256 "type": "Button",
2257 "props": { "label": "Delete" },
2258 "action": {
2259 "handler": "orders.destroy",
2260 "method": "POST",
2261 "confirm": {
2262 "title": "Delete?",
2263 "message": "x",
2264 "variant": "danger"
2265 }
2266 }
2267 }
2268 }
2269 }"#,
2270 );
2271 let errs = cat
2272 .validate(&spec)
2273 .expect_err("should fail: confirm.variant is a retired prop");
2274 assert!(
2275 errs.iter().any(|e| matches!(
2276 e,
2277 CatalogError::PropsInvalid { errors, .. }
2278 if errors.iter().any(|m| m.contains("/action") && m.contains("variant"))
2279 )),
2280 "expected PropsInvalid mentioning /action and variant; got {errs:?}"
2281 );
2282 }
2283
2284 #[test]
2285 fn validate_accepts_canonical_el_action_confirm_tone() {
2286 let cat = Catalog::build_builtins_only().expect("build");
2288 let spec = spec_from_json_string(
2289 r#"{
2290 "$schema": "ferro-json-ui/v2",
2291 "root": "btn",
2292 "elements": {
2293 "btn": {
2294 "type": "Button",
2295 "props": { "label": "Delete" },
2296 "action": {
2297 "handler": "orders.destroy",
2298 "method": "POST",
2299 "confirm": {
2300 "title": "Delete?",
2301 "message": "x",
2302 "tone": "destructive"
2303 }
2304 }
2305 }
2306 }
2307 }"#,
2308 );
2309 if let Err(errs) = cat.validate(&spec) {
2310 panic!("validate with canonical confirm.tone failed: {errs:?}");
2311 }
2312 }
2313
2314 #[test]
2315 fn global_catalog_includes_stream_text() {
2316 let cat = Catalog::build_builtins_only().expect("build");
2317 assert!(
2318 cat.components.contains_key("StreamText"),
2319 "catalog must include StreamText"
2320 );
2321 let spec = &cat.components["StreamText"];
2322 assert_eq!(spec.name, "StreamText");
2323 assert!(
2324 spec.description.contains("event: done"),
2325 "StreamText description must mention 'event: done'; got: {}",
2326 spec.description
2327 );
2328 assert!(
2329 spec.props_schema.is_object(),
2330 "StreamText props_schema must be a JSON object"
2331 );
2332 assert!(!spec.is_plugin);
2333 }
2334}