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 tone-styled status label.",
141        || to_value(schema_for!(BadgeProps)).unwrap(),
142        &[],
143    ),
144    (
145        "Alert",
146        "Inline notice with neutral / success / warning / destructive tones.",
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 tone-colored left 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    ///    **2b. Retired prop names** — prop names renamed in the canonical
678    ///    variant/tone/size migration (`Card.variant` → `appearance`,
679    ///    `Badge.variant` → `tone`, …) are hard errors. serde ignores unknown
680    ///    keys and the per-component schemas do not set
681    ///    `additionalProperties: false`, so without this lint a retired name
682    ///    would be silently dropped — an invisible visual downgrade.
683    ///
684    /// 3. **Envelope check** — serialize the full `Spec` and run it through the
685    ///    cached `self.validator` (compiled once in [`Catalog::build`], SCHEMA-03).
686    ///    Errors become [`CatalogError::SpecInvalid`].
687    ///
688    /// Errors accumulate across Stages 2 and 3 so a caller sees every issue at once.
689    pub fn validate(&self, spec: &crate::spec::Spec) -> Result<(), Vec<CatalogError>> {
690        let mut errors: Vec<CatalogError> = Vec::new();
691
692        // === Stage 1: type_name whitelist (O(1) per element) ===
693        for (id, el) in &spec.elements {
694            let known = self.components.contains_key(&el.type_name)
695                || self.plugin_components.contains_key(&el.type_name);
696            if !known {
697                errors.push(CatalogError::UnknownType {
698                    element_id: id.clone(),
699                    type_name: el.type_name.clone(),
700                });
701            }
702        }
703        // SHORT-CIRCUIT: if any type is unknown, skip Stages 2 & 3.
704        // Rationale: Stage 3's full-spec oneOf would emit dozens of
705        // "no variant matched" errors for unknown types, obscuring the signal.
706        // Stage 2 would skip unknowns anyway.
707        if !errors.is_empty() {
708            return Err(errors);
709        }
710
711        // === Stage 2: per-element Props validation ===
712        for (id, el) in &spec.elements {
713            if let Some(schema) = self.per_component_schemas.get(&el.type_name) {
714                // Skip null props — null means "no props provided"; the schema's
715                // `required` list is the gate for required fields. When props is
716                // null the element carries no props object, which the envelope
717                // schema permits (props is optional per the element allOf shape).
718                if el.props.is_null() {
719                    continue;
720                }
721                // On-demand compile (CONTEXT D-12). Schemas are small (~50–200 LOC
722                // JSON); compile cost < 1 ms per component. Cache as
723                // HashMap<String, Validator> if profiling demands it.
724                let v = match jsonschema::validator_for(schema) {
725                    Ok(v) => v,
726                    Err(e) => {
727                        errors.push(CatalogError::BuildFailed(format!(
728                            "compiling per-component schema for '{}': {e}",
729                            el.type_name
730                        )));
731                        continue;
732                    }
733                };
734                // Strip $data/$template expression objects before schema validation.
735                // Expressions are resolved at render time against handler data — the
736                // static catalog validator cannot know the resolved type. We substitute
737                // expression objects with "" so string-typed fields pass; the runtime
738                // resolver (resolve_expressions) enforces the actual type via data binding.
739                let validation_props = strip_expr_objects(&el.props);
740                let mut per_elem_errs: Vec<String> = Vec::new();
741                for err in v.iter_errors(&validation_props) {
742                    per_elem_errs.push(format!("{}: {}", err.instance_path(), err));
743                }
744                if !per_elem_errs.is_empty() {
745                    errors.push(CatalogError::PropsInvalid {
746                        element_id: id.clone(),
747                        type_name: el.type_name.clone(),
748                        errors: per_elem_errs,
749                    });
750                }
751            }
752        }
753
754        // === Stage 2b: retired prop names (canonical vocabulary migration) ===
755        // serde ignores unknown keys and the per-component schemas do not set
756        // `additionalProperties: false`, so a retired prop name would otherwise
757        // decode cleanly and be silently dropped — turning the rename into an
758        // invisible visual downgrade (e.g. `Badge.variant: "success"` rendering
759        // a neutral badge). Flag renames as hard errors pointing at the new name.
760        for (id, el) in &spec.elements {
761            let mut renamed: Vec<String> = Vec::new();
762            for (ty, old, new) in RETIRED_PROPS {
763                if el.type_name == *ty && el.props.get(old).is_some() {
764                    renamed.push(format!(
765                        "/{old}: `{old}` was renamed to `{new}` — update the spec"
766                    ));
767                }
768            }
769            collect_retired_action_variants(&el.props, "", &mut renamed);
770            if let Some(action) = &el.action {
771                if let Ok(action_value) = serde_json::to_value(action) {
772                    collect_retired_action_variants(&action_value, "/action", &mut renamed);
773                }
774            }
775            if !renamed.is_empty() {
776                errors.push(CatalogError::PropsInvalid {
777                    element_id: id.clone(),
778                    type_name: el.type_name.clone(),
779                    errors: renamed,
780                });
781            }
782        }
783
784        // === Stage 3: full-spec envelope validation (cached validator, SCHEMA-03) ===
785        let spec_value = match serde_json::to_value(spec) {
786            Ok(v) => v,
787            Err(e) => {
788                errors.push(CatalogError::SchemaSerialization(e));
789                return Err(errors);
790            }
791        };
792        // Strip expression objects in the serialized spec for the same reason as Stage 2.
793        let stripped_spec_value = strip_expr_objects(&spec_value);
794        let mut envelope_errs: Vec<String> = Vec::new();
795        for err in self.validator.iter_errors(&stripped_spec_value) {
796            envelope_errs.push(format!("{}: {}", err.instance_path(), err));
797        }
798        if !envelope_errs.is_empty() {
799            errors.push(CatalogError::SpecInvalid {
800                errors: envelope_errs,
801            });
802        }
803
804        if errors.is_empty() {
805            Ok(())
806        } else {
807            Err(errors)
808        }
809    }
810
811    /// Return the per-component Props JSON Schema for `type_name`, or `None`
812    /// if the name is not registered as a built-in or plugin component.
813    ///
814    /// The returned schema is Props-only (NOT wrapped in the Element envelope
815    /// used by [`Self::json_schema`]). This is the schema shape Phase 120 AI
816    /// structured-output generation consumes, and what `ferro json-ui:schema
817    /// --component <name>` prints.
818    ///
819    /// The reference has the same lifetime as `&self` — zero-copy (CONTEXT D-15).
820    ///
821    /// Lookup is unified across built-ins and plugins via the
822    /// `per_component_schemas` map populated in [`Self::build`] (CONTEXT D-20
823    /// — plugin schemas are stored identically after meta-validation).
824    pub fn component_schema(&self, type_name: &str) -> Option<&Value> {
825        self.per_component_schemas.get(type_name)
826    }
827
828    /// Iterate built-in [`ComponentSpec`] entries sorted by name (ascending).
829    ///
830    /// Deterministic ordering is required by CONTEXT D-18 so that
831    /// [`Self::json_schema`], `prompt()` (Plan 06), and ferro-mcp
832    /// `json_ui_catalog` output (Plan 06 migration) produce byte-stable
833    /// results for snapshot tests.
834    pub fn components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
835        let mut entries: Vec<&ComponentSpec> = self.components.values().collect();
836        entries.sort_by(|a, b| a.name.cmp(&b.name));
837        entries.into_iter()
838    }
839
840    /// Iterate plugin [`ComponentSpec`] entries sorted by name (ascending).
841    ///
842    /// Separate from built-ins so consumers can format them in a distinct
843    /// section (ferro-mcp `json_ui_catalog.CatalogResponse` preserves the
844    /// `components` / `plugin_components` split per CONTEXT D-24).
845    pub fn plugin_components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
846        let mut entries: Vec<&ComponentSpec> = self.plugin_components.values().collect();
847        entries.sort_by(|a, b| a.name.cmp(&b.name));
848        entries.into_iter()
849    }
850
851    /// Generate a concise text system prompt summarizing every component.
852    ///
853    /// Format: `## Component Catalog` header, a one-line note explaining the
854    /// slot convention, then one `### <Name>` section per component (built-ins
855    /// then plugins, both sorted by name). Each section contains the
856    /// description, a single `Props:` line with `name (Type)` tuples, and
857    /// (when non-empty) a `Slots:` line listing slot field names only.
858    ///
859    /// The prompt is intentionally CONCISE (≤ 10 KB, CONTEXT D-17) — the full
860    /// JSON Schema is NOT embedded. Consumers wanting machine-readable schemas
861    /// use [`Self::json_schema`] or [`Self::component_schema`] (Plan 07 CLI).
862    ///
863    /// Deterministic (CONTEXT D-18): two builds of the same catalog yield
864    /// byte-identical output; order within sections follows alphabetical order
865    /// via [`Self::components_sorted`] and [`Self::plugin_components_sorted`].
866    pub fn prompt(&self) -> String {
867        let mut out = String::with_capacity(8 * 1024);
868        out.push_str("## Component Catalog\n\n");
869        out.push_str("Slot fields are Vec<String> of element IDs; body children come from Element.children.\n\n");
870        for spec in self.components_sorted() {
871            render_component_section(&mut out, spec);
872        }
873        if self.plugin_components.is_empty() {
874            return out;
875        }
876        out.push_str("## Plugin Components\n\n");
877        for spec in self.plugin_components_sorted() {
878            render_component_section(&mut out, spec);
879        }
880        out
881    }
882}
883
884// ── Retired prop-name lint (validate Stage 2b) ────────────────────────────────
885
886/// Element-level prop names retired by the canonical variant/tone/size
887/// migration: `(component type, retired prop, replacement prop)`.
888const RETIRED_PROPS: &[(&str, &str, &str)] = &[
889    ("Card", "variant", "appearance"),
890    ("Badge", "variant", "tone"),
891    ("Alert", "variant", "tone"),
892    ("Toast", "variant", "tone"),
893    ("ActionCard", "variant", "tone"),
894    ("MediaCardGrid", "badge_variant_key", "badge_tone_key"),
895];
896
897/// Recursively flag retired `variant` keys inside action-shaped objects
898/// embedded in props: `confirm: {..}` dialogs and `on_success`/`on_error`
899/// notify outcomes (e.g. inside `row_actions`, `buttons`, `actions` arrays).
900/// These decode through typed structs that ignore unknown keys, so without
901/// this walk an old `confirm.variant: "danger"` would silently lose its
902/// destructive styling.
903fn collect_retired_action_variants(value: &Value, path: &str, out: &mut Vec<String>) {
904    match value {
905        Value::Object(map) => {
906            for (key, child) in map {
907                let child_path = format!("{path}/{key}");
908                if let Value::Object(obj) = child {
909                    let is_confirm = key == "confirm";
910                    let is_notify_outcome = (key == "on_success" || key == "on_error")
911                        && obj.get("type").and_then(Value::as_str) == Some("notify");
912                    if (is_confirm || is_notify_outcome) && obj.contains_key("variant") {
913                        out.push(format!(
914                            "{child_path}/variant: `variant` was renamed to `tone` — update the spec"
915                        ));
916                    }
917                }
918                collect_retired_action_variants(child, &child_path, out);
919            }
920        }
921        Value::Array(arr) => {
922            for (i, child) in arr.iter().enumerate() {
923                collect_retired_action_variants(child, &format!("{path}/{i}"), out);
924            }
925        }
926        _ => {}
927    }
928}
929
930// ── Prompt generation helpers ─────────────────────────────────────────────────
931
932/// Append a single component section to `out`.
933///
934/// Shape:
935/// ```text
936/// ### Card
937/// Content container with title and optional footer slot.
938/// Props: title (String), description (Option<String>), ...
939/// Slots: footer
940///
941/// ```
942///
943/// The slot semantics (Vec<String> of element IDs, body children from
944/// Element.children) are declared once in the catalog header by
945/// [`Catalog::prompt`] rather than repeated per section.
946fn render_component_section(out: &mut String, spec: &ComponentSpec) {
947    out.push_str("### ");
948    out.push_str(&spec.name);
949    out.push('\n');
950    out.push_str(&spec.description);
951    out.push('\n');
952
953    let props_line = render_props_line(&spec.props_schema);
954    if !props_line.is_empty() {
955        out.push_str("Props: ");
956        out.push_str(&props_line);
957        out.push('\n');
958    }
959    if !spec.slot_fields.is_empty() {
960        out.push_str("Slots: ");
961        out.push_str(&spec.slot_fields.join(", "));
962        out.push('\n');
963    }
964    out.push('\n');
965}
966
967/// Render the `Props:` line for a schemars-derived Props schema.
968///
969/// Walks `schema.properties` in serde-emit order. For each field:
970/// - `Option<T>` schemas (schemars emits `anyOf: [{...}, {type: null}]`) render as `Option<T>`.
971/// - Enum fields with ≤ 8 `enum` entries render inline as `name (a|b|c)` —
972///   including fields referencing a local `$defs` enum (`$ref` resolved).
973/// - Enum fields with > 8 entries render as `name (one of N — see schema)`.
974/// - Plain scalar fields render as `name (String)` / `(i64)` / `(bool)`.
975/// - Array types render as `name (Vec<T>)`.
976///
977/// Returns an empty string if the schema has no `properties` map.
978fn render_props_line(schema: &Value) -> String {
979    let Some(obj) = schema.as_object() else {
980        return String::new();
981    };
982    let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
983        return String::new();
984    };
985    // Component-local $defs (per_component_schemas keep them) — lets enum-typed
986    // fields ($ref → $defs entry) render their values inline instead of
987    // `<see schema>`.
988    let defs = obj.get("$defs").and_then(|v| v.as_object());
989    let required: std::collections::HashSet<&str> = obj
990        .get("required")
991        .and_then(|v| v.as_array())
992        .map(|arr| {
993            arr.iter()
994                .filter_map(|v| v.as_str())
995                .collect::<std::collections::HashSet<_>>()
996        })
997        .unwrap_or_default();
998
999    let parts: Vec<String> = props
1000        .iter()
1001        .map(|(name, field_schema)| {
1002            let ty = render_field_type(field_schema, required.contains(name.as_str()), defs);
1003            format!("{name} ({ty})")
1004        })
1005        .collect();
1006    parts.join(", ")
1007}
1008
1009/// Resolve a `#/$defs/<Name>` reference against a component schema's local
1010/// `$defs`, returning the enum value names ONLY when the target is a plain
1011/// string enum (`{"enum": [...]}`). Non-enum refs return `None` so
1012/// [`render_field_type`]'s `<see schema>` fallback stays unchanged for them.
1013fn resolve_local_enum_ref<'a>(
1014    schema: &'a Value,
1015    defs: Option<&'a serde_json::Map<String, Value>>,
1016) -> Option<Vec<&'a str>> {
1017    let name = schema.get("$ref")?.as_str()?.strip_prefix("#/$defs/")?;
1018    let target = defs?.get(name)?;
1019    let arr = target.get("enum")?.as_array()?;
1020    Some(arr.iter().filter_map(|v| v.as_str()).collect())
1021}
1022
1023/// Render a single field's type string from its JSON Schema.
1024///
1025/// `defs` is the component schema's local `$defs` map (when present) so
1026/// enum-typed fields referenced via `$ref` render their values inline.
1027fn render_field_type(
1028    schema: &Value,
1029    is_required: bool,
1030    defs: Option<&serde_json::Map<String, Value>>,
1031) -> String {
1032    // 1) Detect enum inline: {type: "string", enum: [...]} or {enum: [...]}
1033    if let Some(variants) = schema.get("enum").and_then(|v| v.as_array()) {
1034        let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
1035        let inner = render_enum_inline(&names);
1036        return wrap_optional(inner, is_required);
1037    }
1038    // 2) anyOf / oneOf with null → Option<T>
1039    for key in ["anyOf", "oneOf"] {
1040        if let Some(arr) = schema.get(key).and_then(|v| v.as_array()) {
1041            let has_null = arr
1042                .iter()
1043                .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
1044            let non_null: Vec<&Value> = arr
1045                .iter()
1046                .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
1047                .collect();
1048            if has_null && non_null.len() == 1 {
1049                let inner = render_field_type(non_null[0], true, defs);
1050                return format!("Option<{inner}>");
1051            }
1052        }
1053    }
1054    // 3) type: ["T", "null"] → Option<T>
1055    if let Some(types) = schema.get("type").and_then(|v| v.as_array()) {
1056        let non_null: Vec<&str> = types
1057            .iter()
1058            .filter_map(|v| v.as_str())
1059            .filter(|s| *s != "null")
1060            .collect();
1061        let has_null = types.iter().any(|v| v.as_str() == Some("null"));
1062        if has_null && non_null.len() == 1 {
1063            return format!("Option<{}>", rust_for_json_type(non_null[0], schema, defs));
1064        }
1065    }
1066    // 4) Plain type
1067    if let Some(t) = schema.get("type").and_then(|v| v.as_str()) {
1068        let inner = rust_for_json_type(t, schema, defs);
1069        return wrap_optional(inner, is_required);
1070    }
1071    // 5) $ref to a local plain string enum → inline its values (the canonical
1072    //    Variant/Tone/Size land here; non-enum refs keep the fallback below).
1073    if let Some(names) = resolve_local_enum_ref(schema, defs) {
1074        return wrap_optional(render_enum_inline(&names), is_required);
1075    }
1076    // 6) Fallback: $ref or complex
1077    wrap_optional("<see schema>".to_string(), is_required)
1078}
1079
1080/// Map a JSON Schema `type` + optional `items` to a Rust-ish type name.
1081fn rust_for_json_type(
1082    t: &str,
1083    schema: &Value,
1084    defs: Option<&serde_json::Map<String, Value>>,
1085) -> String {
1086    match t {
1087        "string" => "String".to_string(),
1088        "integer" => "i64".to_string(),
1089        "number" => "f64".to_string(),
1090        "boolean" => "bool".to_string(),
1091        "array" => {
1092            if let Some(items) = schema.get("items") {
1093                let inner = render_field_type(items, true, defs);
1094                format!("Vec<{inner}>")
1095            } else {
1096                "Vec<Value>".to_string()
1097            }
1098        }
1099        "object" => "Object".to_string(),
1100        other => other.to_string(),
1101    }
1102}
1103
1104/// Render an enum's variants inline when count ≤ 8, else collapse.
1105fn render_enum_inline(variants: &[&str]) -> String {
1106    if variants.len() <= 8 {
1107        variants.join("|")
1108    } else {
1109        format!("one of {} — see schema", variants.len())
1110    }
1111}
1112
1113/// Wrap inner type in `Option<...>` when the field is not required.
1114fn wrap_optional(inner: String, is_required: bool) -> String {
1115    if is_required {
1116        inner
1117    } else {
1118        format!("Option<{inner}>")
1119    }
1120}
1121
1122/// Replace every `$data` / `$template` expression object in a value tree with `""`.
1123///
1124/// Used by [`Catalog::validate`] so that specs with runtime data-binding placeholders
1125/// pass static schema validation. Expression objects have the shape
1126/// `{"$data": "/path"}` or `{"$template": "literal {/path}"}` — single-key objects
1127/// whose key is the expression marker. They are resolved at render time by
1128/// [`crate::expression::resolve_expressions`]; the catalog validator must not reject
1129/// them for failing type checks that only apply to the resolved value.
1130fn strip_expr_objects(val: &Value) -> Value {
1131    match val {
1132        Value::Object(map) => {
1133            if map.len() == 1 && (map.contains_key("$data") || map.contains_key("$template")) {
1134                Value::String(String::new())
1135            } else {
1136                Value::Object(
1137                    map.iter()
1138                        .map(|(k, v)| (k.clone(), strip_expr_objects(v)))
1139                        .collect(),
1140                )
1141            }
1142        }
1143        Value::Array(arr) => Value::Array(arr.iter().map(strip_expr_objects).collect()),
1144        other => other.clone(),
1145    }
1146}
1147
1148// ── Global singleton ───────────────────────────────────────────────────────────
1149
1150/// Access the global, immutable component catalog.
1151///
1152/// Lazily initialized on first call using the plugin registry state at that moment.
1153/// Subsequent plugin registrations do NOT propagate into the catalog (D-04).
1154///
1155/// # Panics
1156///
1157/// Panics if [`Catalog::build`] fails. In practice this only occurs if a registered
1158/// plugin returns a malformed JSON Schema from `props_schema()`. Built-in schemas
1159/// are derived at compile time and are always valid.
1160pub fn global_catalog() -> &'static Catalog {
1161    static GLOBAL_CATALOG: OnceLock<Catalog> = OnceLock::new();
1162    GLOBAL_CATALOG.get_or_init(|| {
1163        Catalog::build().expect("catalog build failed — see CatalogError for details")
1164    })
1165}
1166
1167// ── Tests ──────────────────────────────────────────────────────────────────────
1168
1169#[cfg(test)]
1170impl Catalog {
1171    /// Build a catalog from built-in specs only, skipping the global plugin registry.
1172    ///
1173    /// Tests that register plugins with invalid schemas pollute the global registry.
1174    /// This helper produces a clean, plugin-free catalog safe for use in any test order.
1175    pub(crate) fn build_builtins_only() -> Result<Self, CatalogError> {
1176        let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
1177        let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len());
1178        for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
1179            let raw = schema_fn();
1180            let schema = sanitize_schema(raw);
1181            per_component_schemas.insert((*name).to_string(), schema.clone());
1182            components.insert(
1183                (*name).to_string(),
1184                ComponentSpec {
1185                    name: (*name).to_string(),
1186                    description: (*desc).to_string(),
1187                    props_schema: schema,
1188                    is_plugin: false,
1189                    slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
1190                },
1191            );
1192        }
1193        let full_schema = assemble_full_schema(&per_component_schemas)?;
1194        let validator = jsonschema::validator_for(&full_schema)
1195            .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
1196        Ok(Catalog {
1197            components,
1198            plugin_components: HashMap::new(),
1199            full_schema,
1200            per_component_schemas,
1201            validator,
1202        })
1203    }
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::*;
1209
1210    #[test]
1211    fn builtin_types_count_drift_guard() {
1212        // SINGLE source of truth for the absolute builtin-component count. When
1213        // BUILTIN_TYPES changes, update the number HERE and nowhere else — every
1214        // other test asserts its invariant relationally (against
1215        // BUILTIN_TYPES.len()), so a component addition breaks only this test.
1216        // History: 39 → 40 (CheckboxList) → 42 (DetailPage) → 43 (CheckboxGroup)
1217        // → 44 (MediaCardGrid) → 45 (StreamText) → 47 (SegmentedControl, SidebarLayout)
1218        // → 47 (DropdownMenu replaced by ActionGroup).
1219        assert_eq!(crate::render::BUILTIN_TYPES.len(), 47);
1220    }
1221
1222    // ── D-19 canonical enum-set drift guard ─────────────────────────────────
1223
1224    /// SINGLE source of truth for the canonical `variant` / `tone` / `size`
1225    /// value sets, in serde declaration order of
1226    /// `component::{Variant, Tone, Size}`. When a canonical enum changes,
1227    /// update the matching array HERE and nowhere else — the drift guard
1228    /// asserts every schema property with one of these names relationally.
1229    const CANONICAL_VARIANT: &[&str] = &["primary", "secondary", "outline", "ghost", "destructive"];
1230    const CANONICAL_TONE: &[&str] = &["neutral", "success", "warning", "destructive"];
1231    const CANONICAL_SIZE: &[&str] = &["sm", "md", "lg"];
1232
1233    /// Map a schema property name to the canonical value set it must carry.
1234    fn canonical_set_for(prop: &str) -> Option<&'static [&'static str]> {
1235        match prop {
1236            "variant" => Some(CANONICAL_VARIANT),
1237            "tone" => Some(CANONICAL_TONE),
1238            "size" => Some(CANONICAL_SIZE),
1239            _ => None,
1240        }
1241    }
1242
1243    /// Extract an enum schema's value set, handling every shape schemars 1.x
1244    /// emits: a `#/$defs/...` `$ref` (followed one hop), a plain `enum` array,
1245    /// an `anyOf`/`oneOf` with a null branch (`Option<Enum>` — unwrapped), and
1246    /// an `anyOf` of `{"const": ...}` entries (per-variant doc comments).
1247    /// Returns `None` when the schema is not enum-shaped.
1248    fn extract_enum_values<'a>(
1249        schema: &'a Value,
1250        defs: &'a serde_json::Map<String, Value>,
1251    ) -> Option<Vec<&'a str>> {
1252        if let Some(name) = schema
1253            .get("$ref")
1254            .and_then(|v| v.as_str())
1255            .and_then(|r| r.strip_prefix("#/$defs/"))
1256        {
1257            return extract_enum_values(defs.get(name)?, defs);
1258        }
1259        if let Some(arr) = schema.get("enum").and_then(|v| v.as_array()) {
1260            return Some(arr.iter().filter_map(|v| v.as_str()).collect());
1261        }
1262        for key in ["anyOf", "oneOf"] {
1263            let Some(arr) = schema.get(key).and_then(|v| v.as_array()) else {
1264                continue;
1265            };
1266            let non_null: Vec<&Value> = arr
1267                .iter()
1268                .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
1269                .collect();
1270            // Option<Enum>: single non-null branch beside a null branch.
1271            if non_null.len() == 1 && non_null.len() < arr.len() {
1272                return extract_enum_values(non_null[0], defs);
1273            }
1274            // Per-variant-doc shape: every branch is {"const": "x", ...}.
1275            let consts: Vec<&str> = non_null
1276                .iter()
1277                .filter_map(|v| v.get("const").and_then(|c| c.as_str()))
1278                .collect();
1279            if !consts.is_empty() && consts.len() == non_null.len() {
1280                return Some(consts);
1281            }
1282        }
1283        None
1284    }
1285
1286    /// Recursively walk a schema subtree, resolving `$ref` against the root
1287    /// `$defs` map (the visited set terminates cycles), and assert that every
1288    /// object property named `variant` / `tone` / `size` carries exactly the
1289    /// canonical value set. Increments `checked` per asserted property so the
1290    /// caller can prove the traversal is not vacuous.
1291    fn walk_canonical_enum_props(
1292        node: &Value,
1293        defs: &serde_json::Map<String, Value>,
1294        visited: &mut std::collections::HashSet<String>,
1295        checked: &mut usize,
1296    ) {
1297        match node {
1298            Value::Object(obj) => {
1299                if let Some(name) = obj
1300                    .get("$ref")
1301                    .and_then(|v| v.as_str())
1302                    .and_then(|r| r.strip_prefix("#/$defs/"))
1303                {
1304                    if visited.insert(name.to_string()) {
1305                        if let Some(target) = defs.get(name) {
1306                            walk_canonical_enum_props(target, defs, visited, checked);
1307                        }
1308                    }
1309                }
1310                if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
1311                    for (prop_name, prop_schema) in props {
1312                        let Some(want) = canonical_set_for(prop_name) else {
1313                            continue;
1314                        };
1315                        let got = extract_enum_values(prop_schema, defs).unwrap_or_else(|| {
1316                            panic!(
1317                                "schema property '{prop_name}' must be enum-typed with the \
1318                                 canonical vocabulary, got non-enum schema: {prop_schema}"
1319                            )
1320                        });
1321                        assert_eq!(
1322                            got.as_slice(),
1323                            want,
1324                            "schema property '{prop_name}' carries a non-canonical value set \
1325                             {got:?} (canonical: {want:?})"
1326                        );
1327                        *checked += 1;
1328                    }
1329                }
1330                for child in obj.values() {
1331                    walk_canonical_enum_props(child, defs, visited, checked);
1332                }
1333            }
1334            Value::Array(arr) => {
1335                for item in arr {
1336                    walk_canonical_enum_props(item, defs, visited, checked);
1337                }
1338            }
1339            _ => {}
1340        }
1341    }
1342
1343    #[test]
1344    fn variant_tone_size_enum_sets_drift_guard() {
1345        // D-19: canonical-vocabulary divergence must be a build failure. The
1346        // canonical value sets are pinned ONCE in CANONICAL_VARIANT / _TONE /
1347        // _SIZE above; this guard (1) asserts the three canonical $defs
1348        // directly, then (2) walks every component's props subtree and
1349        // (3) every root $defs entry transitively ($ref-resolved, no
1350        // exclusions — OQ-1 normalized the action-level fields to `tone`), so
1351        // a future `size: xs` anywhere in the catalog fails HERE.
1352        let cat = Catalog::build_builtins_only().expect("build succeeds");
1353        let schema = cat.json_schema();
1354        let defs = schema
1355            .get("$defs")
1356            .and_then(|v| v.as_object())
1357            .expect("assembled schema has a root $defs map");
1358
1359        // 1) The three canonical $defs are enums with exactly the canonical values.
1360        for (def_name, want) in [
1361            ("Variant", CANONICAL_VARIANT),
1362            ("Tone", CANONICAL_TONE),
1363            ("Size", CANONICAL_SIZE),
1364        ] {
1365            let def = defs
1366                .get(def_name)
1367                .unwrap_or_else(|| panic!("$defs/{def_name} missing from the assembled schema"));
1368            let got = extract_enum_values(def, defs)
1369                .unwrap_or_else(|| panic!("$defs/{def_name} is not an enum schema: {def}"));
1370            assert_eq!(
1371                got.as_slice(),
1372                want,
1373                "$defs/{def_name} value set drifted from the canonical enum"
1374            );
1375        }
1376
1377        // 2) Walk every component's oneOf props subtree transitively.
1378        let one_of = defs
1379            .get("Element")
1380            .and_then(|e| e.get("oneOf"))
1381            .and_then(|v| v.as_array())
1382            .expect("$defs/Element/oneOf array");
1383        assert_eq!(
1384            one_of.len(),
1385            crate::render::BUILTIN_TYPES.len(),
1386            "oneOf must carry one entry per builtin component"
1387        );
1388        let mut checked = 0usize;
1389        for entry in one_of {
1390            let props = entry
1391                .pointer("/allOf/1/properties/props")
1392                .unwrap_or_else(|| {
1393                    panic!("oneOf entry missing allOf[1].properties.props: {entry}")
1394                });
1395            let mut visited = std::collections::HashSet::new();
1396            walk_canonical_enum_props(props, defs, &mut visited, &mut checked);
1397        }
1398
1399        // 3) Walk every root $defs entry directly — action-level fields
1400        //    (ConfirmDialog.tone, ActionOutcome::Notify.tone inside
1401        //    $defs/Action) and any hoisted def must conform even if
1402        //    unreachable from a props subtree.
1403        let mut visited = std::collections::HashSet::new();
1404        for def in defs.values() {
1405            walk_canonical_enum_props(def, defs, &mut visited, &mut checked);
1406        }
1407
1408        assert!(
1409            checked >= 10,
1410            "walker asserted only {checked} variant/tone/size properties — \
1411             the schema traversal is broken (expected at least 10 across the catalog)"
1412        );
1413    }
1414
1415    #[test]
1416    fn builtin_specs_len_matches_dispatch() {
1417        // Relational: every builtin type must have exactly one catalog spec.
1418        // The absolute count is pinned once, in builtin_types_count_drift_guard.
1419        assert_eq!(BUILTIN_SPECS.len(), crate::render::BUILTIN_TYPES.len());
1420    }
1421
1422    #[test]
1423    fn builtin_specs_names_match_dispatch() {
1424        use std::collections::HashSet;
1425        let specs: HashSet<&str> = BUILTIN_SPECS.iter().map(|(n, ..)| *n).collect();
1426        let types: HashSet<&str> = crate::render::BUILTIN_TYPES.iter().copied().collect();
1427        assert_eq!(specs, types, "BUILTIN_SPECS names must match BUILTIN_TYPES");
1428    }
1429
1430    #[test]
1431    fn build_populates_all_builtins() {
1432        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1433        let cat = Catalog::build_builtins_only().expect("build succeeds");
1434        for name in crate::render::BUILTIN_TYPES.iter() {
1435            assert!(
1436                cat.components.contains_key(*name),
1437                "built-in '{name}' missing from catalog.components"
1438            );
1439            let spec = &cat.components[*name];
1440            assert_eq!(spec.name, *name);
1441            assert!(
1442                !spec.description.is_empty(),
1443                "'{name}' has empty description"
1444            );
1445            assert!(
1446                spec.props_schema.is_object(),
1447                "'{name}' props_schema is not a JSON object"
1448            );
1449            assert!(!spec.is_plugin);
1450        }
1451    }
1452
1453    #[test]
1454    fn build_card_has_footer_slot() {
1455        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1456        let cat = Catalog::build_builtins_only().expect("build succeeds");
1457        let card = &cat.components["Card"];
1458        assert_eq!(card.slot_fields, vec!["footer"]);
1459    }
1460
1461    #[test]
1462    fn build_modal_has_footer_slot() {
1463        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1464        let cat = Catalog::build_builtins_only().expect("build succeeds");
1465        let modal = &cat.components["Modal"];
1466        assert_eq!(modal.slot_fields, vec!["footer"]);
1467    }
1468
1469    #[test]
1470    fn build_pageheader_has_actions_slot() {
1471        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1472        let cat = Catalog::build_builtins_only().expect("build succeeds");
1473        let ph = &cat.components["PageHeader"];
1474        assert_eq!(ph.slot_fields, vec!["actions"]);
1475    }
1476
1477    #[test]
1478    fn build_text_has_no_slots() {
1479        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1480        let cat = Catalog::build_builtins_only().expect("build succeeds");
1481        assert!(cat.components["Text"].slot_fields.is_empty());
1482    }
1483
1484    #[test]
1485    fn build_populates_per_component_schemas() {
1486        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1487        let cat = Catalog::build_builtins_only().expect("build succeeds");
1488        assert_eq!(
1489            cat.per_component_schemas.len(),
1490            BUILTIN_SPECS.len() + cat.plugin_components.len()
1491        );
1492    }
1493
1494    #[test]
1495    fn sanitize_schema_rewrites_definitions_to_dollar_defs() {
1496        let raw = serde_json::json!({
1497            "type": "object",
1498            "definitions": { "Foo": { "type": "string" } },
1499            "properties": {
1500                "x": { "$ref": "#/definitions/Foo" }
1501            }
1502        });
1503        let out = sanitize_schema(raw);
1504        assert!(out.get("definitions").is_none());
1505        assert!(out.get("$defs").is_some());
1506        assert_eq!(
1507            out["properties"]["x"]["$ref"].as_str().unwrap(),
1508            "#/$defs/Foo"
1509        );
1510    }
1511
1512    #[test]
1513    fn sanitize_schema_is_idempotent() {
1514        let raw = serde_json::json!({
1515            "type": "object",
1516            "$defs": { "Foo": { "type": "string" } },
1517            "properties": {
1518                "x": { "$ref": "#/$defs/Foo" }
1519            }
1520        });
1521        let once = sanitize_schema(raw.clone());
1522        let twice = sanitize_schema(once.clone());
1523        assert_eq!(once, twice);
1524        // Existing $defs should remain, no definitions key introduced.
1525        assert!(twice.get("definitions").is_none());
1526        assert!(twice.get("$defs").is_some());
1527    }
1528
1529    #[test]
1530    fn json_schema_has_spec_envelope_shape() {
1531        // Use build_builtins_only() to avoid global plugin registry pollution
1532        // from build_discovers_plugins_and_rejects_invalid_schema (BadPlugin_117).
1533        let cat = Catalog::build_builtins_only().expect("build");
1534        let schema = cat.json_schema();
1535        assert_eq!(schema["$id"], "ferro-json-ui/v2");
1536        assert_eq!(schema["type"], "object");
1537        let required: Vec<&str> = schema["required"]
1538            .as_array()
1539            .unwrap()
1540            .iter()
1541            .map(|v| v.as_str().unwrap())
1542            .collect();
1543        assert!(required.contains(&"$schema"));
1544        assert!(required.contains(&"root"));
1545        assert!(required.contains(&"elements"));
1546    }
1547
1548    #[test]
1549    fn json_schema_has_action_and_visibility_defs() {
1550        let cat = Catalog::build_builtins_only().expect("build");
1551        let schema = cat.json_schema();
1552        assert!(
1553            schema["$defs"]["Action"].is_object(),
1554            "$defs/Action missing"
1555        );
1556        assert!(
1557            schema["$defs"]["Visibility"].is_object(),
1558            "$defs/Visibility missing"
1559        );
1560        assert!(
1561            schema["$defs"]["Element"].is_object(),
1562            "$defs/Element missing"
1563        );
1564    }
1565
1566    #[test]
1567    fn json_schema_oneof_covers_all_builtins() {
1568        let cat = Catalog::build_builtins_only().expect("build");
1569        let schema = cat.json_schema();
1570        // oneOf is at the Element level (discriminates on element.type, not props.type).
1571        let one_of = schema["$defs"]["Element"]["oneOf"]
1572            .as_array()
1573            .expect("Element.oneOf is an array");
1574
1575        // Extract every const discriminator from the allOf[0] branch.
1576        let mut discriminators: std::collections::HashSet<String> =
1577            std::collections::HashSet::new();
1578        for variant in one_of {
1579            let c = variant["allOf"][0]["properties"]["type"]["const"]
1580                .as_str()
1581                .expect("every variant pins a type const");
1582            discriminators.insert(c.to_string());
1583        }
1584
1585        for name in crate::render::BUILTIN_TYPES.iter() {
1586            assert!(
1587                discriminators.contains(*name),
1588                "oneOf is missing discriminator for '{name}'"
1589            );
1590        }
1591
1592        // Built-ins only — exactly BUILTIN_TYPES.len() variants.
1593        assert_eq!(
1594            discriminators.len(),
1595            crate::render::BUILTIN_TYPES.len(),
1596            "oneOf variant count mismatch"
1597        );
1598    }
1599
1600    #[test]
1601    fn json_schema_is_valid() {
1602        use jsonschema::draft202012;
1603        let cat = Catalog::build_builtins_only().expect("build");
1604        let schema = cat.json_schema();
1605        assert!(
1606            draft202012::meta::is_valid(schema),
1607            "assembled full_schema did not meta-validate as Draft 2020-12"
1608        );
1609    }
1610
1611    #[test]
1612    fn validator_is_compiled_once_and_usable() {
1613        let cat = Catalog::build_builtins_only().expect("build");
1614        // The validator field is private — we prove it's real by validating
1615        // a minimal valid spec value. If the validator were stale / null /
1616        // placeholder, this would fail or mis-report.
1617        let minimal_valid = serde_json::json!({
1618            "$schema": "ferro-json-ui/v2",
1619            "root": "r",
1620            "elements": {
1621                "r": { "type": "Text", "props": { "content": "hi" } }
1622            }
1623        });
1624        // Should succeed — full-schema envelope accepts this shape.
1625        assert!(cat.validator.is_valid(&minimal_valid));
1626    }
1627
1628    #[test]
1629    fn validator_rejects_wrong_schema_version() {
1630        let cat = Catalog::build_builtins_only().expect("build");
1631        let wrong_version = serde_json::json!({
1632            "$schema": "ferro-json-ui/v99-wrong",
1633            "root": "r",
1634            "elements": {
1635                "r": { "type": "Text", "props": { "content": "hi" } }
1636            }
1637        });
1638        assert!(
1639            !cat.validator.is_valid(&wrong_version),
1640            "validator should reject unknown $schema version via const"
1641        );
1642    }
1643
1644    #[test]
1645    fn oneof_variants_are_deterministic_sorted() {
1646        let cat1 = Catalog::build_builtins_only().expect("build 1");
1647        let cat2 = Catalog::build_builtins_only().expect("build 2");
1648        // Byte-exact equality guarantees deterministic output (CONTEXT D-18).
1649        assert_eq!(
1650            serde_json::to_string(cat1.json_schema()).unwrap(),
1651            serde_json::to_string(cat2.json_schema()).unwrap()
1652        );
1653    }
1654
1655    // ── validate() tests (Plan 04) ────────────────────────────────────────────
1656
1657    /// Build a minimal valid Spec with one Element of the given type + props.
1658    fn test_spec_with(type_name: &str, props: Value) -> crate::spec::Spec {
1659        use crate::spec::{Element, Spec};
1660        use std::collections::HashMap;
1661        let mut elements = HashMap::new();
1662        elements.insert(
1663            "r".to_string(),
1664            Element {
1665                type_name: type_name.to_string(),
1666                props,
1667                children: Vec::new(),
1668                action: None,
1669                visible: None,
1670                each: None,
1671                if_: None,
1672            },
1673        );
1674        Spec {
1675            schema: crate::spec::SCHEMA_VERSION.to_string(),
1676            root: "r".to_string(),
1677            elements,
1678            title: None,
1679            layout: None,
1680            fill_viewport: false,
1681            data: Value::Null,
1682            design: None,
1683        }
1684    }
1685
1686    #[test]
1687    fn validate_positive_per_type() {
1688        // Representative subset of built-ins — confirms validate() passes for
1689        // minimally valid elements. Full 39-type coverage lives in Plan 07.
1690        let cat = Catalog::build_builtins_only().expect("build");
1691        let cases: Vec<(&str, Value)> = vec![
1692            ("Text", serde_json::json!({ "content": "hi" })),
1693            ("Button", serde_json::json!({ "label": "Save" })),
1694            ("Badge", serde_json::json!({ "label": "New" })),
1695            ("Separator", serde_json::json!({})),
1696        ];
1697        for (ty, props) in cases {
1698            let spec = test_spec_with(ty, props.clone());
1699            match cat.validate(&spec) {
1700                Ok(()) => {}
1701                Err(errs) => panic!("validate({ty}) failed: {errs:?}"),
1702            }
1703        }
1704    }
1705
1706    #[test]
1707    fn validate_unknown_type() {
1708        let cat = Catalog::build_builtins_only().expect("build");
1709        let spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1710        let errs = cat.validate(&spec).expect_err("should fail");
1711        assert!(
1712            errs.iter().any(|e| matches!(
1713                e,
1714                CatalogError::UnknownType { type_name, .. } if type_name == "NotARealComponent"
1715            )),
1716            "expected UnknownType for NotARealComponent; got {errs:?}"
1717        );
1718    }
1719
1720    #[test]
1721    fn validate_missing_required_prop() {
1722        // CardProps.title is required (no Option, no #[serde(default)]).
1723        // Passing {} props should produce PropsInvalid.
1724        let cat = Catalog::build_builtins_only().expect("build");
1725        let spec = test_spec_with("Card", serde_json::json!({}));
1726        let errs = cat.validate(&spec).expect_err("should fail");
1727        assert!(
1728            errs.iter().any(|e| matches!(
1729                e,
1730                CatalogError::PropsInvalid { type_name, .. } if type_name == "Card"
1731            )),
1732            "expected PropsInvalid for missing required 'title'; got {errs:?}"
1733        );
1734    }
1735
1736    #[test]
1737    fn validate_rejects_retired_prop_names() {
1738        // Prop names renamed in the canonical vocabulary migration must fail
1739        // validation (Stage 2b) rather than be silently dropped by serde.
1740        let cat = Catalog::build_builtins_only().expect("build");
1741        let cases: Vec<(&str, Value, &str)> = vec![
1742            (
1743                "Badge",
1744                serde_json::json!({ "label": "Paid", "variant": "success" }),
1745                "tone",
1746            ),
1747            (
1748                "Card",
1749                serde_json::json!({ "title": "T", "variant": "elevated" }),
1750                "appearance",
1751            ),
1752            (
1753                "MediaCardGrid",
1754                serde_json::json!({
1755                    "data_path": "/rows",
1756                    "title_key": "name",
1757                    "badge_variant_key": "status"
1758                }),
1759                "badge_tone_key",
1760            ),
1761        ];
1762        for (ty, props, new_name) in cases {
1763            let spec = test_spec_with(ty, props);
1764            let errs = cat.validate(&spec).expect_err("should fail");
1765            assert!(
1766                errs.iter().any(|e| matches!(
1767                    e,
1768                    CatalogError::PropsInvalid { type_name, errors, .. }
1769                        if type_name == ty && errors.iter().any(|m| m.contains(new_name))
1770                )),
1771                "expected retired-prop PropsInvalid for {ty} mentioning `{new_name}`; got {errs:?}"
1772            );
1773        }
1774    }
1775
1776    #[test]
1777    fn validate_rejects_retired_confirm_and_notify_variant() {
1778        // `variant` inside props-embedded `confirm` dialogs and notify
1779        // outcomes was renamed to `tone`; the walk must catch it at any depth.
1780        let cat = Catalog::build_builtins_only().expect("build");
1781        let spec = test_spec_with(
1782            "DataTable",
1783            serde_json::json!({
1784                "data_path": "/rows",
1785                "columns": [{ "key": "name", "label": "Name" }],
1786                "row_actions": [{
1787                    "label": "Delete",
1788                    "action": {
1789                        "handler": "rows.destroy",
1790                        "method": "DELETE",
1791                        "confirm": { "title": "Delete?", "variant": "danger" },
1792                        "on_success": {
1793                            "type": "notify",
1794                            "message": "Deleted",
1795                            "variant": "error"
1796                        }
1797                    }
1798                }]
1799            }),
1800        );
1801        let errs = cat.validate(&spec).expect_err("should fail");
1802        let retired_msgs: Vec<&String> = errs
1803            .iter()
1804            .filter_map(|e| match e {
1805                CatalogError::PropsInvalid { errors, .. } => Some(errors),
1806                _ => None,
1807            })
1808            .flatten()
1809            .filter(|m| m.contains("renamed to `tone`"))
1810            .collect();
1811        assert_eq!(
1812            retired_msgs.len(),
1813            2,
1814            "expected confirm + notify retired-variant errors; got {errs:?}"
1815        );
1816    }
1817
1818    #[test]
1819    fn validate_accepts_canonical_prop_names() {
1820        // The renamed props themselves must pass Stage 2b.
1821        let cat = Catalog::build_builtins_only().expect("build");
1822        let cases: Vec<(&str, Value)> = vec![
1823            (
1824                "Badge",
1825                serde_json::json!({ "label": "Paid", "tone": "success" }),
1826            ),
1827            (
1828                "Card",
1829                serde_json::json!({ "title": "T", "appearance": "elevated" }),
1830            ),
1831        ];
1832        for (ty, props) in cases {
1833            let spec = test_spec_with(ty, props.clone());
1834            if let Err(errs) = cat.validate(&spec) {
1835                panic!("validate({ty}) with canonical props failed: {errs:?}");
1836            }
1837        }
1838    }
1839
1840    #[test]
1841    fn validate_bad_schema_version() {
1842        let cat = Catalog::build_builtins_only().expect("build");
1843        let mut spec = test_spec_with("Text", serde_json::json!({ "content": "hi" }));
1844        spec.schema = "ferro-json-ui/v99-wrong".to_string();
1845        let errs = cat.validate(&spec).expect_err("should fail");
1846        assert!(
1847            errs.iter()
1848                .any(|e| matches!(e, CatalogError::SpecInvalid { .. })),
1849            "expected SpecInvalid for wrong $schema version; got {errs:?}"
1850        );
1851    }
1852
1853    #[test]
1854    fn validate_pre_dispatch_short_circuits() {
1855        // Stage 1 must short-circuit: unknown type + malformed envelope →
1856        // only UnknownType surfaces (not SpecInvalid or PropsInvalid).
1857        let cat = Catalog::build_builtins_only().expect("build");
1858        let mut spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1859        spec.schema = "ferro-json-ui/v99-wrong".to_string();
1860        let errs = cat.validate(&spec).expect_err("should fail");
1861
1862        let has_unknown = errs
1863            .iter()
1864            .any(|e| matches!(e, CatalogError::UnknownType { .. }));
1865        let has_spec_invalid = errs
1866            .iter()
1867            .any(|e| matches!(e, CatalogError::SpecInvalid { .. }));
1868        let has_props_invalid = errs
1869            .iter()
1870            .any(|e| matches!(e, CatalogError::PropsInvalid { .. }));
1871
1872        assert!(has_unknown, "expected UnknownType");
1873        assert!(
1874            !has_spec_invalid,
1875            "Stage 3 ran despite Stage 1 failing: {errs:?}"
1876        );
1877        assert!(
1878            !has_props_invalid,
1879            "Stage 2 ran despite Stage 1 failing: {errs:?}"
1880        );
1881    }
1882
1883    #[test]
1884    fn validator_is_cached_not_recompiled() {
1885        // Structural guarantee: self.validator is a plain field, not recompiled
1886        // per validate() call. This test approximates that by running validate()
1887        // 100 times against a single Catalog without panic or regression.
1888        let cat = Catalog::build_builtins_only().expect("build");
1889        for _ in 0..100 {
1890            let spec = test_spec_with("Text", serde_json::json!({ "content": "x" }));
1891            assert!(cat.validate(&spec).is_ok());
1892        }
1893    }
1894
1895    #[test]
1896    fn validate_accumulates_multiple_errors_across_elements() {
1897        // Two elements with missing required props → two PropsInvalid errors.
1898        use crate::spec::{Element, Spec};
1899        use std::collections::HashMap;
1900        let cat = Catalog::build_builtins_only().expect("build");
1901        let mut elements = HashMap::new();
1902        elements.insert(
1903            "a".to_string(),
1904            Element {
1905                type_name: "Card".to_string(),
1906                props: serde_json::json!({}), // missing required "title"
1907                children: Vec::new(),
1908                action: None,
1909                visible: None,
1910                each: None,
1911                if_: None,
1912            },
1913        );
1914        elements.insert(
1915            "b".to_string(),
1916            Element {
1917                type_name: "Button".to_string(),
1918                props: serde_json::json!({}), // missing required "label"
1919                children: Vec::new(),
1920                action: None,
1921                visible: None,
1922                each: None,
1923                if_: None,
1924            },
1925        );
1926        let spec = Spec {
1927            schema: crate::spec::SCHEMA_VERSION.to_string(),
1928            root: "a".to_string(),
1929            elements,
1930            title: None,
1931            layout: None,
1932            fill_viewport: false,
1933            data: Value::Null,
1934            design: None,
1935        };
1936        let errs = cat.validate(&spec).expect_err("should fail");
1937        let props_invalid_count = errs
1938            .iter()
1939            .filter(|e| matches!(e, CatalogError::PropsInvalid { .. }))
1940            .count();
1941        assert!(
1942            props_invalid_count >= 2,
1943            "expected at least 2 PropsInvalid errors; got {errs:?}"
1944        );
1945    }
1946
1947    /// Combined plugin discovery + invalid schema rejection test.
1948    ///
1949    /// Uses unique names (`GoodPlugin_117`, `BadPlugin_117`) to avoid collisions
1950    /// with other test registrations. The bad plugin is registered after the good
1951    /// one to confirm discovery of the good plugin precedes the rejection.
1952    #[test]
1953    fn build_discovers_plugins_and_rejects_invalid_schema() {
1954        use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
1955
1956        struct GoodPlugin;
1957        impl JsonUiPlugin for GoodPlugin {
1958            fn component_type(&self) -> &str {
1959                "GoodPlugin_117"
1960            }
1961            fn props_schema(&self) -> Value {
1962                serde_json::json!({ "type": "object" })
1963            }
1964            fn render(&self, _: &Value, _: &Value) -> String {
1965                String::new()
1966            }
1967            fn css_assets(&self) -> Vec<Asset> {
1968                vec![]
1969            }
1970            fn js_assets(&self) -> Vec<Asset> {
1971                vec![]
1972            }
1973            fn init_script(&self) -> Option<String> {
1974                None
1975            }
1976        }
1977
1978        register_plugin(GoodPlugin);
1979
1980        // Positive discovery: GoodPlugin should appear in plugin_components.
1981        let cat = Catalog::build().expect("build succeeds with valid plugin only");
1982        assert!(
1983            cat.plugin_components.contains_key("GoodPlugin_117"),
1984            "plugin 'GoodPlugin_117' should have been discovered"
1985        );
1986        assert!(cat.plugin_components["GoodPlugin_117"].is_plugin);
1987
1988        // Now register a bad plugin and confirm build fails with the plugin name embedded.
1989        struct BadPlugin;
1990        impl JsonUiPlugin for BadPlugin {
1991            fn component_type(&self) -> &str {
1992                "BadPlugin_117"
1993            }
1994            fn props_schema(&self) -> Value {
1995                // JSON Schema requires `type` to be a string or array of strings.
1996                // Number 42 is invalid → validator_for() rejects it.
1997                serde_json::json!({ "type": 42 })
1998            }
1999            fn render(&self, _: &Value, _: &Value) -> String {
2000                String::new()
2001            }
2002            fn css_assets(&self) -> Vec<Asset> {
2003                vec![]
2004            }
2005            fn js_assets(&self) -> Vec<Asset> {
2006                vec![]
2007            }
2008            fn init_script(&self) -> Option<String> {
2009                None
2010            }
2011        }
2012
2013        register_plugin(BadPlugin);
2014        match Catalog::build() {
2015            Err(CatalogError::BuildFailed(msg)) => {
2016                assert!(
2017                    msg.contains("BadPlugin_117"),
2018                    "error should mention plugin name, got: {msg}"
2019                );
2020            }
2021            Err(other) => panic!("expected BuildFailed mentioning BadPlugin_117, got: {other:?}"),
2022            Ok(_) => panic!("expected build to fail due to invalid plugin schema"),
2023        }
2024    }
2025
2026    // ── component_schema / sorted accessor tests (Plan 05) ───────────────────
2027
2028    #[test]
2029    fn component_schema_returns_props_only() {
2030        // ROADMAP SC-5 canonical example: catalog.component_schema("Card") must
2031        // return the CardProps schema (NOT the Element wrapper from
2032        // $defs/Element.properties.props in the full schema).
2033        let cat = Catalog::build_builtins_only().expect("build");
2034        let schema = cat
2035            .component_schema("Card")
2036            .expect("Card is a built-in component");
2037
2038        // A Props schema is an object with a `properties` map. The Element
2039        // envelope would have a `type` + `props` + `children` layout — we
2040        // assert the Props-only shape by checking for CardProps fields.
2041        let obj = schema
2042            .as_object()
2043            .expect("Card props schema is a JSON object");
2044
2045        // Expect "type": "object" or equivalent (schemars uses `type` or `oneOf`).
2046        assert!(
2047            obj.contains_key("type") || obj.contains_key("oneOf") || obj.contains_key("anyOf"),
2048            "CardProps schema should be a structural object schema; got {obj:?}"
2049        );
2050
2051        // Expect CardProps-specific field "title" exists in `properties`.
2052        if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
2053            assert!(
2054                props.contains_key("title"),
2055                "CardProps schema.properties should include 'title'; got keys: {:?}",
2056                props.keys().collect::<Vec<_>>()
2057            );
2058        } else {
2059            panic!(
2060                "CardProps schema missing top-level 'properties' map — \
2061                 sanitizer or Plan 02 may be wrong. Got: {}",
2062                serde_json::to_string_pretty(schema).unwrap_or_default()
2063            );
2064        }
2065
2066        // Must NOT be the Element envelope (would mean we accidentally returned
2067        // full_schema["$defs"]["Element"] or similar — CONTEXT D-19).
2068        let is_element_wrapper = obj
2069            .get("properties")
2070            .and_then(|v| v.as_object())
2071            .map(|p| p.contains_key("children") && p.contains_key("props"))
2072            .unwrap_or(false);
2073        assert!(
2074            !is_element_wrapper,
2075            "component_schema('Card') returned an Element wrapper; must be Props-only (CONTEXT D-19)"
2076        );
2077    }
2078
2079    #[test]
2080    fn component_schema_none_for_unknown() {
2081        let cat = Catalog::build_builtins_only().expect("build");
2082        assert!(
2083            cat.component_schema("NotARealComponent_117_05").is_none(),
2084            "unknown component must return None"
2085        );
2086        // Empty string is also "unknown".
2087        assert!(cat.component_schema("").is_none());
2088    }
2089
2090    #[test]
2091    fn component_schema_resolves_every_builtin() {
2092        // Parallel safety net for SC-5: every name in BUILTIN_TYPES must have a
2093        // per-component schema. If any is missing, Plan 02's BUILTIN_SPECS table
2094        // or the build loop dropped an entry.
2095        let cat = Catalog::build_builtins_only().expect("build");
2096        for name in crate::render::BUILTIN_TYPES.iter() {
2097            assert!(
2098                cat.component_schema(name).is_some(),
2099                "built-in '{name}' has no per-component schema"
2100            );
2101        }
2102    }
2103
2104    #[test]
2105    fn components_sorted_yields_ascending_by_name() {
2106        let cat = Catalog::build_builtins_only().expect("build");
2107        let names: Vec<String> = cat
2108            .components_sorted()
2109            .map(|spec| spec.name.clone())
2110            .collect();
2111        assert_eq!(names.len(), crate::render::BUILTIN_TYPES.len());
2112        let mut sorted = names.clone();
2113        sorted.sort();
2114        assert_eq!(
2115            names, sorted,
2116            "components_sorted must yield ascending order"
2117        );
2118
2119        // plugin_components_sorted returns the plugin side; may be empty.
2120        let plugin_names: Vec<String> = cat
2121            .plugin_components_sorted()
2122            .map(|spec| spec.name.clone())
2123            .collect();
2124        let mut plugin_sorted = plugin_names.clone();
2125        plugin_sorted.sort();
2126        assert_eq!(
2127            plugin_names, plugin_sorted,
2128            "plugin_components_sorted must yield ascending order"
2129        );
2130    }
2131
2132    // ── prompt() tests (Plan 06) ─────────────────────────────────────────────
2133
2134    #[test]
2135    fn prompt_under_size_budget() {
2136        let cat = Catalog::build_builtins_only().expect("build");
2137        let prompt = cat.prompt();
2138        let bytes = prompt.len();
2139        // Budget bumped from 8 KB to 9 KB in Phase 162 Plan 01 (CheckboxList added, 40 components).
2140        // Budget bumped from 9 KB to 10 KB in Phase 175 Plan 04 (CheckboxGroup alias added, 43 components).
2141        // Budget bumped from 10 KB to 11 KB in Phase 169 Plan 02 (StreamText added, 45 components).
2142        // Budget bumped from 11 KB to 12 KB in Phase 251 Plan 03 ($ref'd enum
2143        // values inlined in prop docs — canonical Variant/Tone/Size surfaced).
2144        assert!(
2145            bytes <= 12 * 1024,
2146            "prompt() is {bytes} bytes, exceeds 12 KB budget (CONTEXT D-17)"
2147        );
2148    }
2149
2150    #[test]
2151    fn prompt_mentions_every_builtin() {
2152        let cat = Catalog::build_builtins_only().expect("build");
2153        let prompt = cat.prompt();
2154        for name in crate::render::BUILTIN_TYPES.iter() {
2155            let heading = format!("### {name}\n");
2156            assert!(
2157                prompt.contains(&heading),
2158                "prompt() missing section heading for '{name}'"
2159            );
2160        }
2161    }
2162
2163    #[test]
2164    fn prompt_inlines_canonical_enum_values() {
2165        // Enum-typed props referenced via $ref must surface their values
2166        // inline — an agent reading the prompt sees the exact canonical
2167        // vocabulary, not `<see schema>`.
2168        let cat = Catalog::build_builtins_only().expect("build");
2169        let prompt = cat.prompt();
2170        for values in [
2171            CANONICAL_VARIANT.join("|"),
2172            CANONICAL_TONE.join("|"),
2173            CANONICAL_SIZE.join("|"),
2174        ] {
2175            assert!(
2176                prompt.contains(&values),
2177                "prompt() must inline the canonical enum values '{values}'"
2178            );
2179        }
2180    }
2181
2182    #[test]
2183    fn prompt_is_deterministic() {
2184        let cat1 = Catalog::build_builtins_only().expect("build 1");
2185        let cat2 = Catalog::build_builtins_only().expect("build 2");
2186        assert_eq!(
2187            cat1.prompt(),
2188            cat2.prompt(),
2189            "prompt() must be deterministic"
2190        );
2191    }
2192
2193    #[test]
2194    fn prompt_documents_slot_fields() {
2195        // CardProps has slot_fields = ["footer"] (set in Plan 02). The prompt
2196        // must include a `Slots:` line for Card.
2197        let cat = Catalog::build_builtins_only().expect("build");
2198        let prompt = cat.prompt();
2199        let card_start = prompt.find("### Card\n").expect("Card section present");
2200        let card_slice = &prompt[card_start..];
2201        // End at the next ### heading (or EOF).
2202        let end = card_slice[3..]
2203            .find("### ")
2204            .map(|i| i + 3)
2205            .unwrap_or(card_slice.len());
2206        let card_section = &card_slice[..end];
2207        assert!(
2208            card_section.contains("Slots: footer"),
2209            "Card section missing 'Slots: footer' line:\n{card_section}"
2210        );
2211    }
2212
2213    #[test]
2214    fn prompt_is_not_raw_json_schema() {
2215        let cat = Catalog::build_builtins_only().expect("build");
2216        let prompt = cat.prompt();
2217        assert!(
2218            prompt.starts_with("## Component Catalog"),
2219            "prompt() should start with Markdown header, not JSON"
2220        );
2221        assert!(
2222            !prompt.contains("\"$schema\""),
2223            "prompt() must not embed raw JSON Schema (ROADMAP caveat)"
2224        );
2225    }
2226
2227    #[test]
2228    fn catalog_contains_checkbox_group() {
2229        let cat = Catalog::build_builtins_only().expect("build");
2230        assert!(
2231            cat.component_schema("CheckboxGroup").is_some(),
2232            "CheckboxGroup must be registered in BUILTIN_SPECS as an alias for CheckboxList"
2233        );
2234    }
2235
2236    // ── Stage 2b el.action walk tests (Plan 252-02) ──────────────────────────
2237
2238    /// Build a minimal spec from a JSON string (goes through `Spec::from_json`
2239    /// so the element-level `action` field is typed-deserialized).
2240    fn spec_from_json_string(json: &str) -> crate::spec::Spec {
2241        crate::spec::Spec::from_json(json).expect("test spec must parse")
2242    }
2243
2244    #[test]
2245    fn validate_rejects_retired_el_action_confirm_variant() {
2246        // A typed element-level `action.confirm.variant` (retired in Phase 251)
2247        // must be caught by Stage 2b after the el.action walk is added.
2248        // The error must mention the `/action` path prefix.
2249        let cat = Catalog::build_builtins_only().expect("build");
2250        let spec = spec_from_json_string(
2251            r#"{
2252                "$schema": "ferro-json-ui/v2",
2253                "root": "btn",
2254                "elements": {
2255                    "btn": {
2256                        "type": "Button",
2257                        "props": { "label": "Delete" },
2258                        "action": {
2259                            "handler": "orders.destroy",
2260                            "method": "POST",
2261                            "confirm": {
2262                                "title": "Delete?",
2263                                "message": "x",
2264                                "variant": "danger"
2265                            }
2266                        }
2267                    }
2268                }
2269            }"#,
2270        );
2271        let errs = cat
2272            .validate(&spec)
2273            .expect_err("should fail: confirm.variant is a retired prop");
2274        assert!(
2275            errs.iter().any(|e| matches!(
2276                e,
2277                CatalogError::PropsInvalid { errors, .. }
2278                    if errors.iter().any(|m| m.contains("/action") && m.contains("variant"))
2279            )),
2280            "expected PropsInvalid mentioning /action and variant; got {errs:?}"
2281        );
2282    }
2283
2284    #[test]
2285    fn validate_accepts_canonical_el_action_confirm_tone() {
2286        // The canonical `confirm.tone` field must not produce any false positive.
2287        let cat = Catalog::build_builtins_only().expect("build");
2288        let spec = spec_from_json_string(
2289            r#"{
2290                "$schema": "ferro-json-ui/v2",
2291                "root": "btn",
2292                "elements": {
2293                    "btn": {
2294                        "type": "Button",
2295                        "props": { "label": "Delete" },
2296                        "action": {
2297                            "handler": "orders.destroy",
2298                            "method": "POST",
2299                            "confirm": {
2300                                "title": "Delete?",
2301                                "message": "x",
2302                                "tone": "destructive"
2303                            }
2304                        }
2305                    }
2306                }
2307            }"#,
2308        );
2309        if let Err(errs) = cat.validate(&spec) {
2310            panic!("validate with canonical confirm.tone failed: {errs:?}");
2311        }
2312    }
2313
2314    #[test]
2315    fn global_catalog_includes_stream_text() {
2316        let cat = Catalog::build_builtins_only().expect("build");
2317        assert!(
2318            cat.components.contains_key("StreamText"),
2319            "catalog must include StreamText"
2320        );
2321        let spec = &cat.components["StreamText"];
2322        assert_eq!(spec.name, "StreamText");
2323        assert!(
2324            spec.description.contains("event: done"),
2325            "StreamText description must mention 'event: done'; got: {}",
2326            spec.description
2327        );
2328        assert!(
2329            spec.props_schema.is_object(),
2330            "StreamText props_schema must be a JSON object"
2331        );
2332        assert!(!spec.is_plugin);
2333    }
2334}