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