use std::collections::HashMap;
use std::sync::OnceLock;
use schemars::schema_for;
use serde_json::{to_value, Value};
use crate::component::{
ActionCardProps, AlertProps, AvatarProps, BadgeProps, BreadcrumbProps, ButtonGroupProps,
ButtonProps, CalendarCellProps, CardProps, CheckboxListProps, CheckboxProps, ChecklistProps,
CollapsibleProps, DataTableProps, DescriptionListProps, DetailPageProps, DropdownMenuProps,
EmptyStateProps, FormProps, FormSectionProps, GridProps, HeaderProps, ImageProps, InputProps,
KanbanBoardProps, MediaCardGridProps, ModalProps, NotificationDropdownProps, PageHeaderProps,
PaginationProps, ProductTileProps, ProgressProps, RawHtmlProps, SelectProps, SeparatorProps,
SidebarProps, SkeletonProps, StatCardProps, SwitchProps, TableProps, TabsProps, TextProps,
ToastProps,
};
pub struct ComponentSpec {
pub name: String,
pub description: String,
pub props_schema: Value,
pub is_plugin: bool,
pub slot_fields: Vec<String>,
}
pub struct Catalog {
pub(crate) components: HashMap<String, ComponentSpec>,
pub(crate) plugin_components: HashMap<String, ComponentSpec>,
pub(crate) full_schema: Value,
pub(crate) per_component_schemas: HashMap<String, Value>,
pub(crate) validator: jsonschema::Validator,
}
#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
#[error("unknown component type '{type_name}' at element '{element_id}'")]
UnknownType {
element_id: String,
type_name: String,
},
#[error("props invalid for '{type_name}' at element '{element_id}': {errors:?}")]
PropsInvalid {
element_id: String,
type_name: String,
errors: Vec<String>,
},
#[error("spec invalid: {errors:?}")]
SpecInvalid {
errors: Vec<String>,
},
#[error("catalog build failed: {0}")]
BuildFailed(String),
#[error("schema serialization error: {0}")]
SchemaSerialization(#[from] serde_json::Error),
}
type SchemaFn = fn() -> Value;
static BUILTIN_SPECS: &[(&str, &str, SchemaFn, &[&str])] = &[
(
"Text",
"Semantic text element (p / h1 / h2 / h3 / span / div / section).",
|| to_value(schema_for!(TextProps)).unwrap(),
&[],
),
(
"Button",
"Interactive button with variant, size, optional icon, and disabled state.",
|| to_value(schema_for!(ButtonProps)).unwrap(),
&[],
),
(
"Badge",
"Small variant-styled label.",
|| to_value(schema_for!(BadgeProps)).unwrap(),
&[],
),
(
"Alert",
"Inline notice with info / success / warning / error variants.",
|| to_value(schema_for!(AlertProps)).unwrap(),
&[],
),
(
"Separator",
"Horizontal or vertical divider between content sections.",
|| to_value(schema_for!(SeparatorProps)).unwrap(),
&[],
),
(
"Progress",
"Progress bar with 0–100 percentage value and optional label.",
|| to_value(schema_for!(ProgressProps)).unwrap(),
&[],
),
(
"Avatar",
"Circular user image with fallback initials and size variants.",
|| to_value(schema_for!(AvatarProps)).unwrap(),
&[],
),
(
"Image",
"Image with optional aspect ratio and skeleton fallback on load error.",
|| to_value(schema_for!(ImageProps)).unwrap(),
&[],
),
(
"Skeleton",
"Loading placeholder with configurable width / height / rounding.",
|| to_value(schema_for!(SkeletonProps)).unwrap(),
&[],
),
(
"Breadcrumb",
"Navigation trail of label + optional URL items.",
|| to_value(schema_for!(BreadcrumbProps)).unwrap(),
&[],
),
(
"Pagination",
"Page navigation for paginated data (current / per_page / total).",
|| to_value(schema_for!(PaginationProps)).unwrap(),
&[],
),
(
"DescriptionList",
"Key-value pairs displayed as a description list with optional format.",
|| to_value(schema_for!(DescriptionListProps)).unwrap(),
&[],
),
(
"EmptyState",
"Standardized empty view with title, description, and optional CTA.",
|| to_value(schema_for!(EmptyStateProps)).unwrap(),
&[],
),
(
"StatCard",
"Live-updatable metric card with label, value, icon, SSE target.",
|| to_value(schema_for!(StatCardProps)).unwrap(),
&[],
),
(
"Checklist",
"Onboarding-style checklist with dismissal and server-side state.",
|| to_value(schema_for!(ChecklistProps)).unwrap(),
&[],
),
(
"Toast",
"Declarative notification intent consumed by the runtime JS via data attributes.",
|| to_value(schema_for!(ToastProps)).unwrap(),
&[],
),
(
"NotificationDropdown",
"Dropdown listing notification items with icons, timestamps, read state.",
|| to_value(schema_for!(NotificationDropdownProps)).unwrap(),
&[],
),
(
"Sidebar",
"Dashboard sidebar with fixed top / bottom items and collapsible nav groups.",
|| to_value(schema_for!(SidebarProps)).unwrap(),
&[],
),
(
"Header",
"Dashboard top bar with business name, notification badge, user menu.",
|| to_value(schema_for!(HeaderProps)).unwrap(),
&[],
),
(
"DropdownMenu",
"Trigger button with an absolutely-positioned kebab-style action panel.",
|| to_value(schema_for!(DropdownMenuProps)).unwrap(),
&[],
),
(
"CalendarCell",
"Single day in a month grid with today highlight, out-of-month muting, event dots.",
|| to_value(schema_for!(CalendarCellProps)).unwrap(),
&[],
),
(
"ActionCard",
"Clickable row with icon, title, description, chevron, and variant-colored border.",
|| to_value(schema_for!(ActionCardProps)).unwrap(),
&[],
),
(
"ProductTile",
"Touch-friendly POS tile with name, price, and +/- quantity controls.",
|| to_value(schema_for!(ProductTileProps)).unwrap(),
&[],
),
(
"RawHtml",
"Server-injected HTML island. CONSUMER is responsible for sanitization — see docs/src/json-ui/plugins.md.",
|| to_value(schema_for!(RawHtmlProps)).unwrap(),
&[],
),
(
"Card",
"Content container with title, description, optional badge and subtitle, body children, and optional footer slot.",
|| to_value(schema_for!(CardProps)).unwrap(),
&["footer"],
),
(
"Modal",
"Dialog overlay with title, description, body children, and optional footer slot.",
|| to_value(schema_for!(ModalProps)).unwrap(),
&["footer"],
),
(
"Tabs",
"Tabbed content; per-tab children live in TabsProps.tabs[i].children.",
|| to_value(schema_for!(TabsProps)).unwrap(),
&[],
),
(
"KanbanBoard",
"Horizontally scrollable kanban columns on desktop, tab-switched on mobile.",
|| to_value(schema_for!(KanbanBoardProps)).unwrap(),
&[],
),
(
"PageHeader",
"Page title with optional breadcrumb and action button slot.",
|| to_value(schema_for!(PageHeaderProps)).unwrap(),
&["actions"],
),
(
"DetailPage",
"Canonical resource-detail skeleton: PageHeader chrome, optional info Card slot, and stacked body sections from Element.children.",
|| to_value(schema_for!(DetailPageProps)).unwrap(),
&["actions", "info"],
),
(
"Grid",
"Responsive multi-column grid with configurable breakpoint columns, gap, scroll.",
|| to_value(schema_for!(GridProps)).unwrap(),
&[],
),
(
"Collapsible",
"Expandable <details> / <summary> section.",
|| to_value(schema_for!(CollapsibleProps)).unwrap(),
&[],
),
(
"FormSection",
"Visual grouping within a form with title, description, and layout variant.",
|| to_value(schema_for!(FormSectionProps)).unwrap(),
&[],
),
(
"ButtonGroup",
"Horizontal button row with configurable gap.",
|| to_value(schema_for!(ButtonGroupProps)).unwrap(),
&[],
),
(
"Form",
"Form container with action binding and field components.",
|| to_value(schema_for!(FormProps)).unwrap(),
&[],
),
(
"Input",
"Text input with type variants, validation error, data_path pre-fill.",
|| to_value(schema_for!(InputProps)).unwrap(),
&[],
),
(
"Select",
"Dropdown select with options, error, data_path pre-fill.",
|| to_value(schema_for!(SelectProps)).unwrap(),
&[],
),
(
"Checkbox",
"Boolean checkbox with label, description, data binding.",
|| to_value(schema_for!(CheckboxProps)).unwrap(),
&[],
),
(
"Switch",
"Toggle switch (visual alternative to Checkbox); auto-submit when `action` set.",
|| to_value(schema_for!(SwitchProps)).unwrap(),
&[],
),
(
"CheckboxList",
"Multi-select checkbox group from static options or data-driven array. \
Each checked option submits as field=value.",
|| to_value(schema_for!(CheckboxListProps)).unwrap(),
&[],
),
(
"CheckboxGroup",
"Multi-select checkbox group (alias for CheckboxList). Each checked option \
submits as field=value with array-submit semantics. Identical props to \
CheckboxList; see that entry for full schema.",
|| to_value(schema_for!(CheckboxListProps)).unwrap(),
&[],
),
(
"Table",
"Data table with columns, row_actions, sorting, empty_message.",
|| to_value(schema_for!(TableProps)).unwrap(),
&[],
),
(
"DataTable",
"Stripe-style alternating-row table with per-row DropdownMenu and mobile card fallback.",
|| to_value(schema_for!(DataTableProps)).unwrap(),
&[],
),
(
"MediaCardGrid",
"Responsive card grid backed by a data array. Each card shows an optional screenshot image, title, description, status badge, and per-row dropdown actions.",
|| to_value(schema_for!(MediaCardGridProps)).unwrap(),
&[],
),
];
fn sanitize_schema(mut schema: Value) -> Value {
fn walk(v: &mut Value) {
if let Some(obj) = v.as_object_mut() {
if let Some(defs) = obj.remove("definitions") {
obj.entry("$defs".to_string()).or_insert(defs);
}
if let Some(Value::String(ref_str)) = obj.get_mut("$ref") {
if let Some(suffix) = ref_str.strip_prefix("#/definitions/") {
*ref_str = format!("#/$defs/{suffix}");
}
}
let keys: Vec<String> = obj.keys().cloned().collect();
for k in keys {
if let Some(child) = obj.get_mut(&k) {
walk(child);
}
}
} else if let Some(arr) = v.as_array_mut() {
for item in arr.iter_mut() {
walk(item);
}
}
}
walk(&mut schema);
schema
}
fn hoist_defs(schema: &mut Value, shared_defs: &mut serde_json::Map<String, Value>) {
if let Some(obj) = schema.as_object_mut() {
if let Some(Value::Object(defs)) = obj.remove("$defs") {
for (k, v) in defs {
shared_defs.entry(k).or_insert(v);
}
}
}
}
fn assemble_full_schema(per_component: &HashMap<String, Value>) -> Result<Value, CatalogError> {
let mut action_schema = sanitize_schema(to_value(schema_for!(crate::action::Action))?);
let mut visibility_schema =
sanitize_schema(to_value(schema_for!(crate::visibility::Visibility))?);
let mut shared_defs: serde_json::Map<String, Value> = serde_json::Map::new();
hoist_defs(&mut action_schema, &mut shared_defs);
hoist_defs(&mut visibility_schema, &mut shared_defs);
let mut names: Vec<&String> = per_component.keys().collect();
names.sort();
let one_of: Vec<Value> = names
.into_iter()
.map(|name| {
let mut props_schema = per_component[name].clone();
hoist_defs(&mut props_schema, &mut shared_defs);
serde_json::json!({
"allOf": [
{
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": name }
}
},
{
"type": "object",
"properties": {
"props": props_schema,
"children": { "type": "array", "items": { "type": "string" } },
"action": { "$ref": "#/$defs/Action" },
"visible": { "$ref": "#/$defs/Visibility" }
}
}
]
})
})
.collect();
shared_defs
.entry("Action".to_string())
.or_insert(action_schema);
shared_defs
.entry("Visibility".to_string())
.or_insert(visibility_schema);
shared_defs.insert(
"Element".to_string(),
serde_json::json!({ "oneOf": one_of }),
);
Ok(serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "ferro-json-ui/v2",
"type": "object",
"required": ["$schema", "root", "elements"],
"properties": {
"$schema": { "const": "ferro-json-ui/v2" },
"root": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,127}$" },
"elements": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/Element" }
},
"title": { "type": ["string", "null"] },
"layout": { "type": ["string", "null"] },
"data": true
},
"$defs": shared_defs
}))
}
impl Catalog {
pub fn build() -> Result<Self, CatalogError> {
if BUILTIN_SPECS.len() != crate::render::BUILTIN_TYPES.len() {
return Err(CatalogError::BuildFailed(format!(
"BUILTIN_SPECS has {} entries but BUILTIN_TYPES has {} — \
add an entry to BUILTIN_SPECS or remove from BUILTIN_TYPES",
BUILTIN_SPECS.len(),
crate::render::BUILTIN_TYPES.len(),
)));
}
let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len() * 2);
for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
let raw = schema_fn();
let schema = sanitize_schema(raw);
per_component_schemas.insert((*name).to_string(), schema.clone());
components.insert(
(*name).to_string(),
ComponentSpec {
name: (*name).to_string(),
description: (*desc).to_string(),
props_schema: schema,
is_plugin: false,
slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
},
);
}
let mut plugin_components = HashMap::new();
for plugin_type in crate::plugin::registered_plugin_types() {
if components.contains_key(&plugin_type) {
continue;
}
let raw = crate::plugin::with_plugin(&plugin_type, |p| p.props_schema())
.unwrap_or(Value::Null);
let schema = sanitize_schema(raw);
if jsonschema::validator_for(&schema).is_err() {
return Err(CatalogError::BuildFailed(format!(
"plugin '{plugin_type}' returned an invalid JSON Schema"
)));
}
per_component_schemas.insert(plugin_type.clone(), schema.clone());
plugin_components.insert(
plugin_type.clone(),
ComponentSpec {
name: plugin_type,
description: String::from("Plugin component."),
props_schema: schema,
is_plugin: true,
slot_fields: Vec::new(),
},
);
}
let full_schema = assemble_full_schema(&per_component_schemas)?;
let validator = jsonschema::validator_for(&full_schema)
.map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
Ok(Catalog {
components,
plugin_components,
full_schema,
per_component_schemas,
validator,
})
}
pub fn json_schema(&self) -> &Value {
&self.full_schema
}
pub fn validate(&self, spec: &crate::spec::Spec) -> Result<(), Vec<CatalogError>> {
let mut errors: Vec<CatalogError> = Vec::new();
for (id, el) in &spec.elements {
let known = self.components.contains_key(&el.type_name)
|| self.plugin_components.contains_key(&el.type_name);
if !known {
errors.push(CatalogError::UnknownType {
element_id: id.clone(),
type_name: el.type_name.clone(),
});
}
}
if !errors.is_empty() {
return Err(errors);
}
for (id, el) in &spec.elements {
if let Some(schema) = self.per_component_schemas.get(&el.type_name) {
if el.props.is_null() {
continue;
}
let v = match jsonschema::validator_for(schema) {
Ok(v) => v,
Err(e) => {
errors.push(CatalogError::BuildFailed(format!(
"compiling per-component schema for '{}': {e}",
el.type_name
)));
continue;
}
};
let validation_props = strip_expr_objects(&el.props);
let mut per_elem_errs: Vec<String> = Vec::new();
for err in v.iter_errors(&validation_props) {
per_elem_errs.push(format!("{}: {}", err.instance_path(), err));
}
if !per_elem_errs.is_empty() {
errors.push(CatalogError::PropsInvalid {
element_id: id.clone(),
type_name: el.type_name.clone(),
errors: per_elem_errs,
});
}
}
}
let spec_value = match serde_json::to_value(spec) {
Ok(v) => v,
Err(e) => {
errors.push(CatalogError::SchemaSerialization(e));
return Err(errors);
}
};
let stripped_spec_value = strip_expr_objects(&spec_value);
let mut envelope_errs: Vec<String> = Vec::new();
for err in self.validator.iter_errors(&stripped_spec_value) {
envelope_errs.push(format!("{}: {}", err.instance_path(), err));
}
if !envelope_errs.is_empty() {
errors.push(CatalogError::SpecInvalid {
errors: envelope_errs,
});
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub fn component_schema(&self, type_name: &str) -> Option<&Value> {
self.per_component_schemas.get(type_name)
}
pub fn components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
let mut entries: Vec<&ComponentSpec> = self.components.values().collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
entries.into_iter()
}
pub fn plugin_components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
let mut entries: Vec<&ComponentSpec> = self.plugin_components.values().collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
entries.into_iter()
}
pub fn prompt(&self) -> String {
let mut out = String::with_capacity(8 * 1024);
out.push_str("## Component Catalog\n\n");
for spec in self.components_sorted() {
render_component_section(&mut out, spec);
}
if self.plugin_components.is_empty() {
return out;
}
out.push_str("## Plugin Components\n\n");
for spec in self.plugin_components_sorted() {
render_component_section(&mut out, spec);
}
out
}
}
fn render_component_section(out: &mut String, spec: &ComponentSpec) {
out.push_str("### ");
out.push_str(&spec.name);
out.push('\n');
out.push_str(&spec.description);
out.push('\n');
let props_line = render_props_line(&spec.props_schema);
if !props_line.is_empty() {
out.push_str("Props: ");
out.push_str(&props_line);
out.push('\n');
}
if !spec.slot_fields.is_empty() {
out.push_str("Slots: ");
out.push_str(&spec.slot_fields.join(", "));
out.push_str(" (Vec<String> of element IDs) — body children come from Element.children.\n");
}
out.push('\n');
}
fn render_props_line(schema: &Value) -> String {
let Some(obj) = schema.as_object() else {
return String::new();
};
let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
return String::new();
};
let required: std::collections::HashSet<&str> = obj
.get("required")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.collect::<std::collections::HashSet<_>>()
})
.unwrap_or_default();
let parts: Vec<String> = props
.iter()
.map(|(name, field_schema)| {
let ty = render_field_type(field_schema, required.contains(name.as_str()));
format!("{name} ({ty})")
})
.collect();
parts.join(", ")
}
fn render_field_type(schema: &Value, is_required: bool) -> String {
if let Some(variants) = schema.get("enum").and_then(|v| v.as_array()) {
let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
let inner = render_enum_inline(&names);
return wrap_optional(inner, is_required);
}
for key in ["anyOf", "oneOf"] {
if let Some(arr) = schema.get(key).and_then(|v| v.as_array()) {
let has_null = arr
.iter()
.any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
let non_null: Vec<&Value> = arr
.iter()
.filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
.collect();
if has_null && non_null.len() == 1 {
let inner = render_field_type(non_null[0], true);
return format!("Option<{inner}>");
}
}
}
if let Some(types) = schema.get("type").and_then(|v| v.as_array()) {
let non_null: Vec<&str> = types
.iter()
.filter_map(|v| v.as_str())
.filter(|s| *s != "null")
.collect();
let has_null = types.iter().any(|v| v.as_str() == Some("null"));
if has_null && non_null.len() == 1 {
return format!("Option<{}>", rust_for_json_type(non_null[0], schema));
}
}
if let Some(t) = schema.get("type").and_then(|v| v.as_str()) {
let inner = rust_for_json_type(t, schema);
return wrap_optional(inner, is_required);
}
wrap_optional("<see schema>".to_string(), is_required)
}
fn rust_for_json_type(t: &str, schema: &Value) -> String {
match t {
"string" => "String".to_string(),
"integer" => "i64".to_string(),
"number" => "f64".to_string(),
"boolean" => "bool".to_string(),
"array" => {
if let Some(items) = schema.get("items") {
let inner = render_field_type(items, true);
format!("Vec<{inner}>")
} else {
"Vec<Value>".to_string()
}
}
"object" => "Object".to_string(),
other => other.to_string(),
}
}
fn render_enum_inline(variants: &[&str]) -> String {
if variants.len() <= 8 {
variants.join("|")
} else {
format!("one of {} — see schema", variants.len())
}
}
fn wrap_optional(inner: String, is_required: bool) -> String {
if is_required {
inner
} else {
format!("Option<{inner}>")
}
}
fn strip_expr_objects(val: &Value) -> Value {
match val {
Value::Object(map) => {
if map.len() == 1 && (map.contains_key("$data") || map.contains_key("$template")) {
Value::String(String::new())
} else {
Value::Object(
map.iter()
.map(|(k, v)| (k.clone(), strip_expr_objects(v)))
.collect(),
)
}
}
Value::Array(arr) => Value::Array(arr.iter().map(strip_expr_objects).collect()),
other => other.clone(),
}
}
pub fn global_catalog() -> &'static Catalog {
static GLOBAL_CATALOG: OnceLock<Catalog> = OnceLock::new();
GLOBAL_CATALOG.get_or_init(|| {
Catalog::build().expect("catalog build failed — see CatalogError for details")
})
}
#[cfg(test)]
impl Catalog {
pub(crate) fn build_builtins_only() -> Result<Self, CatalogError> {
let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len());
for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
let raw = schema_fn();
let schema = sanitize_schema(raw);
per_component_schemas.insert((*name).to_string(), schema.clone());
components.insert(
(*name).to_string(),
ComponentSpec {
name: (*name).to_string(),
description: (*desc).to_string(),
props_schema: schema,
is_plugin: false,
slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
},
);
}
let full_schema = assemble_full_schema(&per_component_schemas)?;
let validator = jsonschema::validator_for(&full_schema)
.map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
Ok(Catalog {
components,
plugin_components: HashMap::new(),
full_schema,
per_component_schemas,
validator,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_types_count_is_39() {
assert_eq!(crate::render::BUILTIN_TYPES.len(), 44);
}
#[test]
fn builtin_specs_len_matches_dispatch() {
assert_eq!(BUILTIN_SPECS.len(), crate::render::BUILTIN_TYPES.len());
assert_eq!(BUILTIN_SPECS.len(), 44);
}
#[test]
fn builtin_specs_names_match_dispatch() {
use std::collections::HashSet;
let specs: HashSet<&str> = BUILTIN_SPECS.iter().map(|(n, ..)| *n).collect();
let types: HashSet<&str> = crate::render::BUILTIN_TYPES.iter().copied().collect();
assert_eq!(specs, types, "BUILTIN_SPECS names must match BUILTIN_TYPES");
}
#[test]
fn build_populates_all_builtins() {
let cat = Catalog::build_builtins_only().expect("build succeeds");
for name in crate::render::BUILTIN_TYPES.iter() {
assert!(
cat.components.contains_key(*name),
"built-in '{name}' missing from catalog.components"
);
let spec = &cat.components[*name];
assert_eq!(spec.name, *name);
assert!(
!spec.description.is_empty(),
"'{name}' has empty description"
);
assert!(
spec.props_schema.is_object(),
"'{name}' props_schema is not a JSON object"
);
assert!(!spec.is_plugin);
}
}
#[test]
fn build_card_has_footer_slot() {
let cat = Catalog::build_builtins_only().expect("build succeeds");
let card = &cat.components["Card"];
assert_eq!(card.slot_fields, vec!["footer"]);
}
#[test]
fn build_modal_has_footer_slot() {
let cat = Catalog::build_builtins_only().expect("build succeeds");
let modal = &cat.components["Modal"];
assert_eq!(modal.slot_fields, vec!["footer"]);
}
#[test]
fn build_pageheader_has_actions_slot() {
let cat = Catalog::build_builtins_only().expect("build succeeds");
let ph = &cat.components["PageHeader"];
assert_eq!(ph.slot_fields, vec!["actions"]);
}
#[test]
fn build_text_has_no_slots() {
let cat = Catalog::build_builtins_only().expect("build succeeds");
assert!(cat.components["Text"].slot_fields.is_empty());
}
#[test]
fn build_populates_per_component_schemas() {
let cat = Catalog::build_builtins_only().expect("build succeeds");
assert_eq!(
cat.per_component_schemas.len(),
BUILTIN_SPECS.len() + cat.plugin_components.len()
);
}
#[test]
fn sanitize_schema_rewrites_definitions_to_dollar_defs() {
let raw = serde_json::json!({
"type": "object",
"definitions": { "Foo": { "type": "string" } },
"properties": {
"x": { "$ref": "#/definitions/Foo" }
}
});
let out = sanitize_schema(raw);
assert!(out.get("definitions").is_none());
assert!(out.get("$defs").is_some());
assert_eq!(
out["properties"]["x"]["$ref"].as_str().unwrap(),
"#/$defs/Foo"
);
}
#[test]
fn sanitize_schema_is_idempotent() {
let raw = serde_json::json!({
"type": "object",
"$defs": { "Foo": { "type": "string" } },
"properties": {
"x": { "$ref": "#/$defs/Foo" }
}
});
let once = sanitize_schema(raw.clone());
let twice = sanitize_schema(once.clone());
assert_eq!(once, twice);
assert!(twice.get("definitions").is_none());
assert!(twice.get("$defs").is_some());
}
#[test]
fn json_schema_has_spec_envelope_shape() {
let cat = Catalog::build_builtins_only().expect("build");
let schema = cat.json_schema();
assert_eq!(schema["$id"], "ferro-json-ui/v2");
assert_eq!(schema["type"], "object");
let required: Vec<&str> = schema["required"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(required.contains(&"$schema"));
assert!(required.contains(&"root"));
assert!(required.contains(&"elements"));
}
#[test]
fn json_schema_has_action_and_visibility_defs() {
let cat = Catalog::build_builtins_only().expect("build");
let schema = cat.json_schema();
assert!(
schema["$defs"]["Action"].is_object(),
"$defs/Action missing"
);
assert!(
schema["$defs"]["Visibility"].is_object(),
"$defs/Visibility missing"
);
assert!(
schema["$defs"]["Element"].is_object(),
"$defs/Element missing"
);
}
#[test]
fn json_schema_oneof_covers_all_builtins() {
let cat = Catalog::build_builtins_only().expect("build");
let schema = cat.json_schema();
let one_of = schema["$defs"]["Element"]["oneOf"]
.as_array()
.expect("Element.oneOf is an array");
let mut discriminators: std::collections::HashSet<String> =
std::collections::HashSet::new();
for variant in one_of {
let c = variant["allOf"][0]["properties"]["type"]["const"]
.as_str()
.expect("every variant pins a type const");
discriminators.insert(c.to_string());
}
for name in crate::render::BUILTIN_TYPES.iter() {
assert!(
discriminators.contains(*name),
"oneOf is missing discriminator for '{name}'"
);
}
assert_eq!(
discriminators.len(),
crate::render::BUILTIN_TYPES.len(),
"oneOf variant count mismatch"
);
}
#[test]
fn json_schema_is_valid() {
use jsonschema::draft202012;
let cat = Catalog::build_builtins_only().expect("build");
let schema = cat.json_schema();
assert!(
draft202012::meta::is_valid(schema),
"assembled full_schema did not meta-validate as Draft 2020-12"
);
}
#[test]
fn validator_is_compiled_once_and_usable() {
let cat = Catalog::build_builtins_only().expect("build");
let minimal_valid = serde_json::json!({
"$schema": "ferro-json-ui/v2",
"root": "r",
"elements": {
"r": { "type": "Text", "props": { "content": "hi" } }
}
});
assert!(cat.validator.is_valid(&minimal_valid));
}
#[test]
fn validator_rejects_wrong_schema_version() {
let cat = Catalog::build_builtins_only().expect("build");
let wrong_version = serde_json::json!({
"$schema": "ferro-json-ui/v99-wrong",
"root": "r",
"elements": {
"r": { "type": "Text", "props": { "content": "hi" } }
}
});
assert!(
!cat.validator.is_valid(&wrong_version),
"validator should reject unknown $schema version via const"
);
}
#[test]
fn oneof_variants_are_deterministic_sorted() {
let cat1 = Catalog::build_builtins_only().expect("build 1");
let cat2 = Catalog::build_builtins_only().expect("build 2");
assert_eq!(
serde_json::to_string(cat1.json_schema()).unwrap(),
serde_json::to_string(cat2.json_schema()).unwrap()
);
}
fn test_spec_with(type_name: &str, props: Value) -> crate::spec::Spec {
use crate::spec::{Element, Spec};
use std::collections::HashMap;
let mut elements = HashMap::new();
elements.insert(
"r".to_string(),
Element {
type_name: type_name.to_string(),
props,
children: Vec::new(),
action: None,
visible: None,
each: None,
if_: None,
},
);
Spec {
schema: crate::spec::SCHEMA_VERSION.to_string(),
root: "r".to_string(),
elements,
title: None,
layout: None,
data: Value::Null,
}
}
#[test]
fn validate_positive_per_type() {
let cat = Catalog::build_builtins_only().expect("build");
let cases: Vec<(&str, Value)> = vec![
("Text", serde_json::json!({ "content": "hi" })),
("Button", serde_json::json!({ "label": "Save" })),
("Badge", serde_json::json!({ "label": "New" })),
("Separator", serde_json::json!({})),
];
for (ty, props) in cases {
let spec = test_spec_with(ty, props.clone());
match cat.validate(&spec) {
Ok(()) => {}
Err(errs) => panic!("validate({ty}) failed: {errs:?}"),
}
}
}
#[test]
fn validate_unknown_type() {
let cat = Catalog::build_builtins_only().expect("build");
let spec = test_spec_with("NotARealComponent", serde_json::json!({}));
let errs = cat.validate(&spec).expect_err("should fail");
assert!(
errs.iter().any(|e| matches!(
e,
CatalogError::UnknownType { type_name, .. } if type_name == "NotARealComponent"
)),
"expected UnknownType for NotARealComponent; got {errs:?}"
);
}
#[test]
fn validate_missing_required_prop() {
let cat = Catalog::build_builtins_only().expect("build");
let spec = test_spec_with("Card", serde_json::json!({}));
let errs = cat.validate(&spec).expect_err("should fail");
assert!(
errs.iter().any(|e| matches!(
e,
CatalogError::PropsInvalid { type_name, .. } if type_name == "Card"
)),
"expected PropsInvalid for missing required 'title'; got {errs:?}"
);
}
#[test]
fn validate_bad_schema_version() {
let cat = Catalog::build_builtins_only().expect("build");
let mut spec = test_spec_with("Text", serde_json::json!({ "content": "hi" }));
spec.schema = "ferro-json-ui/v99-wrong".to_string();
let errs = cat.validate(&spec).expect_err("should fail");
assert!(
errs.iter()
.any(|e| matches!(e, CatalogError::SpecInvalid { .. })),
"expected SpecInvalid for wrong $schema version; got {errs:?}"
);
}
#[test]
fn validate_pre_dispatch_short_circuits() {
let cat = Catalog::build_builtins_only().expect("build");
let mut spec = test_spec_with("NotARealComponent", serde_json::json!({}));
spec.schema = "ferro-json-ui/v99-wrong".to_string();
let errs = cat.validate(&spec).expect_err("should fail");
let has_unknown = errs
.iter()
.any(|e| matches!(e, CatalogError::UnknownType { .. }));
let has_spec_invalid = errs
.iter()
.any(|e| matches!(e, CatalogError::SpecInvalid { .. }));
let has_props_invalid = errs
.iter()
.any(|e| matches!(e, CatalogError::PropsInvalid { .. }));
assert!(has_unknown, "expected UnknownType");
assert!(
!has_spec_invalid,
"Stage 3 ran despite Stage 1 failing: {errs:?}"
);
assert!(
!has_props_invalid,
"Stage 2 ran despite Stage 1 failing: {errs:?}"
);
}
#[test]
fn validator_is_cached_not_recompiled() {
let cat = Catalog::build_builtins_only().expect("build");
for _ in 0..100 {
let spec = test_spec_with("Text", serde_json::json!({ "content": "x" }));
assert!(cat.validate(&spec).is_ok());
}
}
#[test]
fn validate_accumulates_multiple_errors_across_elements() {
use crate::spec::{Element, Spec};
use std::collections::HashMap;
let cat = Catalog::build_builtins_only().expect("build");
let mut elements = HashMap::new();
elements.insert(
"a".to_string(),
Element {
type_name: "Card".to_string(),
props: serde_json::json!({}), children: Vec::new(),
action: None,
visible: None,
each: None,
if_: None,
},
);
elements.insert(
"b".to_string(),
Element {
type_name: "Button".to_string(),
props: serde_json::json!({}), children: Vec::new(),
action: None,
visible: None,
each: None,
if_: None,
},
);
let spec = Spec {
schema: crate::spec::SCHEMA_VERSION.to_string(),
root: "a".to_string(),
elements,
title: None,
layout: None,
data: Value::Null,
};
let errs = cat.validate(&spec).expect_err("should fail");
let props_invalid_count = errs
.iter()
.filter(|e| matches!(e, CatalogError::PropsInvalid { .. }))
.count();
assert!(
props_invalid_count >= 2,
"expected at least 2 PropsInvalid errors; got {errs:?}"
);
}
#[test]
fn build_discovers_plugins_and_rejects_invalid_schema() {
use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
struct GoodPlugin;
impl JsonUiPlugin for GoodPlugin {
fn component_type(&self) -> &str {
"GoodPlugin_117"
}
fn props_schema(&self) -> Value {
serde_json::json!({ "type": "object" })
}
fn render(&self, _: &Value, _: &Value) -> String {
String::new()
}
fn css_assets(&self) -> Vec<Asset> {
vec![]
}
fn js_assets(&self) -> Vec<Asset> {
vec![]
}
fn init_script(&self) -> Option<String> {
None
}
}
register_plugin(GoodPlugin);
let cat = Catalog::build().expect("build succeeds with valid plugin only");
assert!(
cat.plugin_components.contains_key("GoodPlugin_117"),
"plugin 'GoodPlugin_117' should have been discovered"
);
assert!(cat.plugin_components["GoodPlugin_117"].is_plugin);
struct BadPlugin;
impl JsonUiPlugin for BadPlugin {
fn component_type(&self) -> &str {
"BadPlugin_117"
}
fn props_schema(&self) -> Value {
serde_json::json!({ "type": 42 })
}
fn render(&self, _: &Value, _: &Value) -> String {
String::new()
}
fn css_assets(&self) -> Vec<Asset> {
vec![]
}
fn js_assets(&self) -> Vec<Asset> {
vec![]
}
fn init_script(&self) -> Option<String> {
None
}
}
register_plugin(BadPlugin);
match Catalog::build() {
Err(CatalogError::BuildFailed(msg)) => {
assert!(
msg.contains("BadPlugin_117"),
"error should mention plugin name, got: {msg}"
);
}
Err(other) => panic!("expected BuildFailed mentioning BadPlugin_117, got: {other:?}"),
Ok(_) => panic!("expected build to fail due to invalid plugin schema"),
}
}
#[test]
fn component_schema_returns_props_only() {
let cat = Catalog::build_builtins_only().expect("build");
let schema = cat
.component_schema("Card")
.expect("Card is a built-in component");
let obj = schema
.as_object()
.expect("Card props schema is a JSON object");
assert!(
obj.contains_key("type") || obj.contains_key("oneOf") || obj.contains_key("anyOf"),
"CardProps schema should be a structural object schema; got {obj:?}"
);
if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
assert!(
props.contains_key("title"),
"CardProps schema.properties should include 'title'; got keys: {:?}",
props.keys().collect::<Vec<_>>()
);
} else {
panic!(
"CardProps schema missing top-level 'properties' map — \
sanitizer or Plan 02 may be wrong. Got: {}",
serde_json::to_string_pretty(schema).unwrap_or_default()
);
}
let is_element_wrapper = obj
.get("properties")
.and_then(|v| v.as_object())
.map(|p| p.contains_key("children") && p.contains_key("props"))
.unwrap_or(false);
assert!(
!is_element_wrapper,
"component_schema('Card') returned an Element wrapper; must be Props-only (CONTEXT D-19)"
);
}
#[test]
fn component_schema_none_for_unknown() {
let cat = Catalog::build_builtins_only().expect("build");
assert!(
cat.component_schema("NotARealComponent_117_05").is_none(),
"unknown component must return None"
);
assert!(cat.component_schema("").is_none());
}
#[test]
fn component_schema_resolves_every_builtin() {
let cat = Catalog::build_builtins_only().expect("build");
for name in crate::render::BUILTIN_TYPES.iter() {
assert!(
cat.component_schema(name).is_some(),
"built-in '{name}' has no per-component schema"
);
}
}
#[test]
fn components_sorted_yields_ascending_by_name() {
let cat = Catalog::build_builtins_only().expect("build");
let names: Vec<String> = cat
.components_sorted()
.map(|spec| spec.name.clone())
.collect();
assert_eq!(names.len(), crate::render::BUILTIN_TYPES.len());
let mut sorted = names.clone();
sorted.sort();
assert_eq!(
names, sorted,
"components_sorted must yield ascending order"
);
let plugin_names: Vec<String> = cat
.plugin_components_sorted()
.map(|spec| spec.name.clone())
.collect();
let mut plugin_sorted = plugin_names.clone();
plugin_sorted.sort();
assert_eq!(
plugin_names, plugin_sorted,
"plugin_components_sorted must yield ascending order"
);
}
#[test]
fn prompt_under_size_budget() {
let cat = Catalog::build_builtins_only().expect("build");
let prompt = cat.prompt();
let bytes = prompt.len();
assert!(
bytes <= 10 * 1024,
"prompt() is {bytes} bytes, exceeds 10 KB budget (CONTEXT D-17)"
);
}
#[test]
fn prompt_mentions_every_builtin() {
let cat = Catalog::build_builtins_only().expect("build");
let prompt = cat.prompt();
for name in crate::render::BUILTIN_TYPES.iter() {
let heading = format!("### {name}\n");
assert!(
prompt.contains(&heading),
"prompt() missing section heading for '{name}'"
);
}
}
#[test]
fn prompt_is_deterministic() {
let cat1 = Catalog::build_builtins_only().expect("build 1");
let cat2 = Catalog::build_builtins_only().expect("build 2");
assert_eq!(
cat1.prompt(),
cat2.prompt(),
"prompt() must be deterministic"
);
}
#[test]
fn prompt_documents_slot_fields() {
let cat = Catalog::build_builtins_only().expect("build");
let prompt = cat.prompt();
let card_start = prompt.find("### Card\n").expect("Card section present");
let card_slice = &prompt[card_start..];
let end = card_slice[3..]
.find("### ")
.map(|i| i + 3)
.unwrap_or(card_slice.len());
let card_section = &card_slice[..end];
assert!(
card_section.contains("Slots: footer"),
"Card section missing 'Slots: footer' line:\n{card_section}"
);
}
#[test]
fn prompt_is_not_raw_json_schema() {
let cat = Catalog::build_builtins_only().expect("build");
let prompt = cat.prompt();
assert!(
prompt.starts_with("## Component Catalog"),
"prompt() should start with Markdown header, not JSON"
);
assert!(
!prompt.contains("\"$schema\""),
"prompt() must not embed raw JSON Schema (ROADMAP caveat)"
);
}
#[test]
fn catalog_contains_checkbox_group() {
let cat = Catalog::build_builtins_only().expect("build");
assert!(
cat.component_schema("CheckboxGroup").is_some(),
"CheckboxGroup must be registered in BUILTIN_SPECS as an alias for CheckboxList"
);
}
}