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, then one `### <Name>` section
799    /// per component (built-ins then plugins, both sorted by name). Each
800    /// section contains the description, a single `Props:` line with
801    /// `name (Type)` tuples, and (when non-empty) a `Slots:` line.
802    ///
803    /// The prompt is intentionally CONCISE (≤ 8 KB, CONTEXT D-17) — the full
804    /// JSON Schema is NOT embedded. Consumers wanting machine-readable schemas
805    /// use [`Self::json_schema`] or [`Self::component_schema`] (Plan 07 CLI).
806    ///
807    /// Deterministic (CONTEXT D-18): two builds of the same catalog yield
808    /// byte-identical output; order within sections follows alphabetical order
809    /// via [`Self::components_sorted`] and [`Self::plugin_components_sorted`].
810    pub fn prompt(&self) -> String {
811        let mut out = String::with_capacity(8 * 1024);
812        out.push_str("## Component Catalog\n\n");
813        for spec in self.components_sorted() {
814            render_component_section(&mut out, spec);
815        }
816        if self.plugin_components.is_empty() {
817            return out;
818        }
819        out.push_str("## Plugin Components\n\n");
820        for spec in self.plugin_components_sorted() {
821            render_component_section(&mut out, spec);
822        }
823        out
824    }
825}
826
827// ── Prompt generation helpers ─────────────────────────────────────────────────
828
829/// Append a single component section to `out`.
830///
831/// Shape:
832/// ```text
833/// ### Card
834/// Content container with title and optional footer slot.
835/// Props: title (String), description (Option<String>), ...
836/// Slots: footer (Vec<String> of element IDs) — body children come from Element.children.
837///
838/// ```
839fn render_component_section(out: &mut String, spec: &ComponentSpec) {
840    out.push_str("### ");
841    out.push_str(&spec.name);
842    out.push('\n');
843    out.push_str(&spec.description);
844    out.push('\n');
845
846    let props_line = render_props_line(&spec.props_schema);
847    if !props_line.is_empty() {
848        out.push_str("Props: ");
849        out.push_str(&props_line);
850        out.push('\n');
851    }
852    if !spec.slot_fields.is_empty() {
853        out.push_str("Slots: ");
854        out.push_str(&spec.slot_fields.join(", "));
855        out.push_str(" (Vec<String> of element IDs) — body children come from Element.children.\n");
856    }
857    out.push('\n');
858}
859
860/// Render the `Props:` line for a schemars-derived Props schema.
861///
862/// Walks `schema.properties` in serde-emit order. For each field:
863/// - `Option<T>` schemas (schemars emits `anyOf: [{...}, {type: null}]`) render as `Option<T>`.
864/// - Enum fields with ≤ 8 `enum` entries render inline as `name (a|b|c)`.
865/// - Enum fields with > 8 entries render as `name (one of N — see schema)`.
866/// - Plain scalar fields render as `name (String)` / `(i64)` / `(bool)`.
867/// - Array types render as `name (Vec<T>)`.
868///
869/// Returns an empty string if the schema has no `properties` map.
870fn render_props_line(schema: &Value) -> String {
871    let Some(obj) = schema.as_object() else {
872        return String::new();
873    };
874    let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
875        return String::new();
876    };
877    let required: std::collections::HashSet<&str> = obj
878        .get("required")
879        .and_then(|v| v.as_array())
880        .map(|arr| {
881            arr.iter()
882                .filter_map(|v| v.as_str())
883                .collect::<std::collections::HashSet<_>>()
884        })
885        .unwrap_or_default();
886
887    let parts: Vec<String> = props
888        .iter()
889        .map(|(name, field_schema)| {
890            let ty = render_field_type(field_schema, required.contains(name.as_str()));
891            format!("{name} ({ty})")
892        })
893        .collect();
894    parts.join(", ")
895}
896
897/// Render a single field's type string from its JSON Schema.
898fn render_field_type(schema: &Value, is_required: bool) -> String {
899    // 1) Detect enum inline: {type: "string", enum: [...]} or {enum: [...]}
900    if let Some(variants) = schema.get("enum").and_then(|v| v.as_array()) {
901        let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
902        let inner = render_enum_inline(&names);
903        return wrap_optional(inner, is_required);
904    }
905    // 2) anyOf / oneOf with null → Option<T>
906    for key in ["anyOf", "oneOf"] {
907        if let Some(arr) = schema.get(key).and_then(|v| v.as_array()) {
908            let has_null = arr
909                .iter()
910                .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
911            let non_null: Vec<&Value> = arr
912                .iter()
913                .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
914                .collect();
915            if has_null && non_null.len() == 1 {
916                let inner = render_field_type(non_null[0], true);
917                return format!("Option<{inner}>");
918            }
919        }
920    }
921    // 3) type: ["T", "null"] → Option<T>
922    if let Some(types) = schema.get("type").and_then(|v| v.as_array()) {
923        let non_null: Vec<&str> = types
924            .iter()
925            .filter_map(|v| v.as_str())
926            .filter(|s| *s != "null")
927            .collect();
928        let has_null = types.iter().any(|v| v.as_str() == Some("null"));
929        if has_null && non_null.len() == 1 {
930            return format!("Option<{}>", rust_for_json_type(non_null[0], schema));
931        }
932    }
933    // 4) Plain type
934    if let Some(t) = schema.get("type").and_then(|v| v.as_str()) {
935        let inner = rust_for_json_type(t, schema);
936        return wrap_optional(inner, is_required);
937    }
938    // 5) Fallback: $ref or complex
939    wrap_optional("<see schema>".to_string(), is_required)
940}
941
942/// Map a JSON Schema `type` + optional `items` to a Rust-ish type name.
943fn rust_for_json_type(t: &str, schema: &Value) -> String {
944    match t {
945        "string" => "String".to_string(),
946        "integer" => "i64".to_string(),
947        "number" => "f64".to_string(),
948        "boolean" => "bool".to_string(),
949        "array" => {
950            if let Some(items) = schema.get("items") {
951                let inner = render_field_type(items, true);
952                format!("Vec<{inner}>")
953            } else {
954                "Vec<Value>".to_string()
955            }
956        }
957        "object" => "Object".to_string(),
958        other => other.to_string(),
959    }
960}
961
962/// Render an enum's variants inline when count ≤ 8, else collapse.
963fn render_enum_inline(variants: &[&str]) -> String {
964    if variants.len() <= 8 {
965        variants.join("|")
966    } else {
967        format!("one of {} — see schema", variants.len())
968    }
969}
970
971/// Wrap inner type in `Option<...>` when the field is not required.
972fn wrap_optional(inner: String, is_required: bool) -> String {
973    if is_required {
974        inner
975    } else {
976        format!("Option<{inner}>")
977    }
978}
979
980/// Replace every `$data` / `$template` expression object in a value tree with `""`.
981///
982/// Used by [`Catalog::validate`] so that specs with runtime data-binding placeholders
983/// pass static schema validation. Expression objects have the shape
984/// `{"$data": "/path"}` or `{"$template": "literal {/path}"}` — single-key objects
985/// whose key is the expression marker. They are resolved at render time by
986/// [`crate::expression::resolve_expressions`]; the catalog validator must not reject
987/// them for failing type checks that only apply to the resolved value.
988fn strip_expr_objects(val: &Value) -> Value {
989    match val {
990        Value::Object(map) => {
991            if map.len() == 1 && (map.contains_key("$data") || map.contains_key("$template")) {
992                Value::String(String::new())
993            } else {
994                Value::Object(
995                    map.iter()
996                        .map(|(k, v)| (k.clone(), strip_expr_objects(v)))
997                        .collect(),
998                )
999            }
1000        }
1001        Value::Array(arr) => Value::Array(arr.iter().map(strip_expr_objects).collect()),
1002        other => other.clone(),
1003    }
1004}
1005
1006// ── Global singleton ───────────────────────────────────────────────────────────
1007
1008/// Access the global, immutable component catalog.
1009///
1010/// Lazily initialized on first call using the plugin registry state at that moment.
1011/// Subsequent plugin registrations do NOT propagate into the catalog (D-04).
1012///
1013/// # Panics
1014///
1015/// Panics if [`Catalog::build`] fails. In practice this only occurs if a registered
1016/// plugin returns a malformed JSON Schema from `props_schema()`. Built-in schemas
1017/// are derived at compile time and are always valid.
1018pub fn global_catalog() -> &'static Catalog {
1019    static GLOBAL_CATALOG: OnceLock<Catalog> = OnceLock::new();
1020    GLOBAL_CATALOG.get_or_init(|| {
1021        Catalog::build().expect("catalog build failed — see CatalogError for details")
1022    })
1023}
1024
1025// ── Tests ──────────────────────────────────────────────────────────────────────
1026
1027#[cfg(test)]
1028impl Catalog {
1029    /// Build a catalog from built-in specs only, skipping the global plugin registry.
1030    ///
1031    /// Tests that register plugins with invalid schemas pollute the global registry.
1032    /// This helper produces a clean, plugin-free catalog safe for use in any test order.
1033    pub(crate) fn build_builtins_only() -> Result<Self, CatalogError> {
1034        let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
1035        let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len());
1036        for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
1037            let raw = schema_fn();
1038            let schema = sanitize_schema(raw);
1039            per_component_schemas.insert((*name).to_string(), schema.clone());
1040            components.insert(
1041                (*name).to_string(),
1042                ComponentSpec {
1043                    name: (*name).to_string(),
1044                    description: (*desc).to_string(),
1045                    props_schema: schema,
1046                    is_plugin: false,
1047                    slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
1048                },
1049            );
1050        }
1051        let full_schema = assemble_full_schema(&per_component_schemas)?;
1052        let validator = jsonschema::validator_for(&full_schema)
1053            .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
1054        Ok(Catalog {
1055            components,
1056            plugin_components: HashMap::new(),
1057            full_schema,
1058            per_component_schemas,
1059            validator,
1060        })
1061    }
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067
1068    #[test]
1069    fn builtin_types_count_is_39() {
1070        // Drift guard — if this fails, Phase 116's BUILTIN_TYPES changed
1071        // without a corresponding catalog update. See Plan 02.
1072        // Updated to 40 in Phase 162 Plan 01 (CheckboxList added).
1073        // Updated to 42 when DetailPage was added.
1074        // Updated to 43 in Phase 175 Plan 04 (CheckboxGroup alias added).
1075        // Updated to 44 when MediaCardGrid was added.
1076        assert_eq!(crate::render::BUILTIN_TYPES.len(), 44);
1077    }
1078
1079    #[test]
1080    fn builtin_specs_len_matches_dispatch() {
1081        assert_eq!(BUILTIN_SPECS.len(), crate::render::BUILTIN_TYPES.len());
1082        assert_eq!(BUILTIN_SPECS.len(), 44);
1083    }
1084
1085    #[test]
1086    fn builtin_specs_names_match_dispatch() {
1087        use std::collections::HashSet;
1088        let specs: HashSet<&str> = BUILTIN_SPECS.iter().map(|(n, ..)| *n).collect();
1089        let types: HashSet<&str> = crate::render::BUILTIN_TYPES.iter().copied().collect();
1090        assert_eq!(specs, types, "BUILTIN_SPECS names must match BUILTIN_TYPES");
1091    }
1092
1093    #[test]
1094    fn build_populates_all_builtins() {
1095        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1096        let cat = Catalog::build_builtins_only().expect("build succeeds");
1097        for name in crate::render::BUILTIN_TYPES.iter() {
1098            assert!(
1099                cat.components.contains_key(*name),
1100                "built-in '{name}' missing from catalog.components"
1101            );
1102            let spec = &cat.components[*name];
1103            assert_eq!(spec.name, *name);
1104            assert!(
1105                !spec.description.is_empty(),
1106                "'{name}' has empty description"
1107            );
1108            assert!(
1109                spec.props_schema.is_object(),
1110                "'{name}' props_schema is not a JSON object"
1111            );
1112            assert!(!spec.is_plugin);
1113        }
1114    }
1115
1116    #[test]
1117    fn build_card_has_footer_slot() {
1118        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1119        let cat = Catalog::build_builtins_only().expect("build succeeds");
1120        let card = &cat.components["Card"];
1121        assert_eq!(card.slot_fields, vec!["footer"]);
1122    }
1123
1124    #[test]
1125    fn build_modal_has_footer_slot() {
1126        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1127        let cat = Catalog::build_builtins_only().expect("build succeeds");
1128        let modal = &cat.components["Modal"];
1129        assert_eq!(modal.slot_fields, vec!["footer"]);
1130    }
1131
1132    #[test]
1133    fn build_pageheader_has_actions_slot() {
1134        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1135        let cat = Catalog::build_builtins_only().expect("build succeeds");
1136        let ph = &cat.components["PageHeader"];
1137        assert_eq!(ph.slot_fields, vec!["actions"]);
1138    }
1139
1140    #[test]
1141    fn build_text_has_no_slots() {
1142        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1143        let cat = Catalog::build_builtins_only().expect("build succeeds");
1144        assert!(cat.components["Text"].slot_fields.is_empty());
1145    }
1146
1147    #[test]
1148    fn build_populates_per_component_schemas() {
1149        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1150        let cat = Catalog::build_builtins_only().expect("build succeeds");
1151        assert_eq!(
1152            cat.per_component_schemas.len(),
1153            BUILTIN_SPECS.len() + cat.plugin_components.len()
1154        );
1155    }
1156
1157    #[test]
1158    fn sanitize_schema_rewrites_definitions_to_dollar_defs() {
1159        let raw = serde_json::json!({
1160            "type": "object",
1161            "definitions": { "Foo": { "type": "string" } },
1162            "properties": {
1163                "x": { "$ref": "#/definitions/Foo" }
1164            }
1165        });
1166        let out = sanitize_schema(raw);
1167        assert!(out.get("definitions").is_none());
1168        assert!(out.get("$defs").is_some());
1169        assert_eq!(
1170            out["properties"]["x"]["$ref"].as_str().unwrap(),
1171            "#/$defs/Foo"
1172        );
1173    }
1174
1175    #[test]
1176    fn sanitize_schema_is_idempotent() {
1177        let raw = serde_json::json!({
1178            "type": "object",
1179            "$defs": { "Foo": { "type": "string" } },
1180            "properties": {
1181                "x": { "$ref": "#/$defs/Foo" }
1182            }
1183        });
1184        let once = sanitize_schema(raw.clone());
1185        let twice = sanitize_schema(once.clone());
1186        assert_eq!(once, twice);
1187        // Existing $defs should remain, no definitions key introduced.
1188        assert!(twice.get("definitions").is_none());
1189        assert!(twice.get("$defs").is_some());
1190    }
1191
1192    #[test]
1193    fn json_schema_has_spec_envelope_shape() {
1194        // Use build_builtins_only() to avoid global plugin registry pollution
1195        // from build_discovers_plugins_and_rejects_invalid_schema (BadPlugin_117).
1196        let cat = Catalog::build_builtins_only().expect("build");
1197        let schema = cat.json_schema();
1198        assert_eq!(schema["$id"], "ferro-json-ui/v2");
1199        assert_eq!(schema["type"], "object");
1200        let required: Vec<&str> = schema["required"]
1201            .as_array()
1202            .unwrap()
1203            .iter()
1204            .map(|v| v.as_str().unwrap())
1205            .collect();
1206        assert!(required.contains(&"$schema"));
1207        assert!(required.contains(&"root"));
1208        assert!(required.contains(&"elements"));
1209    }
1210
1211    #[test]
1212    fn json_schema_has_action_and_visibility_defs() {
1213        let cat = Catalog::build_builtins_only().expect("build");
1214        let schema = cat.json_schema();
1215        assert!(
1216            schema["$defs"]["Action"].is_object(),
1217            "$defs/Action missing"
1218        );
1219        assert!(
1220            schema["$defs"]["Visibility"].is_object(),
1221            "$defs/Visibility missing"
1222        );
1223        assert!(
1224            schema["$defs"]["Element"].is_object(),
1225            "$defs/Element missing"
1226        );
1227    }
1228
1229    #[test]
1230    fn json_schema_oneof_covers_all_builtins() {
1231        let cat = Catalog::build_builtins_only().expect("build");
1232        let schema = cat.json_schema();
1233        // oneOf is at the Element level (discriminates on element.type, not props.type).
1234        let one_of = schema["$defs"]["Element"]["oneOf"]
1235            .as_array()
1236            .expect("Element.oneOf is an array");
1237
1238        // Extract every const discriminator from the allOf[0] branch.
1239        let mut discriminators: std::collections::HashSet<String> =
1240            std::collections::HashSet::new();
1241        for variant in one_of {
1242            let c = variant["allOf"][0]["properties"]["type"]["const"]
1243                .as_str()
1244                .expect("every variant pins a type const");
1245            discriminators.insert(c.to_string());
1246        }
1247
1248        for name in crate::render::BUILTIN_TYPES.iter() {
1249            assert!(
1250                discriminators.contains(*name),
1251                "oneOf is missing discriminator for '{name}'"
1252            );
1253        }
1254
1255        // Built-ins only — exactly BUILTIN_TYPES.len() variants.
1256        assert_eq!(
1257            discriminators.len(),
1258            crate::render::BUILTIN_TYPES.len(),
1259            "oneOf variant count mismatch"
1260        );
1261    }
1262
1263    #[test]
1264    fn json_schema_is_valid() {
1265        use jsonschema::draft202012;
1266        let cat = Catalog::build_builtins_only().expect("build");
1267        let schema = cat.json_schema();
1268        assert!(
1269            draft202012::meta::is_valid(schema),
1270            "assembled full_schema did not meta-validate as Draft 2020-12"
1271        );
1272    }
1273
1274    #[test]
1275    fn validator_is_compiled_once_and_usable() {
1276        let cat = Catalog::build_builtins_only().expect("build");
1277        // The validator field is private — we prove it's real by validating
1278        // a minimal valid spec value. If the validator were stale / null /
1279        // placeholder, this would fail or mis-report.
1280        let minimal_valid = serde_json::json!({
1281            "$schema": "ferro-json-ui/v2",
1282            "root": "r",
1283            "elements": {
1284                "r": { "type": "Text", "props": { "content": "hi" } }
1285            }
1286        });
1287        // Should succeed — full-schema envelope accepts this shape.
1288        assert!(cat.validator.is_valid(&minimal_valid));
1289    }
1290
1291    #[test]
1292    fn validator_rejects_wrong_schema_version() {
1293        let cat = Catalog::build_builtins_only().expect("build");
1294        let wrong_version = serde_json::json!({
1295            "$schema": "ferro-json-ui/v99-wrong",
1296            "root": "r",
1297            "elements": {
1298                "r": { "type": "Text", "props": { "content": "hi" } }
1299            }
1300        });
1301        assert!(
1302            !cat.validator.is_valid(&wrong_version),
1303            "validator should reject unknown $schema version via const"
1304        );
1305    }
1306
1307    #[test]
1308    fn oneof_variants_are_deterministic_sorted() {
1309        let cat1 = Catalog::build_builtins_only().expect("build 1");
1310        let cat2 = Catalog::build_builtins_only().expect("build 2");
1311        // Byte-exact equality guarantees deterministic output (CONTEXT D-18).
1312        assert_eq!(
1313            serde_json::to_string(cat1.json_schema()).unwrap(),
1314            serde_json::to_string(cat2.json_schema()).unwrap()
1315        );
1316    }
1317
1318    // ── validate() tests (Plan 04) ────────────────────────────────────────────
1319
1320    /// Build a minimal valid Spec with one Element of the given type + props.
1321    fn test_spec_with(type_name: &str, props: Value) -> crate::spec::Spec {
1322        use crate::spec::{Element, Spec};
1323        use std::collections::HashMap;
1324        let mut elements = HashMap::new();
1325        elements.insert(
1326            "r".to_string(),
1327            Element {
1328                type_name: type_name.to_string(),
1329                props,
1330                children: Vec::new(),
1331                action: None,
1332                visible: None,
1333                each: None,
1334                if_: None,
1335            },
1336        );
1337        Spec {
1338            schema: crate::spec::SCHEMA_VERSION.to_string(),
1339            root: "r".to_string(),
1340            elements,
1341            title: None,
1342            layout: None,
1343            data: Value::Null,
1344        }
1345    }
1346
1347    #[test]
1348    fn validate_positive_per_type() {
1349        // Representative subset of built-ins — confirms validate() passes for
1350        // minimally valid elements. Full 39-type coverage lives in Plan 07.
1351        let cat = Catalog::build_builtins_only().expect("build");
1352        let cases: Vec<(&str, Value)> = vec![
1353            ("Text", serde_json::json!({ "content": "hi" })),
1354            ("Button", serde_json::json!({ "label": "Save" })),
1355            ("Badge", serde_json::json!({ "label": "New" })),
1356            ("Separator", serde_json::json!({})),
1357        ];
1358        for (ty, props) in cases {
1359            let spec = test_spec_with(ty, props.clone());
1360            match cat.validate(&spec) {
1361                Ok(()) => {}
1362                Err(errs) => panic!("validate({ty}) failed: {errs:?}"),
1363            }
1364        }
1365    }
1366
1367    #[test]
1368    fn validate_unknown_type() {
1369        let cat = Catalog::build_builtins_only().expect("build");
1370        let spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1371        let errs = cat.validate(&spec).expect_err("should fail");
1372        assert!(
1373            errs.iter().any(|e| matches!(
1374                e,
1375                CatalogError::UnknownType { type_name, .. } if type_name == "NotARealComponent"
1376            )),
1377            "expected UnknownType for NotARealComponent; got {errs:?}"
1378        );
1379    }
1380
1381    #[test]
1382    fn validate_missing_required_prop() {
1383        // CardProps.title is required (no Option, no #[serde(default)]).
1384        // Passing {} props should produce PropsInvalid.
1385        let cat = Catalog::build_builtins_only().expect("build");
1386        let spec = test_spec_with("Card", serde_json::json!({}));
1387        let errs = cat.validate(&spec).expect_err("should fail");
1388        assert!(
1389            errs.iter().any(|e| matches!(
1390                e,
1391                CatalogError::PropsInvalid { type_name, .. } if type_name == "Card"
1392            )),
1393            "expected PropsInvalid for missing required 'title'; got {errs:?}"
1394        );
1395    }
1396
1397    #[test]
1398    fn validate_bad_schema_version() {
1399        let cat = Catalog::build_builtins_only().expect("build");
1400        let mut spec = test_spec_with("Text", serde_json::json!({ "content": "hi" }));
1401        spec.schema = "ferro-json-ui/v99-wrong".to_string();
1402        let errs = cat.validate(&spec).expect_err("should fail");
1403        assert!(
1404            errs.iter()
1405                .any(|e| matches!(e, CatalogError::SpecInvalid { .. })),
1406            "expected SpecInvalid for wrong $schema version; got {errs:?}"
1407        );
1408    }
1409
1410    #[test]
1411    fn validate_pre_dispatch_short_circuits() {
1412        // Stage 1 must short-circuit: unknown type + malformed envelope →
1413        // only UnknownType surfaces (not SpecInvalid or PropsInvalid).
1414        let cat = Catalog::build_builtins_only().expect("build");
1415        let mut spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1416        spec.schema = "ferro-json-ui/v99-wrong".to_string();
1417        let errs = cat.validate(&spec).expect_err("should fail");
1418
1419        let has_unknown = errs
1420            .iter()
1421            .any(|e| matches!(e, CatalogError::UnknownType { .. }));
1422        let has_spec_invalid = errs
1423            .iter()
1424            .any(|e| matches!(e, CatalogError::SpecInvalid { .. }));
1425        let has_props_invalid = errs
1426            .iter()
1427            .any(|e| matches!(e, CatalogError::PropsInvalid { .. }));
1428
1429        assert!(has_unknown, "expected UnknownType");
1430        assert!(
1431            !has_spec_invalid,
1432            "Stage 3 ran despite Stage 1 failing: {errs:?}"
1433        );
1434        assert!(
1435            !has_props_invalid,
1436            "Stage 2 ran despite Stage 1 failing: {errs:?}"
1437        );
1438    }
1439
1440    #[test]
1441    fn validator_is_cached_not_recompiled() {
1442        // Structural guarantee: self.validator is a plain field, not recompiled
1443        // per validate() call. This test approximates that by running validate()
1444        // 100 times against a single Catalog without panic or regression.
1445        let cat = Catalog::build_builtins_only().expect("build");
1446        for _ in 0..100 {
1447            let spec = test_spec_with("Text", serde_json::json!({ "content": "x" }));
1448            assert!(cat.validate(&spec).is_ok());
1449        }
1450    }
1451
1452    #[test]
1453    fn validate_accumulates_multiple_errors_across_elements() {
1454        // Two elements with missing required props → two PropsInvalid errors.
1455        use crate::spec::{Element, Spec};
1456        use std::collections::HashMap;
1457        let cat = Catalog::build_builtins_only().expect("build");
1458        let mut elements = HashMap::new();
1459        elements.insert(
1460            "a".to_string(),
1461            Element {
1462                type_name: "Card".to_string(),
1463                props: serde_json::json!({}), // missing required "title"
1464                children: Vec::new(),
1465                action: None,
1466                visible: None,
1467                each: None,
1468                if_: None,
1469            },
1470        );
1471        elements.insert(
1472            "b".to_string(),
1473            Element {
1474                type_name: "Button".to_string(),
1475                props: serde_json::json!({}), // missing required "label"
1476                children: Vec::new(),
1477                action: None,
1478                visible: None,
1479                each: None,
1480                if_: None,
1481            },
1482        );
1483        let spec = Spec {
1484            schema: crate::spec::SCHEMA_VERSION.to_string(),
1485            root: "a".to_string(),
1486            elements,
1487            title: None,
1488            layout: None,
1489            data: Value::Null,
1490        };
1491        let errs = cat.validate(&spec).expect_err("should fail");
1492        let props_invalid_count = errs
1493            .iter()
1494            .filter(|e| matches!(e, CatalogError::PropsInvalid { .. }))
1495            .count();
1496        assert!(
1497            props_invalid_count >= 2,
1498            "expected at least 2 PropsInvalid errors; got {errs:?}"
1499        );
1500    }
1501
1502    /// Combined plugin discovery + invalid schema rejection test.
1503    ///
1504    /// Uses unique names (`GoodPlugin_117`, `BadPlugin_117`) to avoid collisions
1505    /// with other test registrations. The bad plugin is registered after the good
1506    /// one to confirm discovery of the good plugin precedes the rejection.
1507    #[test]
1508    fn build_discovers_plugins_and_rejects_invalid_schema() {
1509        use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
1510
1511        struct GoodPlugin;
1512        impl JsonUiPlugin for GoodPlugin {
1513            fn component_type(&self) -> &str {
1514                "GoodPlugin_117"
1515            }
1516            fn props_schema(&self) -> Value {
1517                serde_json::json!({ "type": "object" })
1518            }
1519            fn render(&self, _: &Value, _: &Value) -> String {
1520                String::new()
1521            }
1522            fn css_assets(&self) -> Vec<Asset> {
1523                vec![]
1524            }
1525            fn js_assets(&self) -> Vec<Asset> {
1526                vec![]
1527            }
1528            fn init_script(&self) -> Option<String> {
1529                None
1530            }
1531        }
1532
1533        register_plugin(GoodPlugin);
1534
1535        // Positive discovery: GoodPlugin should appear in plugin_components.
1536        let cat = Catalog::build().expect("build succeeds with valid plugin only");
1537        assert!(
1538            cat.plugin_components.contains_key("GoodPlugin_117"),
1539            "plugin 'GoodPlugin_117' should have been discovered"
1540        );
1541        assert!(cat.plugin_components["GoodPlugin_117"].is_plugin);
1542
1543        // Now register a bad plugin and confirm build fails with the plugin name embedded.
1544        struct BadPlugin;
1545        impl JsonUiPlugin for BadPlugin {
1546            fn component_type(&self) -> &str {
1547                "BadPlugin_117"
1548            }
1549            fn props_schema(&self) -> Value {
1550                // JSON Schema requires `type` to be a string or array of strings.
1551                // Number 42 is invalid → validator_for() rejects it.
1552                serde_json::json!({ "type": 42 })
1553            }
1554            fn render(&self, _: &Value, _: &Value) -> String {
1555                String::new()
1556            }
1557            fn css_assets(&self) -> Vec<Asset> {
1558                vec![]
1559            }
1560            fn js_assets(&self) -> Vec<Asset> {
1561                vec![]
1562            }
1563            fn init_script(&self) -> Option<String> {
1564                None
1565            }
1566        }
1567
1568        register_plugin(BadPlugin);
1569        match Catalog::build() {
1570            Err(CatalogError::BuildFailed(msg)) => {
1571                assert!(
1572                    msg.contains("BadPlugin_117"),
1573                    "error should mention plugin name, got: {msg}"
1574                );
1575            }
1576            Err(other) => panic!("expected BuildFailed mentioning BadPlugin_117, got: {other:?}"),
1577            Ok(_) => panic!("expected build to fail due to invalid plugin schema"),
1578        }
1579    }
1580
1581    // ── component_schema / sorted accessor tests (Plan 05) ───────────────────
1582
1583    #[test]
1584    fn component_schema_returns_props_only() {
1585        // ROADMAP SC-5 canonical example: catalog.component_schema("Card") must
1586        // return the CardProps schema (NOT the Element wrapper from
1587        // $defs/Element.properties.props in the full schema).
1588        let cat = Catalog::build_builtins_only().expect("build");
1589        let schema = cat
1590            .component_schema("Card")
1591            .expect("Card is a built-in component");
1592
1593        // A Props schema is an object with a `properties` map. The Element
1594        // envelope would have a `type` + `props` + `children` layout — we
1595        // assert the Props-only shape by checking for CardProps fields.
1596        let obj = schema
1597            .as_object()
1598            .expect("Card props schema is a JSON object");
1599
1600        // Expect "type": "object" or equivalent (schemars uses `type` or `oneOf`).
1601        assert!(
1602            obj.contains_key("type") || obj.contains_key("oneOf") || obj.contains_key("anyOf"),
1603            "CardProps schema should be a structural object schema; got {obj:?}"
1604        );
1605
1606        // Expect CardProps-specific field "title" exists in `properties`.
1607        if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
1608            assert!(
1609                props.contains_key("title"),
1610                "CardProps schema.properties should include 'title'; got keys: {:?}",
1611                props.keys().collect::<Vec<_>>()
1612            );
1613        } else {
1614            panic!(
1615                "CardProps schema missing top-level 'properties' map — \
1616                 sanitizer or Plan 02 may be wrong. Got: {}",
1617                serde_json::to_string_pretty(schema).unwrap_or_default()
1618            );
1619        }
1620
1621        // Must NOT be the Element envelope (would mean we accidentally returned
1622        // full_schema["$defs"]["Element"] or similar — CONTEXT D-19).
1623        let is_element_wrapper = obj
1624            .get("properties")
1625            .and_then(|v| v.as_object())
1626            .map(|p| p.contains_key("children") && p.contains_key("props"))
1627            .unwrap_or(false);
1628        assert!(
1629            !is_element_wrapper,
1630            "component_schema('Card') returned an Element wrapper; must be Props-only (CONTEXT D-19)"
1631        );
1632    }
1633
1634    #[test]
1635    fn component_schema_none_for_unknown() {
1636        let cat = Catalog::build_builtins_only().expect("build");
1637        assert!(
1638            cat.component_schema("NotARealComponent_117_05").is_none(),
1639            "unknown component must return None"
1640        );
1641        // Empty string is also "unknown".
1642        assert!(cat.component_schema("").is_none());
1643    }
1644
1645    #[test]
1646    fn component_schema_resolves_every_builtin() {
1647        // Parallel safety net for SC-5: every name in BUILTIN_TYPES must have a
1648        // per-component schema. If any is missing, Plan 02's BUILTIN_SPECS table
1649        // or the build loop dropped an entry.
1650        let cat = Catalog::build_builtins_only().expect("build");
1651        for name in crate::render::BUILTIN_TYPES.iter() {
1652            assert!(
1653                cat.component_schema(name).is_some(),
1654                "built-in '{name}' has no per-component schema"
1655            );
1656        }
1657    }
1658
1659    #[test]
1660    fn components_sorted_yields_ascending_by_name() {
1661        let cat = Catalog::build_builtins_only().expect("build");
1662        let names: Vec<String> = cat
1663            .components_sorted()
1664            .map(|spec| spec.name.clone())
1665            .collect();
1666        assert_eq!(names.len(), crate::render::BUILTIN_TYPES.len());
1667        let mut sorted = names.clone();
1668        sorted.sort();
1669        assert_eq!(
1670            names, sorted,
1671            "components_sorted must yield ascending order"
1672        );
1673
1674        // plugin_components_sorted returns the plugin side; may be empty.
1675        let plugin_names: Vec<String> = cat
1676            .plugin_components_sorted()
1677            .map(|spec| spec.name.clone())
1678            .collect();
1679        let mut plugin_sorted = plugin_names.clone();
1680        plugin_sorted.sort();
1681        assert_eq!(
1682            plugin_names, plugin_sorted,
1683            "plugin_components_sorted must yield ascending order"
1684        );
1685    }
1686
1687    // ── prompt() tests (Plan 06) ─────────────────────────────────────────────
1688
1689    #[test]
1690    fn prompt_under_size_budget() {
1691        let cat = Catalog::build_builtins_only().expect("build");
1692        let prompt = cat.prompt();
1693        let bytes = prompt.len();
1694        // Budget bumped from 8 KB to 9 KB in Phase 162 Plan 01 (CheckboxList added, 40 components).
1695        // Budget bumped from 9 KB to 10 KB in Phase 175 Plan 04 (CheckboxGroup alias added, 43 components).
1696        assert!(
1697            bytes <= 10 * 1024,
1698            "prompt() is {bytes} bytes, exceeds 10 KB budget (CONTEXT D-17)"
1699        );
1700    }
1701
1702    #[test]
1703    fn prompt_mentions_every_builtin() {
1704        let cat = Catalog::build_builtins_only().expect("build");
1705        let prompt = cat.prompt();
1706        for name in crate::render::BUILTIN_TYPES.iter() {
1707            let heading = format!("### {name}\n");
1708            assert!(
1709                prompt.contains(&heading),
1710                "prompt() missing section heading for '{name}'"
1711            );
1712        }
1713    }
1714
1715    #[test]
1716    fn prompt_is_deterministic() {
1717        let cat1 = Catalog::build_builtins_only().expect("build 1");
1718        let cat2 = Catalog::build_builtins_only().expect("build 2");
1719        assert_eq!(
1720            cat1.prompt(),
1721            cat2.prompt(),
1722            "prompt() must be deterministic"
1723        );
1724    }
1725
1726    #[test]
1727    fn prompt_documents_slot_fields() {
1728        // CardProps has slot_fields = ["footer"] (set in Plan 02). The prompt
1729        // must include a `Slots:` line for Card.
1730        let cat = Catalog::build_builtins_only().expect("build");
1731        let prompt = cat.prompt();
1732        let card_start = prompt.find("### Card\n").expect("Card section present");
1733        let card_slice = &prompt[card_start..];
1734        // End at the next ### heading (or EOF).
1735        let end = card_slice[3..]
1736            .find("### ")
1737            .map(|i| i + 3)
1738            .unwrap_or(card_slice.len());
1739        let card_section = &card_slice[..end];
1740        assert!(
1741            card_section.contains("Slots: footer"),
1742            "Card section missing 'Slots: footer' line:\n{card_section}"
1743        );
1744    }
1745
1746    #[test]
1747    fn prompt_is_not_raw_json_schema() {
1748        let cat = Catalog::build_builtins_only().expect("build");
1749        let prompt = cat.prompt();
1750        assert!(
1751            prompt.starts_with("## Component Catalog"),
1752            "prompt() should start with Markdown header, not JSON"
1753        );
1754        assert!(
1755            !prompt.contains("\"$schema\""),
1756            "prompt() must not embed raw JSON Schema (ROADMAP caveat)"
1757        );
1758    }
1759
1760    #[test]
1761    fn catalog_contains_checkbox_group() {
1762        let cat = Catalog::build_builtins_only().expect("build");
1763        assert!(
1764            cat.component_schema("CheckboxGroup").is_some(),
1765            "CheckboxGroup must be registered in BUILTIN_SPECS as an alias for CheckboxList"
1766        );
1767    }
1768}