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