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