Skip to main content

ferro_json_ui/
catalog.rs

1//! # Component Catalog
2//!
3//! Machine-readable registry of every built-in and plugin JSON-UI component.
4//!
5//! Phase 117 replaces the hand-maintained component reference string with
6//! this auto-derived catalog that reads per-component JSON Schema from the
7//! `#[derive(JsonSchema)]` attributes already present on every `*Props` struct
8//! (Phase 115). The Catalog pre-computes five artifacts at build time:
9//!
10//! - `components` — built-in component specs keyed by type name (D-01, D-03)
11//! - `plugin_components` — plugin specs pulled from the global registry (D-08)
12//! - `full_schema` — assembled JSON Schema document for the full Spec shape (D-13)
13//! - `per_component_schemas` — per-component Props schemas for targeted validation (D-12)
14//! - `validator` — a `jsonschema::Validator` compiled once from `full_schema` (D-12)
15//!
16//! Consumers access the singleton via [`global_catalog()`] (D-04). The catalog is
17//! frozen after first construction; late-registered plugins do not propagate (D-04).
18//!
19//! See CONTEXT D-01..D-04, D-11 for the full design rationale.
20//!
21//! Plan 02 populates `BUILTIN_SPECS` and implements `Catalog::build()` fully.
22
23use std::collections::HashMap;
24use std::sync::OnceLock;
25
26use schemars::schema_for;
27use serde_json::{to_value, Value};
28
29use crate::component::{
30    ActionCardProps, AlertProps, AvatarProps, BadgeProps, BreadcrumbProps, ButtonGroupProps,
31    ButtonProps, CalendarCellProps, CardProps, CheckboxListProps, CheckboxProps, ChecklistProps,
32    CollapsibleProps, DataTableProps, DescriptionListProps, DetailPageProps, DropdownMenuProps,
33    EmptyStateProps, FormProps, FormSectionProps, GridProps, HeaderProps, ImageProps, InputProps,
34    KanbanBoardProps, MediaCardGridProps, ModalProps, NotificationDropdownProps, PageHeaderProps,
35    PaginationProps, ProductTileProps, ProgressProps, RawHtmlProps, SelectProps, SeparatorProps,
36    SidebarProps, SkeletonProps, StatCardProps, SwitchProps, TableProps, TabsProps, TextProps,
37    ToastProps,
38};
39
40// ── Public types ───────────────────────────────────────────────────────────────
41
42/// Metadata and JSON Schema for a single JSON-UI component.
43///
44/// Built by [`Catalog::build`] from the static `BUILTIN_SPECS` table (built-ins)
45/// or from [`crate::plugin::JsonUiPlugin::props_schema`] (plugins).
46pub struct ComponentSpec {
47    /// Component type name as it appears in the Spec's `"type"` field.
48    pub name: String,
49    /// Short imperative description used in prompt output and catalog tooling.
50    pub description: String,
51    /// JSON Schema object for the component's Props struct (schemars output).
52    pub props_schema: Value,
53    /// `true` for plugin components; `false` for built-ins.
54    pub is_plugin: bool,
55    /// Names of fields whose values are `Vec<String>` of element IDs (slot fields).
56    ///
57    /// Examples: `["footer"]` for Card, `["children"]` for Tabs' per-tab model,
58    /// `[]` for leaf components with no children slots.
59    pub slot_fields: Vec<String>,
60}
61
62/// Pre-computed, immutable registry of all JSON-UI components and their schemas.
63///
64/// Constructed once via [`Catalog::build`] and accessed globally through
65/// [`global_catalog`]. All fields are `pub(crate)` — external callers use the
66/// accessor methods added in Plans 02–05.
67pub struct Catalog {
68    /// Built-in components keyed by type name.
69    pub(crate) components: HashMap<String, ComponentSpec>,
70    /// Plugin components keyed by type name.
71    pub(crate) plugin_components: HashMap<String, ComponentSpec>,
72    /// Full Spec JSON Schema document (root + elements + oneOf over all components).
73    pub(crate) full_schema: Value,
74    /// Per-component Props schemas keyed by type name.
75    pub(crate) per_component_schemas: HashMap<String, Value>,
76    /// Compiled validator over `full_schema`. Reused across `validate()` calls.
77    pub(crate) validator: jsonschema::Validator,
78}
79
80/// Errors that can occur during catalog construction or spec validation.
81#[derive(Debug, thiserror::Error)]
82pub enum CatalogError {
83    /// A Spec element references a component type not in the catalog.
84    #[error("unknown component type '{type_name}' at element '{element_id}'")]
85    UnknownType {
86        /// The element's ID field.
87        element_id: String,
88        /// The unrecognized `"type"` value.
89        type_name: String,
90    },
91    /// An element's `props` object fails JSON Schema validation for its component.
92    #[error("props invalid for '{type_name}' at element '{element_id}': {errors:?}")]
93    PropsInvalid {
94        /// The element's ID field.
95        element_id: String,
96        /// The component type that owns the failing props.
97        type_name: String,
98        /// Human-readable validation error messages from the jsonschema crate.
99        errors: Vec<String>,
100    },
101    /// The full Spec fails the top-level JSON Schema (missing `$schema`, bad `root`, etc.).
102    #[error("spec invalid: {errors:?}")]
103    SpecInvalid {
104        /// Human-readable validation error messages.
105        errors: Vec<String>,
106    },
107    /// Catalog construction failed (e.g., a plugin returned an invalid JSON Schema).
108    #[error("catalog build failed: {0}")]
109    BuildFailed(String),
110    /// JSON serialization error during schema assembly.
111    #[error("schema serialization error: {0}")]
112    SchemaSerialization(#[from] serde_json::Error),
113}
114
115// ── Static built-in table ─────────────────────────────────────────────────────
116
117type SchemaFn = fn() -> Value;
118
119/// `(type_name, description, schema_fn, slot_fields)`
120///
121/// Descriptions per CONTEXT D-06. Slot fields per Phase 116 / CONTEXT D-05.
122/// Order MUST match `crate::render::BUILTIN_TYPES` exactly (see drift guard in
123/// `Catalog::build`).
124static BUILTIN_SPECS: &[(&str, &str, SchemaFn, &[&str])] = &[
125    // === Leaves (atoms.rs) ===
126    (
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 variant-styled label.",
141        || to_value(schema_for!(BadgeProps)).unwrap(),
142        &[],
143    ),
144    (
145        "Alert",
146        "Inline notice with info / success / warning / error variants.",
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        "DropdownMenu",
242        "Trigger button with an absolutely-positioned kebab-style action panel.",
243        || to_value(schema_for!(DropdownMenuProps)).unwrap(),
244        &[],
245    ),
246    (
247        "CalendarCell",
248        "Single day in a month grid with today highlight, out-of-month muting, event dots.",
249        || to_value(schema_for!(CalendarCellProps)).unwrap(),
250        &[],
251    ),
252    (
253        "ActionCard",
254        "Clickable row with icon, title, description, chevron, and variant-colored border.",
255        || to_value(schema_for!(ActionCardProps)).unwrap(),
256        &[],
257    ),
258    (
259        "ProductTile",
260        "Touch-friendly POS tile with name, price, and +/- quantity controls.",
261        || to_value(schema_for!(ProductTileProps)).unwrap(),
262        &[],
263    ),
264    (
265        "RawHtml",
266        "Server-injected HTML island. CONSUMER is responsible for sanitization — see docs/src/json-ui/plugins.md.",
267        || to_value(schema_for!(RawHtmlProps)).unwrap(),
268        &[],
269    ),
270    // === Containers (containers.rs) ===
271    (
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    // === Form controls (form.rs) ===
332    (
333        "Form",
334        "Form container with action binding and field components.",
335        || to_value(schema_for!(FormProps)).unwrap(),
336        &[],
337    ),
338    (
339        "Input",
340        "Text input with type variants, validation error, data_path pre-fill.",
341        || to_value(schema_for!(InputProps)).unwrap(),
342        &[],
343    ),
344    (
345        "Select",
346        "Dropdown select with options, error, data_path pre-fill.",
347        || to_value(schema_for!(SelectProps)).unwrap(),
348        &[],
349    ),
350    (
351        "Checkbox",
352        "Boolean checkbox with label, description, data binding.",
353        || to_value(schema_for!(CheckboxProps)).unwrap(),
354        &[],
355    ),
356    (
357        "Switch",
358        "Toggle switch (visual alternative to Checkbox); auto-submit when `action` set.",
359        || to_value(schema_for!(SwitchProps)).unwrap(),
360        &[],
361    ),
362    (
363        "CheckboxList",
364        "Multi-select checkbox group from static options or data-driven array. \
365         Each checked option submits as field=value.",
366        || to_value(schema_for!(CheckboxListProps)).unwrap(),
367        &[],
368    ),
369    (
370        "CheckboxGroup",
371        "Multi-select checkbox group (alias for CheckboxList). Each checked option \
372         submits as field=value with array-submit semantics. Identical props to \
373         CheckboxList; see that entry for full schema.",
374        || to_value(schema_for!(CheckboxListProps)).unwrap(),
375        &[],
376    ),
377    // === Data displays (data.rs) ===
378    (
379        "Table",
380        "Data table with columns, row_actions, sorting, empty_message.",
381        || to_value(schema_for!(TableProps)).unwrap(),
382        &[],
383    ),
384    (
385        "DataTable",
386        "Stripe-style alternating-row table with per-row DropdownMenu and mobile card fallback.",
387        || to_value(schema_for!(DataTableProps)).unwrap(),
388        &[],
389    ),
390    (
391        "MediaCardGrid",
392        "Responsive card grid backed by a data array. Each card shows an optional screenshot image, title, description, status badge, and per-row dropdown actions.",
393        || to_value(schema_for!(MediaCardGridProps)).unwrap(),
394        &[],
395    ),
396];
397
398// ── Schema sanitizer ──────────────────────────────────────────────────────────
399
400/// Walk a JSON Schema tree and rewrite legacy `definitions` → `$defs`
401/// (schemars 0.8 → 1.x draft key drift). Idempotent.
402///
403/// Also rewrites `$ref` strings that reference `#/definitions/X` → `#/$defs/X`
404/// so that validator resolution does not break after the key rename (H-2).
405fn sanitize_schema(mut schema: Value) -> Value {
406    fn walk(v: &mut Value) {
407        if let Some(obj) = v.as_object_mut() {
408            if let Some(defs) = obj.remove("definitions") {
409                obj.entry("$defs".to_string()).or_insert(defs);
410            }
411            if let Some(Value::String(ref_str)) = obj.get_mut("$ref") {
412                if let Some(suffix) = ref_str.strip_prefix("#/definitions/") {
413                    *ref_str = format!("#/$defs/{suffix}");
414                }
415            }
416            // Collect keys first to avoid borrow conflicts.
417            let keys: Vec<String> = obj.keys().cloned().collect();
418            for k in keys {
419                if let Some(child) = obj.get_mut(&k) {
420                    walk(child);
421                }
422            }
423        } else if let Some(arr) = v.as_array_mut() {
424            for item in arr.iter_mut() {
425                walk(item);
426            }
427        }
428    }
429    walk(&mut schema);
430    schema
431}
432
433// ── Schema assembly ────────────────────────────────────────────────────────────
434
435/// Hoist all `$defs` entries from a schemars-generated schema into a shared map.
436///
437/// schemars emits nested type definitions under `$defs` on the schema root. When
438/// component schemas are embedded as `allOf[1]` in the oneOf, the `jsonschema`
439/// validator resolves `$ref` pointers from the *assembled* root — so every
440/// component-local `$defs` entry must be merged up to the top level.
441fn hoist_defs(schema: &mut Value, shared_defs: &mut serde_json::Map<String, Value>) {
442    if let Some(obj) = schema.as_object_mut() {
443        if let Some(Value::Object(defs)) = obj.remove("$defs") {
444            for (k, v) in defs {
445                shared_defs.entry(k).or_insert(v);
446            }
447        }
448    }
449}
450
451/// Hand-assemble the full spec JSON Schema document from per-component schemas.
452///
453/// Root: `$schema`, `root`, `elements` (HashMap<String, Element>), optional `title` /
454/// `layout` / `data`. `$defs/Element` uses a `oneOf` at the element level —
455/// each variant pins `"type": { "const": "X" }` on the element object itself and
456/// validates `props` against that component's Props schema (CONTEXT D-13).
457///
458/// Variants are sorted by name to guarantee deterministic output (CONTEXT D-18).
459///
460/// `$defs` from every per-component schema are hoisted to the root so that `$ref`
461/// pointers (e.g., `#/$defs/ConfirmDialog`) resolve against the assembled document.
462fn assemble_full_schema(per_component: &HashMap<String, Value>) -> Result<Value, CatalogError> {
463    // Start with Action and Visibility defs — their nested types ($defs) are hoisted too.
464    let mut action_schema = sanitize_schema(to_value(schema_for!(crate::action::Action))?);
465    let mut visibility_schema =
466        sanitize_schema(to_value(schema_for!(crate::visibility::Visibility))?);
467
468    // Collect shared $defs — starts with action + visibility nested types.
469    let mut shared_defs: serde_json::Map<String, Value> = serde_json::Map::new();
470    hoist_defs(&mut action_schema, &mut shared_defs);
471    hoist_defs(&mut visibility_schema, &mut shared_defs);
472
473    // Deterministic oneOf at the Element level — sorted by name (CONTEXT D-18).
474    // Each variant describes a complete element object: pins `type` via const on the
475    // element itself, then validates `props` against that component's Props schema.
476    let mut names: Vec<&String> = per_component.keys().collect();
477    names.sort();
478    let one_of: Vec<Value> = names
479        .into_iter()
480        .map(|name| {
481            let mut props_schema = per_component[name].clone();
482            // Hoist component-local $defs so $ref pointers resolve from the assembled root.
483            hoist_defs(&mut props_schema, &mut shared_defs);
484            serde_json::json!({
485                "allOf": [
486                    {
487                        "type": "object",
488                        "required": ["type"],
489                        "properties": {
490                            "type": { "const": name }
491                        }
492                    },
493                    {
494                        "type": "object",
495                        "properties": {
496                            "props": props_schema,
497                            "children": { "type": "array", "items": { "type": "string" } },
498                            "action":   { "$ref": "#/$defs/Action" },
499                            "visible":  { "$ref": "#/$defs/Visibility" }
500                        }
501                    }
502                ]
503            })
504        })
505        .collect();
506
507    // Merge the framework-level $defs (Element, Action, Visibility) with the hoisted ones.
508    // Framework entries take precedence and must not be overwritten by component defs.
509    shared_defs
510        .entry("Action".to_string())
511        .or_insert(action_schema);
512    shared_defs
513        .entry("Visibility".to_string())
514        .or_insert(visibility_schema);
515    // Element is the discriminated union itself — oneOf over all component variants.
516    shared_defs.insert(
517        "Element".to_string(),
518        serde_json::json!({ "oneOf": one_of }),
519    );
520
521    Ok(serde_json::json!({
522        "$schema": "https://json-schema.org/draft/2020-12/schema",
523        "$id": "ferro-json-ui/v2",
524        "type": "object",
525        "required": ["$schema", "root", "elements"],
526        "properties": {
527            "$schema":  { "const": "ferro-json-ui/v2" },
528            "root":     { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,127}$" },
529            "elements": {
530                "type": "object",
531                "additionalProperties": { "$ref": "#/$defs/Element" }
532            },
533            "title":    { "type": ["string", "null"] },
534            "layout":   { "type": ["string", "null"] },
535            "data":     true
536        },
537        "$defs": shared_defs
538    }))
539}
540
541// ── Catalog impl ───────────────────────────────────────────────────────────────
542
543impl Catalog {
544    /// Build the catalog from the static built-in specs and the current plugin registry.
545    ///
546    /// Called once by [`global_catalog`]. Returns `Err` if a plugin's `props_schema()`
547    /// is not a valid JSON Schema or if the assembled full schema fails to compile.
548    ///
549    /// # Errors
550    ///
551    /// - [`CatalogError::BuildFailed`] — plugin meta-validation failure or jsonschema
552    ///   compilation failure.
553    /// - [`CatalogError::SchemaSerialization`] — serde_json serialization failure.
554    pub fn build() -> Result<Self, CatalogError> {
555        // === Runtime drift guard ===
556        // BUILTIN_SPECS and BUILTIN_TYPES must stay in sync. If they diverge, the
557        // catalog is incomplete and downstream validation would silently skip types.
558        if BUILTIN_SPECS.len() != crate::render::BUILTIN_TYPES.len() {
559            return Err(CatalogError::BuildFailed(format!(
560                "BUILTIN_SPECS has {} entries but BUILTIN_TYPES has {} — \
561                 add an entry to BUILTIN_SPECS or remove from BUILTIN_TYPES",
562                BUILTIN_SPECS.len(),
563                crate::render::BUILTIN_TYPES.len(),
564            )));
565        }
566
567        // === Populate built-ins ===
568        let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
569        let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len() * 2);
570        for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
571            let raw = schema_fn();
572            let schema = sanitize_schema(raw);
573            per_component_schemas.insert((*name).to_string(), schema.clone());
574            components.insert(
575                (*name).to_string(),
576                ComponentSpec {
577                    name: (*name).to_string(),
578                    description: (*desc).to_string(),
579                    props_schema: schema,
580                    is_plugin: false,
581                    slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
582                },
583            );
584        }
585
586        // === Populate plugins (H-3 meta-validation) ===
587        // Plugins are developer-authored but their schemas are treated as untrusted
588        // input (CONTEXT D-20, RESEARCH H-3). Each schema is compiled with
589        // `jsonschema::validator_for` before wiring into the catalog; a bad schema
590        // aborts build with the plugin name embedded in the error.
591        let mut plugin_components = HashMap::new();
592        for plugin_type in crate::plugin::registered_plugin_types() {
593            // Built-ins take precedence; a plugin cannot shadow a built-in type (D-19).
594            if components.contains_key(&plugin_type) {
595                continue;
596            }
597            let raw = crate::plugin::with_plugin(&plugin_type, |p| p.props_schema())
598                .unwrap_or(Value::Null);
599            let schema = sanitize_schema(raw);
600            // Meta-validate plugin schema (CONTEXT D-20, RESEARCH H-3).
601            if jsonschema::validator_for(&schema).is_err() {
602                return Err(CatalogError::BuildFailed(format!(
603                    "plugin '{plugin_type}' returned an invalid JSON Schema"
604                )));
605            }
606            per_component_schemas.insert(plugin_type.clone(), schema.clone());
607            plugin_components.insert(
608                plugin_type.clone(),
609                ComponentSpec {
610                    name: plugin_type,
611                    description: String::from("Plugin component."),
612                    props_schema: schema,
613                    is_plugin: true,
614                    slot_fields: Vec::new(),
615                },
616            );
617        }
618
619        // === Assemble full schema (CONTEXT D-13, D-14, D-15) ===
620        let full_schema = assemble_full_schema(&per_component_schemas)?;
621
622        // === Compile validator ONCE (SCHEMA-03) ===
623        let validator = jsonschema::validator_for(&full_schema)
624            .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
625
626        Ok(Catalog {
627            components,
628            plugin_components,
629            full_schema,
630            per_component_schemas,
631            validator,
632        })
633    }
634
635    /// Return the fully-assembled spec JSON Schema document.
636    ///
637    /// Shape: root with `$schema`, `root`, `elements`, plus `$defs`
638    /// containing `Element` (with a discriminated `oneOf` over all
639    /// component Props) and `Action` / `Visibility` references.
640    /// Zero-copy — the returned `&Value` lives as long as the Catalog.
641    pub fn json_schema(&self) -> &Value {
642        &self.full_schema
643    }
644
645    /// Validate a [`crate::spec::Spec`] against the catalog.
646    ///
647    /// Three-stage pipeline (CONTEXT D-10):
648    ///
649    /// 1. **Type-name whitelist** — every `element.type_name` must resolve to a
650    ///    built-in or plugin component. Unknown names return [`CatalogError::UnknownType`]
651    ///    and **short-circuit** the rest of the pipeline. This avoids noisy `oneOf`
652    ///    errors from Stage 3 when a component name is simply wrong (RESEARCH §5).
653    ///
654    /// 2. **Per-element Props validation** — for each element, look up
655    ///    `per_component_schemas[type_name]` and validate `element.props` against it
656    ///    using on-demand [`jsonschema::validator_for`]. Errors accumulate as
657    ///    [`CatalogError::PropsInvalid`]. Plugin schemas are accepted per CONTEXT D-20.
658    ///
659    /// 3. **Envelope check** — serialize the full `Spec` and run it through the
660    ///    cached `self.validator` (compiled once in [`Catalog::build`], SCHEMA-03).
661    ///    Errors become [`CatalogError::SpecInvalid`].
662    ///
663    /// Errors accumulate across Stages 2 and 3 so a caller sees every issue at once.
664    pub fn validate(&self, spec: &crate::spec::Spec) -> Result<(), Vec<CatalogError>> {
665        let mut errors: Vec<CatalogError> = Vec::new();
666
667        // === Stage 1: type_name whitelist (O(1) per element) ===
668        for (id, el) in &spec.elements {
669            let known = self.components.contains_key(&el.type_name)
670                || self.plugin_components.contains_key(&el.type_name);
671            if !known {
672                errors.push(CatalogError::UnknownType {
673                    element_id: id.clone(),
674                    type_name: el.type_name.clone(),
675                });
676            }
677        }
678        // SHORT-CIRCUIT: if any type is unknown, skip Stages 2 & 3.
679        // Rationale: Stage 3's full-spec oneOf would emit dozens of
680        // "no variant matched" errors for unknown types, obscuring the signal.
681        // Stage 2 would skip unknowns anyway.
682        if !errors.is_empty() {
683            return Err(errors);
684        }
685
686        // === Stage 2: per-element Props validation ===
687        for (id, el) in &spec.elements {
688            if let Some(schema) = self.per_component_schemas.get(&el.type_name) {
689                // Skip null props — null means "no props provided"; the schema's
690                // `required` list is the gate for required fields. When props is
691                // null the element carries no props object, which the envelope
692                // schema permits (props is optional per the element allOf shape).
693                if el.props.is_null() {
694                    continue;
695                }
696                // On-demand compile (CONTEXT D-12). Schemas are small (~50–200 LOC
697                // JSON); compile cost < 1 ms per component. Cache as
698                // HashMap<String, Validator> if profiling demands it.
699                let v = match jsonschema::validator_for(schema) {
700                    Ok(v) => v,
701                    Err(e) => {
702                        errors.push(CatalogError::BuildFailed(format!(
703                            "compiling per-component schema for '{}': {e}",
704                            el.type_name
705                        )));
706                        continue;
707                    }
708                };
709                // Strip $data/$template expression objects before schema validation.
710                // Expressions are resolved at render time against handler data — the
711                // static catalog validator cannot know the resolved type. We substitute
712                // expression objects with "" so string-typed fields pass; the runtime
713                // resolver (resolve_expressions) enforces the actual type via data binding.
714                let validation_props = strip_expr_objects(&el.props);
715                let mut per_elem_errs: Vec<String> = Vec::new();
716                for err in v.iter_errors(&validation_props) {
717                    per_elem_errs.push(format!("{}: {}", err.instance_path(), err));
718                }
719                if !per_elem_errs.is_empty() {
720                    errors.push(CatalogError::PropsInvalid {
721                        element_id: id.clone(),
722                        type_name: el.type_name.clone(),
723                        errors: per_elem_errs,
724                    });
725                }
726            }
727        }
728
729        // === Stage 3: full-spec envelope validation (cached validator, SCHEMA-03) ===
730        let spec_value = match serde_json::to_value(spec) {
731            Ok(v) => v,
732            Err(e) => {
733                errors.push(CatalogError::SchemaSerialization(e));
734                return Err(errors);
735            }
736        };
737        // Strip expression objects in the serialized spec for the same reason as Stage 2.
738        let stripped_spec_value = strip_expr_objects(&spec_value);
739        let mut envelope_errs: Vec<String> = Vec::new();
740        for err in self.validator.iter_errors(&stripped_spec_value) {
741            envelope_errs.push(format!("{}: {}", err.instance_path(), err));
742        }
743        if !envelope_errs.is_empty() {
744            errors.push(CatalogError::SpecInvalid {
745                errors: envelope_errs,
746            });
747        }
748
749        if errors.is_empty() {
750            Ok(())
751        } else {
752            Err(errors)
753        }
754    }
755
756    /// Return the per-component Props JSON Schema for `type_name`, or `None`
757    /// if the name is not registered as a built-in or plugin component.
758    ///
759    /// The returned schema is Props-only (NOT wrapped in the Element envelope
760    /// used by [`Self::json_schema`]). This is the schema shape Phase 120 AI
761    /// structured-output generation consumes, and what `ferro json-ui:schema
762    /// --component <name>` prints.
763    ///
764    /// The reference has the same lifetime as `&self` — zero-copy (CONTEXT D-15).
765    ///
766    /// Lookup is unified across built-ins and plugins via the
767    /// `per_component_schemas` map populated in [`Self::build`] (CONTEXT D-20
768    /// — plugin schemas are stored identically after meta-validation).
769    pub fn component_schema(&self, type_name: &str) -> Option<&Value> {
770        self.per_component_schemas.get(type_name)
771    }
772
773    /// Iterate built-in [`ComponentSpec`] entries sorted by name (ascending).
774    ///
775    /// Deterministic ordering is required by CONTEXT D-18 so that
776    /// [`Self::json_schema`], `prompt()` (Plan 06), and ferro-mcp
777    /// `json_ui_catalog` output (Plan 06 migration) produce byte-stable
778    /// results for snapshot tests.
779    pub fn components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
780        let mut entries: Vec<&ComponentSpec> = self.components.values().collect();
781        entries.sort_by(|a, b| a.name.cmp(&b.name));
782        entries.into_iter()
783    }
784
785    /// Iterate plugin [`ComponentSpec`] entries sorted by name (ascending).
786    ///
787    /// Separate from built-ins so consumers can format them in a distinct
788    /// section (ferro-mcp `json_ui_catalog.CatalogResponse` preserves the
789    /// `components` / `plugin_components` split per CONTEXT D-24).
790    pub fn plugin_components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
791        let mut entries: Vec<&ComponentSpec> = self.plugin_components.values().collect();
792        entries.sort_by(|a, b| a.name.cmp(&b.name));
793        entries.into_iter()
794    }
795
796    /// Generate a concise text system prompt summarizing every component.
797    ///
798    /// Format: `## Component Catalog` header, a one-line note explaining the
799    /// slot convention, then one `### <Name>` section per component (built-ins
800    /// then plugins, both sorted by name). Each section contains the
801    /// description, a single `Props:` line with `name (Type)` tuples, and
802    /// (when non-empty) a `Slots:` line listing slot field names only.
803    ///
804    /// The prompt is intentionally CONCISE (≤ 10 KB, CONTEXT D-17) — the full
805    /// JSON Schema is NOT embedded. Consumers wanting machine-readable schemas
806    /// use [`Self::json_schema`] or [`Self::component_schema`] (Plan 07 CLI).
807    ///
808    /// Deterministic (CONTEXT D-18): two builds of the same catalog yield
809    /// byte-identical output; order within sections follows alphabetical order
810    /// via [`Self::components_sorted`] and [`Self::plugin_components_sorted`].
811    pub fn prompt(&self) -> String {
812        let mut out = String::with_capacity(8 * 1024);
813        out.push_str("## Component Catalog\n\n");
814        out.push_str("Slot fields are Vec<String> of element IDs; body children come from Element.children.\n\n");
815        for spec in self.components_sorted() {
816            render_component_section(&mut out, spec);
817        }
818        if self.plugin_components.is_empty() {
819            return out;
820        }
821        out.push_str("## Plugin Components\n\n");
822        for spec in self.plugin_components_sorted() {
823            render_component_section(&mut out, spec);
824        }
825        out
826    }
827}
828
829// ── Prompt generation helpers ─────────────────────────────────────────────────
830
831/// Append a single component section to `out`.
832///
833/// Shape:
834/// ```text
835/// ### Card
836/// Content container with title and optional footer slot.
837/// Props: title (String), description (Option<String>), ...
838/// Slots: footer
839///
840/// ```
841///
842/// The slot semantics (Vec<String> of element IDs, body children from
843/// Element.children) are declared once in the catalog header by
844/// [`Catalog::prompt`] rather than repeated per section.
845fn render_component_section(out: &mut String, spec: &ComponentSpec) {
846    out.push_str("### ");
847    out.push_str(&spec.name);
848    out.push('\n');
849    out.push_str(&spec.description);
850    out.push('\n');
851
852    let props_line = render_props_line(&spec.props_schema);
853    if !props_line.is_empty() {
854        out.push_str("Props: ");
855        out.push_str(&props_line);
856        out.push('\n');
857    }
858    if !spec.slot_fields.is_empty() {
859        out.push_str("Slots: ");
860        out.push_str(&spec.slot_fields.join(", "));
861        out.push('\n');
862    }
863    out.push('\n');
864}
865
866/// Render the `Props:` line for a schemars-derived Props schema.
867///
868/// Walks `schema.properties` in serde-emit order. For each field:
869/// - `Option<T>` schemas (schemars emits `anyOf: [{...}, {type: null}]`) render as `Option<T>`.
870/// - Enum fields with ≤ 8 `enum` entries render inline as `name (a|b|c)`.
871/// - Enum fields with > 8 entries render as `name (one of N — see schema)`.
872/// - Plain scalar fields render as `name (String)` / `(i64)` / `(bool)`.
873/// - Array types render as `name (Vec<T>)`.
874///
875/// Returns an empty string if the schema has no `properties` map.
876fn render_props_line(schema: &Value) -> String {
877    let Some(obj) = schema.as_object() else {
878        return String::new();
879    };
880    let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
881        return String::new();
882    };
883    let required: std::collections::HashSet<&str> = obj
884        .get("required")
885        .and_then(|v| v.as_array())
886        .map(|arr| {
887            arr.iter()
888                .filter_map(|v| v.as_str())
889                .collect::<std::collections::HashSet<_>>()
890        })
891        .unwrap_or_default();
892
893    let parts: Vec<String> = props
894        .iter()
895        .map(|(name, field_schema)| {
896            let ty = render_field_type(field_schema, required.contains(name.as_str()));
897            format!("{name} ({ty})")
898        })
899        .collect();
900    parts.join(", ")
901}
902
903/// Render a single field's type string from its JSON Schema.
904fn render_field_type(schema: &Value, is_required: bool) -> String {
905    // 1) Detect enum inline: {type: "string", enum: [...]} or {enum: [...]}
906    if let Some(variants) = schema.get("enum").and_then(|v| v.as_array()) {
907        let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
908        let inner = render_enum_inline(&names);
909        return wrap_optional(inner, is_required);
910    }
911    // 2) anyOf / oneOf with null → Option<T>
912    for key in ["anyOf", "oneOf"] {
913        if let Some(arr) = schema.get(key).and_then(|v| v.as_array()) {
914            let has_null = arr
915                .iter()
916                .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
917            let non_null: Vec<&Value> = arr
918                .iter()
919                .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
920                .collect();
921            if has_null && non_null.len() == 1 {
922                let inner = render_field_type(non_null[0], true);
923                return format!("Option<{inner}>");
924            }
925        }
926    }
927    // 3) type: ["T", "null"] → Option<T>
928    if let Some(types) = schema.get("type").and_then(|v| v.as_array()) {
929        let non_null: Vec<&str> = types
930            .iter()
931            .filter_map(|v| v.as_str())
932            .filter(|s| *s != "null")
933            .collect();
934        let has_null = types.iter().any(|v| v.as_str() == Some("null"));
935        if has_null && non_null.len() == 1 {
936            return format!("Option<{}>", rust_for_json_type(non_null[0], schema));
937        }
938    }
939    // 4) Plain type
940    if let Some(t) = schema.get("type").and_then(|v| v.as_str()) {
941        let inner = rust_for_json_type(t, schema);
942        return wrap_optional(inner, is_required);
943    }
944    // 5) Fallback: $ref or complex
945    wrap_optional("<see schema>".to_string(), is_required)
946}
947
948/// Map a JSON Schema `type` + optional `items` to a Rust-ish type name.
949fn rust_for_json_type(t: &str, schema: &Value) -> String {
950    match t {
951        "string" => "String".to_string(),
952        "integer" => "i64".to_string(),
953        "number" => "f64".to_string(),
954        "boolean" => "bool".to_string(),
955        "array" => {
956            if let Some(items) = schema.get("items") {
957                let inner = render_field_type(items, true);
958                format!("Vec<{inner}>")
959            } else {
960                "Vec<Value>".to_string()
961            }
962        }
963        "object" => "Object".to_string(),
964        other => other.to_string(),
965    }
966}
967
968/// Render an enum's variants inline when count ≤ 8, else collapse.
969fn render_enum_inline(variants: &[&str]) -> String {
970    if variants.len() <= 8 {
971        variants.join("|")
972    } else {
973        format!("one of {} — see schema", variants.len())
974    }
975}
976
977/// Wrap inner type in `Option<...>` when the field is not required.
978fn wrap_optional(inner: String, is_required: bool) -> String {
979    if is_required {
980        inner
981    } else {
982        format!("Option<{inner}>")
983    }
984}
985
986/// Replace every `$data` / `$template` expression object in a value tree with `""`.
987///
988/// Used by [`Catalog::validate`] so that specs with runtime data-binding placeholders
989/// pass static schema validation. Expression objects have the shape
990/// `{"$data": "/path"}` or `{"$template": "literal {/path}"}` — single-key objects
991/// whose key is the expression marker. They are resolved at render time by
992/// [`crate::expression::resolve_expressions`]; the catalog validator must not reject
993/// them for failing type checks that only apply to the resolved value.
994fn strip_expr_objects(val: &Value) -> Value {
995    match val {
996        Value::Object(map) => {
997            if map.len() == 1 && (map.contains_key("$data") || map.contains_key("$template")) {
998                Value::String(String::new())
999            } else {
1000                Value::Object(
1001                    map.iter()
1002                        .map(|(k, v)| (k.clone(), strip_expr_objects(v)))
1003                        .collect(),
1004                )
1005            }
1006        }
1007        Value::Array(arr) => Value::Array(arr.iter().map(strip_expr_objects).collect()),
1008        other => other.clone(),
1009    }
1010}
1011
1012// ── Global singleton ───────────────────────────────────────────────────────────
1013
1014/// Access the global, immutable component catalog.
1015///
1016/// Lazily initialized on first call using the plugin registry state at that moment.
1017/// Subsequent plugin registrations do NOT propagate into the catalog (D-04).
1018///
1019/// # Panics
1020///
1021/// Panics if [`Catalog::build`] fails. In practice this only occurs if a registered
1022/// plugin returns a malformed JSON Schema from `props_schema()`. Built-in schemas
1023/// are derived at compile time and are always valid.
1024pub fn global_catalog() -> &'static Catalog {
1025    static GLOBAL_CATALOG: OnceLock<Catalog> = OnceLock::new();
1026    GLOBAL_CATALOG.get_or_init(|| {
1027        Catalog::build().expect("catalog build failed — see CatalogError for details")
1028    })
1029}
1030
1031// ── Tests ──────────────────────────────────────────────────────────────────────
1032
1033#[cfg(test)]
1034impl Catalog {
1035    /// Build a catalog from built-in specs only, skipping the global plugin registry.
1036    ///
1037    /// Tests that register plugins with invalid schemas pollute the global registry.
1038    /// This helper produces a clean, plugin-free catalog safe for use in any test order.
1039    pub(crate) fn build_builtins_only() -> Result<Self, CatalogError> {
1040        let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
1041        let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len());
1042        for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
1043            let raw = schema_fn();
1044            let schema = sanitize_schema(raw);
1045            per_component_schemas.insert((*name).to_string(), schema.clone());
1046            components.insert(
1047                (*name).to_string(),
1048                ComponentSpec {
1049                    name: (*name).to_string(),
1050                    description: (*desc).to_string(),
1051                    props_schema: schema,
1052                    is_plugin: false,
1053                    slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
1054                },
1055            );
1056        }
1057        let full_schema = assemble_full_schema(&per_component_schemas)?;
1058        let validator = jsonschema::validator_for(&full_schema)
1059            .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
1060        Ok(Catalog {
1061            components,
1062            plugin_components: HashMap::new(),
1063            full_schema,
1064            per_component_schemas,
1065            validator,
1066        })
1067    }
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073
1074    #[test]
1075    fn builtin_types_count_is_39() {
1076        // Drift guard — if this fails, Phase 116's BUILTIN_TYPES changed
1077        // without a corresponding catalog update. See Plan 02.
1078        // Updated to 40 in Phase 162 Plan 01 (CheckboxList added).
1079        // Updated to 42 when DetailPage was added.
1080        // Updated to 43 in Phase 175 Plan 04 (CheckboxGroup alias added).
1081        // Updated to 44 when MediaCardGrid was added.
1082        assert_eq!(crate::render::BUILTIN_TYPES.len(), 44);
1083    }
1084
1085    #[test]
1086    fn builtin_specs_len_matches_dispatch() {
1087        assert_eq!(BUILTIN_SPECS.len(), crate::render::BUILTIN_TYPES.len());
1088        assert_eq!(BUILTIN_SPECS.len(), 44);
1089    }
1090
1091    #[test]
1092    fn builtin_specs_names_match_dispatch() {
1093        use std::collections::HashSet;
1094        let specs: HashSet<&str> = BUILTIN_SPECS.iter().map(|(n, ..)| *n).collect();
1095        let types: HashSet<&str> = crate::render::BUILTIN_TYPES.iter().copied().collect();
1096        assert_eq!(specs, types, "BUILTIN_SPECS names must match BUILTIN_TYPES");
1097    }
1098
1099    #[test]
1100    fn build_populates_all_builtins() {
1101        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1102        let cat = Catalog::build_builtins_only().expect("build succeeds");
1103        for name in crate::render::BUILTIN_TYPES.iter() {
1104            assert!(
1105                cat.components.contains_key(*name),
1106                "built-in '{name}' missing from catalog.components"
1107            );
1108            let spec = &cat.components[*name];
1109            assert_eq!(spec.name, *name);
1110            assert!(
1111                !spec.description.is_empty(),
1112                "'{name}' has empty description"
1113            );
1114            assert!(
1115                spec.props_schema.is_object(),
1116                "'{name}' props_schema is not a JSON object"
1117            );
1118            assert!(!spec.is_plugin);
1119        }
1120    }
1121
1122    #[test]
1123    fn build_card_has_footer_slot() {
1124        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1125        let cat = Catalog::build_builtins_only().expect("build succeeds");
1126        let card = &cat.components["Card"];
1127        assert_eq!(card.slot_fields, vec!["footer"]);
1128    }
1129
1130    #[test]
1131    fn build_modal_has_footer_slot() {
1132        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1133        let cat = Catalog::build_builtins_only().expect("build succeeds");
1134        let modal = &cat.components["Modal"];
1135        assert_eq!(modal.slot_fields, vec!["footer"]);
1136    }
1137
1138    #[test]
1139    fn build_pageheader_has_actions_slot() {
1140        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1141        let cat = Catalog::build_builtins_only().expect("build succeeds");
1142        let ph = &cat.components["PageHeader"];
1143        assert_eq!(ph.slot_fields, vec!["actions"]);
1144    }
1145
1146    #[test]
1147    fn build_text_has_no_slots() {
1148        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1149        let cat = Catalog::build_builtins_only().expect("build succeeds");
1150        assert!(cat.components["Text"].slot_fields.is_empty());
1151    }
1152
1153    #[test]
1154    fn build_populates_per_component_schemas() {
1155        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1156        let cat = Catalog::build_builtins_only().expect("build succeeds");
1157        assert_eq!(
1158            cat.per_component_schemas.len(),
1159            BUILTIN_SPECS.len() + cat.plugin_components.len()
1160        );
1161    }
1162
1163    #[test]
1164    fn sanitize_schema_rewrites_definitions_to_dollar_defs() {
1165        let raw = serde_json::json!({
1166            "type": "object",
1167            "definitions": { "Foo": { "type": "string" } },
1168            "properties": {
1169                "x": { "$ref": "#/definitions/Foo" }
1170            }
1171        });
1172        let out = sanitize_schema(raw);
1173        assert!(out.get("definitions").is_none());
1174        assert!(out.get("$defs").is_some());
1175        assert_eq!(
1176            out["properties"]["x"]["$ref"].as_str().unwrap(),
1177            "#/$defs/Foo"
1178        );
1179    }
1180
1181    #[test]
1182    fn sanitize_schema_is_idempotent() {
1183        let raw = serde_json::json!({
1184            "type": "object",
1185            "$defs": { "Foo": { "type": "string" } },
1186            "properties": {
1187                "x": { "$ref": "#/$defs/Foo" }
1188            }
1189        });
1190        let once = sanitize_schema(raw.clone());
1191        let twice = sanitize_schema(once.clone());
1192        assert_eq!(once, twice);
1193        // Existing $defs should remain, no definitions key introduced.
1194        assert!(twice.get("definitions").is_none());
1195        assert!(twice.get("$defs").is_some());
1196    }
1197
1198    #[test]
1199    fn json_schema_has_spec_envelope_shape() {
1200        // Use build_builtins_only() to avoid global plugin registry pollution
1201        // from build_discovers_plugins_and_rejects_invalid_schema (BadPlugin_117).
1202        let cat = Catalog::build_builtins_only().expect("build");
1203        let schema = cat.json_schema();
1204        assert_eq!(schema["$id"], "ferro-json-ui/v2");
1205        assert_eq!(schema["type"], "object");
1206        let required: Vec<&str> = schema["required"]
1207            .as_array()
1208            .unwrap()
1209            .iter()
1210            .map(|v| v.as_str().unwrap())
1211            .collect();
1212        assert!(required.contains(&"$schema"));
1213        assert!(required.contains(&"root"));
1214        assert!(required.contains(&"elements"));
1215    }
1216
1217    #[test]
1218    fn json_schema_has_action_and_visibility_defs() {
1219        let cat = Catalog::build_builtins_only().expect("build");
1220        let schema = cat.json_schema();
1221        assert!(
1222            schema["$defs"]["Action"].is_object(),
1223            "$defs/Action missing"
1224        );
1225        assert!(
1226            schema["$defs"]["Visibility"].is_object(),
1227            "$defs/Visibility missing"
1228        );
1229        assert!(
1230            schema["$defs"]["Element"].is_object(),
1231            "$defs/Element missing"
1232        );
1233    }
1234
1235    #[test]
1236    fn json_schema_oneof_covers_all_builtins() {
1237        let cat = Catalog::build_builtins_only().expect("build");
1238        let schema = cat.json_schema();
1239        // oneOf is at the Element level (discriminates on element.type, not props.type).
1240        let one_of = schema["$defs"]["Element"]["oneOf"]
1241            .as_array()
1242            .expect("Element.oneOf is an array");
1243
1244        // Extract every const discriminator from the allOf[0] branch.
1245        let mut discriminators: std::collections::HashSet<String> =
1246            std::collections::HashSet::new();
1247        for variant in one_of {
1248            let c = variant["allOf"][0]["properties"]["type"]["const"]
1249                .as_str()
1250                .expect("every variant pins a type const");
1251            discriminators.insert(c.to_string());
1252        }
1253
1254        for name in crate::render::BUILTIN_TYPES.iter() {
1255            assert!(
1256                discriminators.contains(*name),
1257                "oneOf is missing discriminator for '{name}'"
1258            );
1259        }
1260
1261        // Built-ins only — exactly BUILTIN_TYPES.len() variants.
1262        assert_eq!(
1263            discriminators.len(),
1264            crate::render::BUILTIN_TYPES.len(),
1265            "oneOf variant count mismatch"
1266        );
1267    }
1268
1269    #[test]
1270    fn json_schema_is_valid() {
1271        use jsonschema::draft202012;
1272        let cat = Catalog::build_builtins_only().expect("build");
1273        let schema = cat.json_schema();
1274        assert!(
1275            draft202012::meta::is_valid(schema),
1276            "assembled full_schema did not meta-validate as Draft 2020-12"
1277        );
1278    }
1279
1280    #[test]
1281    fn validator_is_compiled_once_and_usable() {
1282        let cat = Catalog::build_builtins_only().expect("build");
1283        // The validator field is private — we prove it's real by validating
1284        // a minimal valid spec value. If the validator were stale / null /
1285        // placeholder, this would fail or mis-report.
1286        let minimal_valid = serde_json::json!({
1287            "$schema": "ferro-json-ui/v2",
1288            "root": "r",
1289            "elements": {
1290                "r": { "type": "Text", "props": { "content": "hi" } }
1291            }
1292        });
1293        // Should succeed — full-schema envelope accepts this shape.
1294        assert!(cat.validator.is_valid(&minimal_valid));
1295    }
1296
1297    #[test]
1298    fn validator_rejects_wrong_schema_version() {
1299        let cat = Catalog::build_builtins_only().expect("build");
1300        let wrong_version = serde_json::json!({
1301            "$schema": "ferro-json-ui/v99-wrong",
1302            "root": "r",
1303            "elements": {
1304                "r": { "type": "Text", "props": { "content": "hi" } }
1305            }
1306        });
1307        assert!(
1308            !cat.validator.is_valid(&wrong_version),
1309            "validator should reject unknown $schema version via const"
1310        );
1311    }
1312
1313    #[test]
1314    fn oneof_variants_are_deterministic_sorted() {
1315        let cat1 = Catalog::build_builtins_only().expect("build 1");
1316        let cat2 = Catalog::build_builtins_only().expect("build 2");
1317        // Byte-exact equality guarantees deterministic output (CONTEXT D-18).
1318        assert_eq!(
1319            serde_json::to_string(cat1.json_schema()).unwrap(),
1320            serde_json::to_string(cat2.json_schema()).unwrap()
1321        );
1322    }
1323
1324    // ── validate() tests (Plan 04) ────────────────────────────────────────────
1325
1326    /// Build a minimal valid Spec with one Element of the given type + props.
1327    fn test_spec_with(type_name: &str, props: Value) -> crate::spec::Spec {
1328        use crate::spec::{Element, Spec};
1329        use std::collections::HashMap;
1330        let mut elements = HashMap::new();
1331        elements.insert(
1332            "r".to_string(),
1333            Element {
1334                type_name: type_name.to_string(),
1335                props,
1336                children: Vec::new(),
1337                action: None,
1338                visible: None,
1339                each: None,
1340                if_: None,
1341            },
1342        );
1343        Spec {
1344            schema: crate::spec::SCHEMA_VERSION.to_string(),
1345            root: "r".to_string(),
1346            elements,
1347            title: None,
1348            layout: None,
1349            data: Value::Null,
1350        }
1351    }
1352
1353    #[test]
1354    fn validate_positive_per_type() {
1355        // Representative subset of built-ins — confirms validate() passes for
1356        // minimally valid elements. Full 39-type coverage lives in Plan 07.
1357        let cat = Catalog::build_builtins_only().expect("build");
1358        let cases: Vec<(&str, Value)> = vec![
1359            ("Text", serde_json::json!({ "content": "hi" })),
1360            ("Button", serde_json::json!({ "label": "Save" })),
1361            ("Badge", serde_json::json!({ "label": "New" })),
1362            ("Separator", serde_json::json!({})),
1363        ];
1364        for (ty, props) in cases {
1365            let spec = test_spec_with(ty, props.clone());
1366            match cat.validate(&spec) {
1367                Ok(()) => {}
1368                Err(errs) => panic!("validate({ty}) failed: {errs:?}"),
1369            }
1370        }
1371    }
1372
1373    #[test]
1374    fn validate_unknown_type() {
1375        let cat = Catalog::build_builtins_only().expect("build");
1376        let spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1377        let errs = cat.validate(&spec).expect_err("should fail");
1378        assert!(
1379            errs.iter().any(|e| matches!(
1380                e,
1381                CatalogError::UnknownType { type_name, .. } if type_name == "NotARealComponent"
1382            )),
1383            "expected UnknownType for NotARealComponent; got {errs:?}"
1384        );
1385    }
1386
1387    #[test]
1388    fn validate_missing_required_prop() {
1389        // CardProps.title is required (no Option, no #[serde(default)]).
1390        // Passing {} props should produce PropsInvalid.
1391        let cat = Catalog::build_builtins_only().expect("build");
1392        let spec = test_spec_with("Card", serde_json::json!({}));
1393        let errs = cat.validate(&spec).expect_err("should fail");
1394        assert!(
1395            errs.iter().any(|e| matches!(
1396                e,
1397                CatalogError::PropsInvalid { type_name, .. } if type_name == "Card"
1398            )),
1399            "expected PropsInvalid for missing required 'title'; got {errs:?}"
1400        );
1401    }
1402
1403    #[test]
1404    fn validate_bad_schema_version() {
1405        let cat = Catalog::build_builtins_only().expect("build");
1406        let mut spec = test_spec_with("Text", serde_json::json!({ "content": "hi" }));
1407        spec.schema = "ferro-json-ui/v99-wrong".to_string();
1408        let errs = cat.validate(&spec).expect_err("should fail");
1409        assert!(
1410            errs.iter()
1411                .any(|e| matches!(e, CatalogError::SpecInvalid { .. })),
1412            "expected SpecInvalid for wrong $schema version; got {errs:?}"
1413        );
1414    }
1415
1416    #[test]
1417    fn validate_pre_dispatch_short_circuits() {
1418        // Stage 1 must short-circuit: unknown type + malformed envelope →
1419        // only UnknownType surfaces (not SpecInvalid or PropsInvalid).
1420        let cat = Catalog::build_builtins_only().expect("build");
1421        let mut spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1422        spec.schema = "ferro-json-ui/v99-wrong".to_string();
1423        let errs = cat.validate(&spec).expect_err("should fail");
1424
1425        let has_unknown = errs
1426            .iter()
1427            .any(|e| matches!(e, CatalogError::UnknownType { .. }));
1428        let has_spec_invalid = errs
1429            .iter()
1430            .any(|e| matches!(e, CatalogError::SpecInvalid { .. }));
1431        let has_props_invalid = errs
1432            .iter()
1433            .any(|e| matches!(e, CatalogError::PropsInvalid { .. }));
1434
1435        assert!(has_unknown, "expected UnknownType");
1436        assert!(
1437            !has_spec_invalid,
1438            "Stage 3 ran despite Stage 1 failing: {errs:?}"
1439        );
1440        assert!(
1441            !has_props_invalid,
1442            "Stage 2 ran despite Stage 1 failing: {errs:?}"
1443        );
1444    }
1445
1446    #[test]
1447    fn validator_is_cached_not_recompiled() {
1448        // Structural guarantee: self.validator is a plain field, not recompiled
1449        // per validate() call. This test approximates that by running validate()
1450        // 100 times against a single Catalog without panic or regression.
1451        let cat = Catalog::build_builtins_only().expect("build");
1452        for _ in 0..100 {
1453            let spec = test_spec_with("Text", serde_json::json!({ "content": "x" }));
1454            assert!(cat.validate(&spec).is_ok());
1455        }
1456    }
1457
1458    #[test]
1459    fn validate_accumulates_multiple_errors_across_elements() {
1460        // Two elements with missing required props → two PropsInvalid errors.
1461        use crate::spec::{Element, Spec};
1462        use std::collections::HashMap;
1463        let cat = Catalog::build_builtins_only().expect("build");
1464        let mut elements = HashMap::new();
1465        elements.insert(
1466            "a".to_string(),
1467            Element {
1468                type_name: "Card".to_string(),
1469                props: serde_json::json!({}), // missing required "title"
1470                children: Vec::new(),
1471                action: None,
1472                visible: None,
1473                each: None,
1474                if_: None,
1475            },
1476        );
1477        elements.insert(
1478            "b".to_string(),
1479            Element {
1480                type_name: "Button".to_string(),
1481                props: serde_json::json!({}), // missing required "label"
1482                children: Vec::new(),
1483                action: None,
1484                visible: None,
1485                each: None,
1486                if_: None,
1487            },
1488        );
1489        let spec = Spec {
1490            schema: crate::spec::SCHEMA_VERSION.to_string(),
1491            root: "a".to_string(),
1492            elements,
1493            title: None,
1494            layout: None,
1495            data: Value::Null,
1496        };
1497        let errs = cat.validate(&spec).expect_err("should fail");
1498        let props_invalid_count = errs
1499            .iter()
1500            .filter(|e| matches!(e, CatalogError::PropsInvalid { .. }))
1501            .count();
1502        assert!(
1503            props_invalid_count >= 2,
1504            "expected at least 2 PropsInvalid errors; got {errs:?}"
1505        );
1506    }
1507
1508    /// Combined plugin discovery + invalid schema rejection test.
1509    ///
1510    /// Uses unique names (`GoodPlugin_117`, `BadPlugin_117`) to avoid collisions
1511    /// with other test registrations. The bad plugin is registered after the good
1512    /// one to confirm discovery of the good plugin precedes the rejection.
1513    #[test]
1514    fn build_discovers_plugins_and_rejects_invalid_schema() {
1515        use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
1516
1517        struct GoodPlugin;
1518        impl JsonUiPlugin for GoodPlugin {
1519            fn component_type(&self) -> &str {
1520                "GoodPlugin_117"
1521            }
1522            fn props_schema(&self) -> Value {
1523                serde_json::json!({ "type": "object" })
1524            }
1525            fn render(&self, _: &Value, _: &Value) -> String {
1526                String::new()
1527            }
1528            fn css_assets(&self) -> Vec<Asset> {
1529                vec![]
1530            }
1531            fn js_assets(&self) -> Vec<Asset> {
1532                vec![]
1533            }
1534            fn init_script(&self) -> Option<String> {
1535                None
1536            }
1537        }
1538
1539        register_plugin(GoodPlugin);
1540
1541        // Positive discovery: GoodPlugin should appear in plugin_components.
1542        let cat = Catalog::build().expect("build succeeds with valid plugin only");
1543        assert!(
1544            cat.plugin_components.contains_key("GoodPlugin_117"),
1545            "plugin 'GoodPlugin_117' should have been discovered"
1546        );
1547        assert!(cat.plugin_components["GoodPlugin_117"].is_plugin);
1548
1549        // Now register a bad plugin and confirm build fails with the plugin name embedded.
1550        struct BadPlugin;
1551        impl JsonUiPlugin for BadPlugin {
1552            fn component_type(&self) -> &str {
1553                "BadPlugin_117"
1554            }
1555            fn props_schema(&self) -> Value {
1556                // JSON Schema requires `type` to be a string or array of strings.
1557                // Number 42 is invalid → validator_for() rejects it.
1558                serde_json::json!({ "type": 42 })
1559            }
1560            fn render(&self, _: &Value, _: &Value) -> String {
1561                String::new()
1562            }
1563            fn css_assets(&self) -> Vec<Asset> {
1564                vec![]
1565            }
1566            fn js_assets(&self) -> Vec<Asset> {
1567                vec![]
1568            }
1569            fn init_script(&self) -> Option<String> {
1570                None
1571            }
1572        }
1573
1574        register_plugin(BadPlugin);
1575        match Catalog::build() {
1576            Err(CatalogError::BuildFailed(msg)) => {
1577                assert!(
1578                    msg.contains("BadPlugin_117"),
1579                    "error should mention plugin name, got: {msg}"
1580                );
1581            }
1582            Err(other) => panic!("expected BuildFailed mentioning BadPlugin_117, got: {other:?}"),
1583            Ok(_) => panic!("expected build to fail due to invalid plugin schema"),
1584        }
1585    }
1586
1587    // ── component_schema / sorted accessor tests (Plan 05) ───────────────────
1588
1589    #[test]
1590    fn component_schema_returns_props_only() {
1591        // ROADMAP SC-5 canonical example: catalog.component_schema("Card") must
1592        // return the CardProps schema (NOT the Element wrapper from
1593        // $defs/Element.properties.props in the full schema).
1594        let cat = Catalog::build_builtins_only().expect("build");
1595        let schema = cat
1596            .component_schema("Card")
1597            .expect("Card is a built-in component");
1598
1599        // A Props schema is an object with a `properties` map. The Element
1600        // envelope would have a `type` + `props` + `children` layout — we
1601        // assert the Props-only shape by checking for CardProps fields.
1602        let obj = schema
1603            .as_object()
1604            .expect("Card props schema is a JSON object");
1605
1606        // Expect "type": "object" or equivalent (schemars uses `type` or `oneOf`).
1607        assert!(
1608            obj.contains_key("type") || obj.contains_key("oneOf") || obj.contains_key("anyOf"),
1609            "CardProps schema should be a structural object schema; got {obj:?}"
1610        );
1611
1612        // Expect CardProps-specific field "title" exists in `properties`.
1613        if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
1614            assert!(
1615                props.contains_key("title"),
1616                "CardProps schema.properties should include 'title'; got keys: {:?}",
1617                props.keys().collect::<Vec<_>>()
1618            );
1619        } else {
1620            panic!(
1621                "CardProps schema missing top-level 'properties' map — \
1622                 sanitizer or Plan 02 may be wrong. Got: {}",
1623                serde_json::to_string_pretty(schema).unwrap_or_default()
1624            );
1625        }
1626
1627        // Must NOT be the Element envelope (would mean we accidentally returned
1628        // full_schema["$defs"]["Element"] or similar — CONTEXT D-19).
1629        let is_element_wrapper = obj
1630            .get("properties")
1631            .and_then(|v| v.as_object())
1632            .map(|p| p.contains_key("children") && p.contains_key("props"))
1633            .unwrap_or(false);
1634        assert!(
1635            !is_element_wrapper,
1636            "component_schema('Card') returned an Element wrapper; must be Props-only (CONTEXT D-19)"
1637        );
1638    }
1639
1640    #[test]
1641    fn component_schema_none_for_unknown() {
1642        let cat = Catalog::build_builtins_only().expect("build");
1643        assert!(
1644            cat.component_schema("NotARealComponent_117_05").is_none(),
1645            "unknown component must return None"
1646        );
1647        // Empty string is also "unknown".
1648        assert!(cat.component_schema("").is_none());
1649    }
1650
1651    #[test]
1652    fn component_schema_resolves_every_builtin() {
1653        // Parallel safety net for SC-5: every name in BUILTIN_TYPES must have a
1654        // per-component schema. If any is missing, Plan 02's BUILTIN_SPECS table
1655        // or the build loop dropped an entry.
1656        let cat = Catalog::build_builtins_only().expect("build");
1657        for name in crate::render::BUILTIN_TYPES.iter() {
1658            assert!(
1659                cat.component_schema(name).is_some(),
1660                "built-in '{name}' has no per-component schema"
1661            );
1662        }
1663    }
1664
1665    #[test]
1666    fn components_sorted_yields_ascending_by_name() {
1667        let cat = Catalog::build_builtins_only().expect("build");
1668        let names: Vec<String> = cat
1669            .components_sorted()
1670            .map(|spec| spec.name.clone())
1671            .collect();
1672        assert_eq!(names.len(), crate::render::BUILTIN_TYPES.len());
1673        let mut sorted = names.clone();
1674        sorted.sort();
1675        assert_eq!(
1676            names, sorted,
1677            "components_sorted must yield ascending order"
1678        );
1679
1680        // plugin_components_sorted returns the plugin side; may be empty.
1681        let plugin_names: Vec<String> = cat
1682            .plugin_components_sorted()
1683            .map(|spec| spec.name.clone())
1684            .collect();
1685        let mut plugin_sorted = plugin_names.clone();
1686        plugin_sorted.sort();
1687        assert_eq!(
1688            plugin_names, plugin_sorted,
1689            "plugin_components_sorted must yield ascending order"
1690        );
1691    }
1692
1693    // ── prompt() tests (Plan 06) ─────────────────────────────────────────────
1694
1695    #[test]
1696    fn prompt_under_size_budget() {
1697        let cat = Catalog::build_builtins_only().expect("build");
1698        let prompt = cat.prompt();
1699        let bytes = prompt.len();
1700        // Budget bumped from 8 KB to 9 KB in Phase 162 Plan 01 (CheckboxList added, 40 components).
1701        // Budget bumped from 9 KB to 10 KB in Phase 175 Plan 04 (CheckboxGroup alias added, 43 components).
1702        assert!(
1703            bytes <= 10 * 1024,
1704            "prompt() is {bytes} bytes, exceeds 10 KB budget (CONTEXT D-17)"
1705        );
1706    }
1707
1708    #[test]
1709    fn prompt_mentions_every_builtin() {
1710        let cat = Catalog::build_builtins_only().expect("build");
1711        let prompt = cat.prompt();
1712        for name in crate::render::BUILTIN_TYPES.iter() {
1713            let heading = format!("### {name}\n");
1714            assert!(
1715                prompt.contains(&heading),
1716                "prompt() missing section heading for '{name}'"
1717            );
1718        }
1719    }
1720
1721    #[test]
1722    fn prompt_is_deterministic() {
1723        let cat1 = Catalog::build_builtins_only().expect("build 1");
1724        let cat2 = Catalog::build_builtins_only().expect("build 2");
1725        assert_eq!(
1726            cat1.prompt(),
1727            cat2.prompt(),
1728            "prompt() must be deterministic"
1729        );
1730    }
1731
1732    #[test]
1733    fn prompt_documents_slot_fields() {
1734        // CardProps has slot_fields = ["footer"] (set in Plan 02). The prompt
1735        // must include a `Slots:` line for Card.
1736        let cat = Catalog::build_builtins_only().expect("build");
1737        let prompt = cat.prompt();
1738        let card_start = prompt.find("### Card\n").expect("Card section present");
1739        let card_slice = &prompt[card_start..];
1740        // End at the next ### heading (or EOF).
1741        let end = card_slice[3..]
1742            .find("### ")
1743            .map(|i| i + 3)
1744            .unwrap_or(card_slice.len());
1745        let card_section = &card_slice[..end];
1746        assert!(
1747            card_section.contains("Slots: footer"),
1748            "Card section missing 'Slots: footer' line:\n{card_section}"
1749        );
1750    }
1751
1752    #[test]
1753    fn prompt_is_not_raw_json_schema() {
1754        let cat = Catalog::build_builtins_only().expect("build");
1755        let prompt = cat.prompt();
1756        assert!(
1757            prompt.starts_with("## Component Catalog"),
1758            "prompt() should start with Markdown header, not JSON"
1759        );
1760        assert!(
1761            !prompt.contains("\"$schema\""),
1762            "prompt() must not embed raw JSON Schema (ROADMAP caveat)"
1763        );
1764    }
1765
1766    #[test]
1767    fn catalog_contains_checkbox_group() {
1768        let cat = Catalog::build_builtins_only().expect("build");
1769        assert!(
1770            cat.component_schema("CheckboxGroup").is_some(),
1771            "CheckboxGroup must be registered in BUILTIN_SPECS as an alias for CheckboxList"
1772        );
1773    }
1774}