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, FilterTabsProps, FormProps, FormSectionProps, GridProps, HeaderProps,
34    ImageProps, InputProps, KanbanBoardProps, MediaCardGridProps, ModalProps,
35    NotificationDropdownProps, NumpadProps, PageHeaderProps, PaginationProps, ProgressProps,
36    QuantityStepperProps, RawHtmlProps, SegmentedControlProps, SelectProps, SelectionPanelProps,
37    SeparatorProps, SidebarLayoutProps, SidebarProps, SkeletonProps, StatCardProps,
38    StreamTextProps, SwitchProps, TableProps, TabsProps, TextProps, TileGridProps, TileProps,
39    ToastProps,
40};
41
42// ── Public types ───────────────────────────────────────────────────────────────
43
44/// Metadata and JSON Schema for a single JSON-UI component.
45///
46/// Built by [`Catalog::build`] from the static `BUILTIN_SPECS` table (built-ins)
47/// or from [`crate::plugin::JsonUiPlugin::props_schema`] (plugins).
48pub struct ComponentSpec {
49    /// Component type name as it appears in the Spec's `"type"` field.
50    pub name: String,
51    /// Short imperative description used in prompt output and catalog tooling.
52    pub description: String,
53    /// JSON Schema object for the component's Props struct (schemars output).
54    pub props_schema: Value,
55    /// `true` for plugin components; `false` for built-ins.
56    pub is_plugin: bool,
57    /// Names of fields whose values are `Vec<String>` of element IDs (slot fields).
58    ///
59    /// Examples: `["footer"]` for Card, `["children"]` for Tabs' per-tab model,
60    /// `[]` for leaf components with no children slots.
61    pub slot_fields: Vec<String>,
62}
63
64/// Pre-computed, immutable registry of all JSON-UI components and their schemas.
65///
66/// Constructed once via [`Catalog::build`] and accessed globally through
67/// [`global_catalog`]. All fields are `pub(crate)` — external callers use the
68/// accessor methods added in Plans 02–05.
69pub struct Catalog {
70    /// Built-in components keyed by type name.
71    pub(crate) components: HashMap<String, ComponentSpec>,
72    /// Plugin components keyed by type name.
73    pub(crate) plugin_components: HashMap<String, ComponentSpec>,
74    /// Full Spec JSON Schema document (root + elements + oneOf over all components).
75    pub(crate) full_schema: Value,
76    /// Per-component Props schemas keyed by type name.
77    pub(crate) per_component_schemas: HashMap<String, Value>,
78    /// Compiled validator over `full_schema`. Reused across `validate()` calls.
79    pub(crate) validator: jsonschema::Validator,
80}
81
82/// Errors that can occur during catalog construction or spec validation.
83#[derive(Debug, thiserror::Error)]
84pub enum CatalogError {
85    /// A Spec element references a component type not in the catalog.
86    #[error("unknown component type '{type_name}' at element '{element_id}'")]
87    UnknownType {
88        /// The element's ID field.
89        element_id: String,
90        /// The unrecognized `"type"` value.
91        type_name: String,
92    },
93    /// An element's `props` object fails JSON Schema validation for its component.
94    #[error("props invalid for '{type_name}' at element '{element_id}': {errors:?}")]
95    PropsInvalid {
96        /// The element's ID field.
97        element_id: String,
98        /// The component type that owns the failing props.
99        type_name: String,
100        /// Human-readable validation error messages from the jsonschema crate.
101        errors: Vec<String>,
102    },
103    /// The full Spec fails the top-level JSON Schema (missing `$schema`, bad `root`, etc.).
104    #[error("spec invalid: {errors:?}")]
105    SpecInvalid {
106        /// Human-readable validation error messages.
107        errors: Vec<String>,
108    },
109    /// Catalog construction failed (e.g., a plugin returned an invalid JSON Schema).
110    #[error("catalog build failed: {0}")]
111    BuildFailed(String),
112    /// JSON serialization error during schema assembly.
113    #[error("schema serialization error: {0}")]
114    SchemaSerialization(#[from] serde_json::Error),
115}
116
117// ── Static built-in table ─────────────────────────────────────────────────────
118
119type SchemaFn = fn() -> Value;
120
121/// `(type_name, description, schema_fn, slot_fields)`
122///
123/// Descriptions per CONTEXT D-06. Slot fields per Phase 116 / CONTEXT D-05.
124/// Order MUST match `crate::render::BUILTIN_TYPES` exactly (see drift guard in
125/// `Catalog::build`).
126static BUILTIN_SPECS: &[(&str, &str, SchemaFn, &[&str])] = &[
127    // === Leaves (atoms.rs) ===
128    (
129        "Text",
130        "Semantic text element (p / h1 / h2 / h3 / span / div / section).",
131        || to_value(schema_for!(TextProps)).unwrap(),
132        &[],
133    ),
134    (
135        "Button",
136        "Interactive button with variant, size, optional icon, and disabled state.",
137        || to_value(schema_for!(ButtonProps)).unwrap(),
138        &[],
139    ),
140    (
141        "Badge",
142        "Small tone-styled status label.",
143        || to_value(schema_for!(BadgeProps)).unwrap(),
144        &[],
145    ),
146    (
147        "Alert",
148        "Inline notice with neutral / success / warning / destructive tones.",
149        || to_value(schema_for!(AlertProps)).unwrap(),
150        &[],
151    ),
152    (
153        "Separator",
154        "Horizontal or vertical divider between content sections.",
155        || to_value(schema_for!(SeparatorProps)).unwrap(),
156        &[],
157    ),
158    (
159        "Progress",
160        "Progress bar with 0–100 percentage value and optional label.",
161        || to_value(schema_for!(ProgressProps)).unwrap(),
162        &[],
163    ),
164    (
165        "Avatar",
166        "Circular user image with fallback initials and size variants.",
167        || to_value(schema_for!(AvatarProps)).unwrap(),
168        &[],
169    ),
170    (
171        "Image",
172        "Image with optional aspect ratio and skeleton fallback on load error.",
173        || to_value(schema_for!(ImageProps)).unwrap(),
174        &[],
175    ),
176    (
177        "Skeleton",
178        "Loading placeholder with configurable width / height / rounding.",
179        || to_value(schema_for!(SkeletonProps)).unwrap(),
180        &[],
181    ),
182    (
183        "Breadcrumb",
184        "Navigation trail of label + optional URL items.",
185        || to_value(schema_for!(BreadcrumbProps)).unwrap(),
186        &[],
187    ),
188    (
189        "Pagination",
190        "Page navigation for paginated data (current / per_page / total).",
191        || to_value(schema_for!(PaginationProps)).unwrap(),
192        &[],
193    ),
194    (
195        "DescriptionList",
196        "Key-value pairs displayed as a description list with optional format.",
197        || to_value(schema_for!(DescriptionListProps)).unwrap(),
198        &[],
199    ),
200    (
201        "EmptyState",
202        "Standardized empty view with title, description, and optional CTA.",
203        || to_value(schema_for!(EmptyStateProps)).unwrap(),
204        &[],
205    ),
206    (
207        "StatCard",
208        "Live-updatable metric card with label, value, icon, SSE target.",
209        || to_value(schema_for!(StatCardProps)).unwrap(),
210        &[],
211    ),
212    (
213        "Checklist",
214        "Onboarding-style checklist with dismissal and server-side state.",
215        || to_value(schema_for!(ChecklistProps)).unwrap(),
216        &[],
217    ),
218    (
219        "Toast",
220        "Declarative notification intent consumed by the runtime JS via data attributes.",
221        || to_value(schema_for!(ToastProps)).unwrap(),
222        &[],
223    ),
224    (
225        "NotificationDropdown",
226        "Dropdown listing notification items with icons, timestamps, read state.",
227        || to_value(schema_for!(NotificationDropdownProps)).unwrap(),
228        &[],
229    ),
230    (
231        "Sidebar",
232        "Dashboard sidebar with fixed top / bottom items and collapsible nav groups.",
233        || to_value(schema_for!(SidebarProps)).unwrap(),
234        &[],
235    ),
236    (
237        "Header",
238        "Dashboard top bar with business name, notification badge, user menu.",
239        || to_value(schema_for!(HeaderProps)).unwrap(),
240        &[],
241    ),
242    (
243        "CalendarCell",
244        "Single day in a month grid with today highlight, out-of-month muting, event dots.",
245        || to_value(schema_for!(CalendarCellProps)).unwrap(),
246        &[],
247    ),
248    (
249        "ActionCard",
250        "Clickable row with icon, title, description, chevron, and tone-colored left border.",
251        || to_value(schema_for!(ActionCardProps)).unwrap(),
252        &[],
253    ),
254    (
255        "Tile",
256        "Touch-friendly tile with name, price, and +/- quantity controls.",
257        || to_value(schema_for!(TileProps)).unwrap(),
258        &[],
259    ),
260    (
261        "FilterTabs",
262        "Standalone touch filter-tab strip that filters visible tiles client-side by category token; renders an All tab plus one tab per item.",
263        || to_value(schema_for!(FilterTabsProps)).unwrap(),
264        &[],
265    ),
266    (
267        "RawHtml",
268        "Server-injected HTML island. CONSUMER is responsible for sanitization — see docs/src/json-ui/plugins.md.",
269        || to_value(schema_for!(RawHtmlProps)).unwrap(),
270        &[],
271    ),
272    (
273        "StreamText",
274        "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.",
275        || to_value(schema_for!(StreamTextProps)).unwrap(),
276        &[],
277    ),
278    (
279        "QuantityStepper",
280        "Reusable +/- numeric stepper with its own hidden input on the data-qty contract; honors optional min/max/step bounds.",
281        || to_value(schema_for!(QuantityStepperProps)).unwrap(),
282        &[],
283    ),
284    (
285        "Numpad",
286        "Tap-surface numeric keypad (quantity or price mode) writing to a hidden field; never renders a native input so the software keyboard is not triggered.",
287        || to_value(schema_for!(NumpadProps)).unwrap(),
288        &[],
289    ),
290    // === Containers (containers.rs) ===
291    (
292        "Card",
293        "Content container with title, description, optional badge and subtitle, body children, and optional footer slot.",
294        || to_value(schema_for!(CardProps)).unwrap(),
295        &["footer"],
296    ),
297    (
298        "Modal",
299        "Dialog overlay with title, description, body children, and optional footer slot.",
300        || to_value(schema_for!(ModalProps)).unwrap(),
301        &["footer"],
302    ),
303    (
304        "Tabs",
305        "Tabbed content; per-tab children live in TabsProps.tabs[i].children.",
306        || to_value(schema_for!(TabsProps)).unwrap(),
307        &[],
308    ),
309    (
310        "KanbanBoard",
311        "Horizontally scrollable kanban columns on desktop, tab-switched on mobile.",
312        || to_value(schema_for!(KanbanBoardProps)).unwrap(),
313        &[],
314    ),
315    (
316        "PageHeader",
317        "Page title with optional breadcrumb and action button slot.",
318        || to_value(schema_for!(PageHeaderProps)).unwrap(),
319        &["actions"],
320    ),
321    (
322        "DetailPage",
323        "Canonical resource-detail skeleton: PageHeader chrome, optional info Card slot, and stacked body sections from Element.children.",
324        || to_value(schema_for!(DetailPageProps)).unwrap(),
325        &["actions", "info"],
326    ),
327    (
328        "Grid",
329        "Responsive multi-column grid with configurable breakpoint columns, gap, scroll.",
330        || to_value(schema_for!(GridProps)).unwrap(),
331        &[],
332    ),
333    (
334        "TileGrid",
335        "Touch-first responsive tile grid with integrated category filter strip and client-side text search; Tile children iterate via $each.",
336        || to_value(schema_for!(TileGridProps)).unwrap(),
337        &[],
338    ),
339    (
340        "Collapsible",
341        "Expandable <details> / <summary> section.",
342        || to_value(schema_for!(CollapsibleProps)).unwrap(),
343        &[],
344    ),
345    (
346        "FormSection",
347        "Visual grouping within a form with title, description, and layout variant.",
348        || to_value(schema_for!(FormSectionProps)).unwrap(),
349        &[],
350    ),
351    (
352        "ButtonGroup",
353        "Horizontal button row with configurable gap.",
354        || to_value(schema_for!(ButtonGroupProps)).unwrap(),
355        &[],
356    ),
357    (
358        "SegmentedControl",
359        "Connected button cluster — date scrollers, view toggles, mode pickers. Items via literal or data_path.",
360        || to_value(schema_for!(SegmentedControlProps)).unwrap(),
361        &[],
362    ),
363    (
364        "SidebarLayout",
365        "Two-column layout with sticky vertical nav (left) and main content slot (right). Mobile-collapsing.",
366        || to_value(schema_for!(SidebarLayoutProps)).unwrap(),
367        &[],
368    ),
369    (
370        "ActionGroup",
371        "Ordered action list: inline buttons up to max_inline, trailing overflow kebab for the rest; destructive items forced into the kebab last.",
372        || to_value(schema_for!(ActionGroupProps)).unwrap(),
373        &[],
374    ),
375    (
376        "SelectionPanel",
377        "Live client-side view of the register form state: lines appear as tiles are tapped, each with a per-line stepper + remove, a running total, an EmptyState, and a confirm slot; pins and scrolls under fill_viewport.",
378        || to_value(schema_for!(SelectionPanelProps)).unwrap(),
379        &[],
380    ),
381    // === Form controls (form.rs) ===
382    (
383        "Form",
384        "Form container with action binding and field components.",
385        || to_value(schema_for!(FormProps)).unwrap(),
386        &[],
387    ),
388    (
389        "Input",
390        "Text input with type variants, validation error, data_path pre-fill.",
391        || to_value(schema_for!(InputProps)).unwrap(),
392        &[],
393    ),
394    (
395        "Select",
396        "Dropdown select with options, error, data_path pre-fill.",
397        || to_value(schema_for!(SelectProps)).unwrap(),
398        &[],
399    ),
400    (
401        "Checkbox",
402        "Boolean checkbox with label, description, data binding.",
403        || to_value(schema_for!(CheckboxProps)).unwrap(),
404        &[],
405    ),
406    (
407        "Switch",
408        "Toggle switch (visual alternative to Checkbox); auto-submit when `action` set.",
409        || to_value(schema_for!(SwitchProps)).unwrap(),
410        &[],
411    ),
412    (
413        "CheckboxList",
414        "Multi-select checkbox group from static options or data-driven array. \
415         Each checked option submits as field=value.",
416        || to_value(schema_for!(CheckboxListProps)).unwrap(),
417        &[],
418    ),
419    (
420        "CheckboxGroup",
421        "Multi-select checkbox group (alias for CheckboxList). Each checked option \
422         submits as field=value with array-submit semantics. Identical props to \
423         CheckboxList; see that entry for full schema.",
424        || to_value(schema_for!(CheckboxListProps)).unwrap(),
425        &[],
426    ),
427    // === Data displays (data.rs) ===
428    (
429        "Table",
430        "Data table with columns, row_actions, sorting, empty_message.",
431        || to_value(schema_for!(TableProps)).unwrap(),
432        &[],
433    ),
434    (
435        "DataTable",
436        "Stripe-style alternating-row table with per-row action menu and mobile card fallback.",
437        || to_value(schema_for!(DataTableProps)).unwrap(),
438        &[],
439    ),
440    (
441        "MediaCardGrid",
442        "Responsive card grid backed by a data array. Each card shows an optional screenshot image, title, description, status badge, and per-row dropdown actions.",
443        || to_value(schema_for!(MediaCardGridProps)).unwrap(),
444        &[],
445    ),
446];
447
448// ── Schema sanitizer ──────────────────────────────────────────────────────────
449
450/// Walk a JSON Schema tree and rewrite legacy `definitions` → `$defs`
451/// (schemars 0.8 → 1.x draft key drift). Idempotent.
452///
453/// Also rewrites `$ref` strings that reference `#/definitions/X` → `#/$defs/X`
454/// so that validator resolution does not break after the key rename (H-2).
455fn sanitize_schema(mut schema: Value) -> Value {
456    fn walk(v: &mut Value) {
457        if let Some(obj) = v.as_object_mut() {
458            if let Some(defs) = obj.remove("definitions") {
459                obj.entry("$defs".to_string()).or_insert(defs);
460            }
461            if let Some(Value::String(ref_str)) = obj.get_mut("$ref") {
462                if let Some(suffix) = ref_str.strip_prefix("#/definitions/") {
463                    *ref_str = format!("#/$defs/{suffix}");
464                }
465            }
466            // Collect keys first to avoid borrow conflicts.
467            let keys: Vec<String> = obj.keys().cloned().collect();
468            for k in keys {
469                if let Some(child) = obj.get_mut(&k) {
470                    walk(child);
471                }
472            }
473        } else if let Some(arr) = v.as_array_mut() {
474            for item in arr.iter_mut() {
475                walk(item);
476            }
477        }
478    }
479    walk(&mut schema);
480    schema
481}
482
483// ── Schema assembly ────────────────────────────────────────────────────────────
484
485/// Hoist all `$defs` entries from a schemars-generated schema into a shared map.
486///
487/// schemars emits nested type definitions under `$defs` on the schema root. When
488/// component schemas are embedded as `allOf[1]` in the oneOf, the `jsonschema`
489/// validator resolves `$ref` pointers from the *assembled* root — so every
490/// component-local `$defs` entry must be merged up to the top level.
491fn hoist_defs(schema: &mut Value, shared_defs: &mut serde_json::Map<String, Value>) {
492    if let Some(obj) = schema.as_object_mut() {
493        if let Some(Value::Object(defs)) = obj.remove("$defs") {
494            for (k, v) in defs {
495                shared_defs.entry(k).or_insert(v);
496            }
497        }
498    }
499}
500
501/// Hand-assemble the full spec JSON Schema document from per-component schemas.
502///
503/// Root: `$schema`, `root`, `elements` (HashMap<String, Element>), optional `title` /
504/// `layout` / `fill_viewport` / `data` / `design`. `$defs/Element` uses a `oneOf`
505/// at the element level — each variant pins `"type": { "const": "X" }` on the
506/// element object itself and validates `props` against that component's Props
507/// schema (CONTEXT D-13).
508///
509/// Variants are sorted by name to guarantee deterministic output (CONTEXT D-18).
510///
511/// `$defs` from every per-component schema are hoisted to the root so that `$ref`
512/// pointers (e.g., `#/$defs/ConfirmDialog`) resolve against the assembled document.
513fn assemble_full_schema(per_component: &HashMap<String, Value>) -> Result<Value, CatalogError> {
514    // Start with Action, Visibility and DesignMeta defs — their nested types
515    // ($defs) are hoisted too.
516    let mut action_schema = sanitize_schema(to_value(schema_for!(crate::action::Action))?);
517    let mut visibility_schema =
518        sanitize_schema(to_value(schema_for!(crate::visibility::Visibility))?);
519    let mut design_schema = sanitize_schema(to_value(schema_for!(crate::spec::DesignMeta))?);
520
521    // Collect shared $defs — starts with action + visibility + design nested types.
522    let mut shared_defs: serde_json::Map<String, Value> = serde_json::Map::new();
523    hoist_defs(&mut action_schema, &mut shared_defs);
524    hoist_defs(&mut visibility_schema, &mut shared_defs);
525    hoist_defs(&mut design_schema, &mut shared_defs);
526
527    // Deterministic oneOf at the Element level — sorted by name (CONTEXT D-18).
528    // Each variant describes a complete element object: pins `type` via const on the
529    // element itself, then validates `props` against that component's Props schema.
530    let mut names: Vec<&String> = per_component.keys().collect();
531    names.sort();
532    let one_of: Vec<Value> = names
533        .into_iter()
534        .map(|name| {
535            let mut props_schema = per_component[name].clone();
536            // Hoist component-local $defs so $ref pointers resolve from the assembled root.
537            hoist_defs(&mut props_schema, &mut shared_defs);
538            serde_json::json!({
539                "allOf": [
540                    {
541                        "type": "object",
542                        "required": ["type"],
543                        "properties": {
544                            "type": { "const": name }
545                        }
546                    },
547                    {
548                        "type": "object",
549                        "properties": {
550                            "props": props_schema,
551                            "children": { "type": "array", "items": { "type": "string" } },
552                            "action":   { "$ref": "#/$defs/Action" },
553                            "visible":  { "$ref": "#/$defs/Visibility" }
554                        }
555                    }
556                ]
557            })
558        })
559        .collect();
560
561    // Merge the framework-level $defs (Element, Action, Visibility) with the hoisted ones.
562    // Framework entries take precedence and must not be overwritten by component defs.
563    shared_defs
564        .entry("Action".to_string())
565        .or_insert(action_schema);
566    shared_defs
567        .entry("Visibility".to_string())
568        .or_insert(visibility_schema);
569    shared_defs
570        .entry("DesignMeta".to_string())
571        .or_insert(design_schema);
572    // Element is the discriminated union itself — oneOf over all component variants.
573    shared_defs.insert(
574        "Element".to_string(),
575        serde_json::json!({ "oneOf": one_of }),
576    );
577
578    Ok(serde_json::json!({
579        "$schema": "https://json-schema.org/draft/2020-12/schema",
580        "$id": "ferro-json-ui/v2",
581        "type": "object",
582        "required": ["$schema", "root", "elements"],
583        "properties": {
584            "$schema":  { "const": "ferro-json-ui/v2" },
585            "root":     { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,127}$" },
586            "elements": {
587                "type": "object",
588                "additionalProperties": { "$ref": "#/$defs/Element" }
589            },
590            "title":    { "type": ["string", "null"] },
591            "layout":   { "type": ["string", "null"] },
592            "fill_viewport": { "type": "boolean", "default": false },
593            "data":     true,
594            "design":   { "$ref": "#/$defs/DesignMeta" }
595        },
596        "$defs": shared_defs
597    }))
598}
599
600// ── Catalog impl ───────────────────────────────────────────────────────────────
601
602impl Catalog {
603    /// Build the catalog from the static built-in specs and the current plugin registry.
604    ///
605    /// Called once by [`global_catalog`]. Returns `Err` if a plugin's `props_schema()`
606    /// is not a valid JSON Schema or if the assembled full schema fails to compile.
607    ///
608    /// # Errors
609    ///
610    /// - [`CatalogError::BuildFailed`] — plugin meta-validation failure or jsonschema
611    ///   compilation failure.
612    /// - [`CatalogError::SchemaSerialization`] — serde_json serialization failure.
613    pub fn build() -> Result<Self, CatalogError> {
614        // === Runtime drift guard ===
615        // BUILTIN_SPECS and BUILTIN_TYPES must stay in sync. If they diverge, the
616        // catalog is incomplete and downstream validation would silently skip types.
617        if BUILTIN_SPECS.len() != crate::render::BUILTIN_TYPES.len() {
618            return Err(CatalogError::BuildFailed(format!(
619                "BUILTIN_SPECS has {} entries but BUILTIN_TYPES has {} — \
620                 add an entry to BUILTIN_SPECS or remove from BUILTIN_TYPES",
621                BUILTIN_SPECS.len(),
622                crate::render::BUILTIN_TYPES.len(),
623            )));
624        }
625
626        // === Populate built-ins ===
627        let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
628        let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len() * 2);
629        for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
630            let raw = schema_fn();
631            let schema = sanitize_schema(raw);
632            per_component_schemas.insert((*name).to_string(), schema.clone());
633            components.insert(
634                (*name).to_string(),
635                ComponentSpec {
636                    name: (*name).to_string(),
637                    description: (*desc).to_string(),
638                    props_schema: schema,
639                    is_plugin: false,
640                    slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
641                },
642            );
643        }
644
645        // === Populate plugins (H-3 meta-validation) ===
646        // Plugins are developer-authored but their schemas are treated as untrusted
647        // input (CONTEXT D-20, RESEARCH H-3). Each schema is compiled with
648        // `jsonschema::validator_for` before wiring into the catalog; a bad schema
649        // aborts build with the plugin name embedded in the error.
650        let mut plugin_components = HashMap::new();
651        for plugin_type in crate::plugin::registered_plugin_types() {
652            // Built-ins take precedence; a plugin cannot shadow a built-in type (D-19).
653            if components.contains_key(&plugin_type) {
654                continue;
655            }
656            let raw = crate::plugin::with_plugin(&plugin_type, |p| p.props_schema())
657                .unwrap_or(Value::Null);
658            let schema = sanitize_schema(raw);
659            // Meta-validate plugin schema (CONTEXT D-20, RESEARCH H-3).
660            if jsonschema::validator_for(&schema).is_err() {
661                return Err(CatalogError::BuildFailed(format!(
662                    "plugin '{plugin_type}' returned an invalid JSON Schema"
663                )));
664            }
665            per_component_schemas.insert(plugin_type.clone(), schema.clone());
666            plugin_components.insert(
667                plugin_type.clone(),
668                ComponentSpec {
669                    name: plugin_type,
670                    description: String::from("Plugin component."),
671                    props_schema: schema,
672                    is_plugin: true,
673                    slot_fields: Vec::new(),
674                },
675            );
676        }
677
678        // === Assemble full schema (CONTEXT D-13, D-14, D-15) ===
679        let full_schema = assemble_full_schema(&per_component_schemas)?;
680
681        // === Compile validator ONCE (SCHEMA-03) ===
682        let validator = jsonschema::validator_for(&full_schema)
683            .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
684
685        Ok(Catalog {
686            components,
687            plugin_components,
688            full_schema,
689            per_component_schemas,
690            validator,
691        })
692    }
693
694    /// Return the fully-assembled spec JSON Schema document.
695    ///
696    /// Shape: root with `$schema`, `root`, `elements`, plus `$defs`
697    /// containing `Element` (with a discriminated `oneOf` over all
698    /// component Props) and `Action` / `Visibility` references.
699    /// Zero-copy — the returned `&Value` lives as long as the Catalog.
700    pub fn json_schema(&self) -> &Value {
701        &self.full_schema
702    }
703
704    /// Validate a [`crate::spec::Spec`] against the catalog.
705    ///
706    /// Three-stage pipeline (CONTEXT D-10):
707    ///
708    /// 1. **Type-name whitelist** — every `element.type_name` must resolve to a
709    ///    built-in or plugin component. Unknown names return [`CatalogError::UnknownType`]
710    ///    and **short-circuit** the rest of the pipeline. This avoids noisy `oneOf`
711    ///    errors from Stage 3 when a component name is simply wrong (RESEARCH §5).
712    ///
713    /// 2. **Per-element Props validation** — for each element, look up
714    ///    `per_component_schemas[type_name]` and validate `element.props` against it
715    ///    using on-demand [`jsonschema::validator_for`]. Errors accumulate as
716    ///    [`CatalogError::PropsInvalid`]. Plugin schemas are accepted per CONTEXT D-20.
717    ///
718    ///    **2b. Retired prop names** — prop names renamed in the canonical
719    ///    variant/tone/size migration (`Card.variant` → `appearance`,
720    ///    `Badge.variant` → `tone`, …) are hard errors. serde ignores unknown
721    ///    keys and the per-component schemas do not set
722    ///    `additionalProperties: false`, so without this lint a retired name
723    ///    would be silently dropped — an invisible visual downgrade.
724    ///
725    /// 3. **Envelope check** — serialize the full `Spec` and run it through the
726    ///    cached `self.validator` (compiled once in [`Catalog::build`], SCHEMA-03).
727    ///    Errors become [`CatalogError::SpecInvalid`].
728    ///
729    /// Errors accumulate across Stages 2 and 3 so a caller sees every issue at once.
730    pub fn validate(&self, spec: &crate::spec::Spec) -> Result<(), Vec<CatalogError>> {
731        let mut errors: Vec<CatalogError> = Vec::new();
732
733        // === Stage 1: type_name whitelist (O(1) per element) ===
734        for (id, el) in &spec.elements {
735            let known = self.components.contains_key(&el.type_name)
736                || self.plugin_components.contains_key(&el.type_name);
737            if !known {
738                errors.push(CatalogError::UnknownType {
739                    element_id: id.clone(),
740                    type_name: el.type_name.clone(),
741                });
742            }
743        }
744        // SHORT-CIRCUIT: if any type is unknown, skip Stages 2 & 3.
745        // Rationale: Stage 3's full-spec oneOf would emit dozens of
746        // "no variant matched" errors for unknown types, obscuring the signal.
747        // Stage 2 would skip unknowns anyway.
748        if !errors.is_empty() {
749            return Err(errors);
750        }
751
752        // === Stage 2: per-element Props validation ===
753        for (id, el) in &spec.elements {
754            if let Some(schema) = self.per_component_schemas.get(&el.type_name) {
755                // Skip null props — null means "no props provided"; the schema's
756                // `required` list is the gate for required fields. When props is
757                // null the element carries no props object, which the envelope
758                // schema permits (props is optional per the element allOf shape).
759                if el.props.is_null() {
760                    continue;
761                }
762                // Template elements ($each) carry data-bound props of arbitrary
763                // type; the concrete type is only known after $each expansion at
764                // render time. strip_expr_objects turns `{"$data":..}` into `""`,
765                // which fails non-String schemas (e.g. TileProps.price_cents:
766                // Option<u64>). Skip per-element Props validation for template
767                // elements — validate_directives (spec.rs) enforces the $each
768                // structural rules, and resolve_expressions enforces types at
769                // render time against real row data.
770                if el.each.is_some() {
771                    continue;
772                }
773                // On-demand compile (CONTEXT D-12). Schemas are small (~50–200 LOC
774                // JSON); compile cost < 1 ms per component. Cache as
775                // HashMap<String, Validator> if profiling demands it.
776                let v = match jsonschema::validator_for(schema) {
777                    Ok(v) => v,
778                    Err(e) => {
779                        errors.push(CatalogError::BuildFailed(format!(
780                            "compiling per-component schema for '{}': {e}",
781                            el.type_name
782                        )));
783                        continue;
784                    }
785                };
786                // Strip $data/$template expression objects before schema validation.
787                // Expressions are resolved at render time against handler data — the
788                // static catalog validator cannot know the resolved type. We substitute
789                // expression objects with "" so string-typed fields pass; the runtime
790                // resolver (resolve_expressions) enforces the actual type via data binding.
791                let validation_props = strip_expr_objects(&el.props);
792                let mut per_elem_errs: Vec<String> = Vec::new();
793                for err in v.iter_errors(&validation_props) {
794                    per_elem_errs.push(format!("{}: {}", err.instance_path(), err));
795                }
796                if !per_elem_errs.is_empty() {
797                    errors.push(CatalogError::PropsInvalid {
798                        element_id: id.clone(),
799                        type_name: el.type_name.clone(),
800                        errors: per_elem_errs,
801                    });
802                }
803            }
804        }
805
806        // === Stage 2b: retired prop names (canonical vocabulary migration) ===
807        // serde ignores unknown keys and the per-component schemas do not set
808        // `additionalProperties: false`, so a retired prop name would otherwise
809        // decode cleanly and be silently dropped — turning the rename into an
810        // invisible visual downgrade (e.g. `Badge.variant: "success"` rendering
811        // a neutral badge). Flag renames as hard errors pointing at the new name.
812        for (id, el) in &spec.elements {
813            let mut renamed: Vec<String> = Vec::new();
814            for (ty, old, new) in RETIRED_PROPS {
815                if el.type_name == *ty && el.props.get(old).is_some() {
816                    renamed.push(format!(
817                        "/{old}: `{old}` was renamed to `{new}` — update the spec"
818                    ));
819                }
820            }
821            collect_retired_action_variants(&el.props, "", &mut renamed);
822            if let Some(action) = &el.action {
823                if let Ok(action_value) = serde_json::to_value(action) {
824                    collect_retired_action_variants(&action_value, "/action", &mut renamed);
825                }
826            }
827            if !renamed.is_empty() {
828                errors.push(CatalogError::PropsInvalid {
829                    element_id: id.clone(),
830                    type_name: el.type_name.clone(),
831                    errors: renamed,
832                });
833            }
834        }
835
836        // === Stage 3: full-spec envelope validation (cached validator, SCHEMA-03) ===
837        let mut spec_value = match serde_json::to_value(spec) {
838            Ok(v) => v,
839            Err(e) => {
840                errors.push(CatalogError::SchemaSerialization(e));
841                return Err(errors);
842            }
843        };
844        // Template elements ($each) carry data-bound props that cannot be schema
845        // -validated before expansion. Remove their props key in the envelope copy
846        // so the whole-spec oneOf does not attempt to validate props against the
847        // component's required-field schema (see Stage 2 rationale).
848        // The envelope schema treats `props` as optional on the element shape;
849        // removing the key avoids the component-props oneOf arm while keeping the
850        // element present for the `elements` map structure check.
851        if let Some(elements) = spec_value
852            .get_mut("elements")
853            .and_then(|e| e.as_object_mut())
854        {
855            for (id, el_val) in elements.iter_mut() {
856                let is_template = spec
857                    .elements
858                    .get(id)
859                    .map(|e| e.each.is_some())
860                    .unwrap_or(false);
861                if is_template {
862                    if let Some(obj) = el_val.as_object_mut() {
863                        obj.remove("props");
864                    }
865                }
866            }
867        }
868        // Strip expression objects in the serialized spec for the same reason as Stage 2.
869        let stripped_spec_value = strip_expr_objects(&spec_value);
870        let mut envelope_errs: Vec<String> = Vec::new();
871        for err in self.validator.iter_errors(&stripped_spec_value) {
872            envelope_errs.push(format!("{}: {}", err.instance_path(), err));
873        }
874        if !envelope_errs.is_empty() {
875            errors.push(CatalogError::SpecInvalid {
876                errors: envelope_errs,
877            });
878        }
879
880        if errors.is_empty() {
881            Ok(())
882        } else {
883            Err(errors)
884        }
885    }
886
887    /// Return the per-component Props JSON Schema for `type_name`, or `None`
888    /// if the name is not registered as a built-in or plugin component.
889    ///
890    /// The returned schema is Props-only (NOT wrapped in the Element envelope
891    /// used by [`Self::json_schema`]). This is the schema shape Phase 120 AI
892    /// structured-output generation consumes, and what `ferro json-ui:schema
893    /// --component <name>` prints.
894    ///
895    /// The reference has the same lifetime as `&self` — zero-copy (CONTEXT D-15).
896    ///
897    /// Lookup is unified across built-ins and plugins via the
898    /// `per_component_schemas` map populated in [`Self::build`] (CONTEXT D-20
899    /// — plugin schemas are stored identically after meta-validation).
900    pub fn component_schema(&self, type_name: &str) -> Option<&Value> {
901        self.per_component_schemas.get(type_name)
902    }
903
904    /// Iterate built-in [`ComponentSpec`] entries sorted by name (ascending).
905    ///
906    /// Deterministic ordering is required by CONTEXT D-18 so that
907    /// [`Self::json_schema`], `prompt()` (Plan 06), and ferro-mcp
908    /// `json_ui_catalog` output (Plan 06 migration) produce byte-stable
909    /// results for snapshot tests.
910    pub fn components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
911        let mut entries: Vec<&ComponentSpec> = self.components.values().collect();
912        entries.sort_by(|a, b| a.name.cmp(&b.name));
913        entries.into_iter()
914    }
915
916    /// Iterate plugin [`ComponentSpec`] entries sorted by name (ascending).
917    ///
918    /// Separate from built-ins so consumers can format them in a distinct
919    /// section (ferro-mcp `json_ui_catalog.CatalogResponse` preserves the
920    /// `components` / `plugin_components` split per CONTEXT D-24).
921    pub fn plugin_components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
922        let mut entries: Vec<&ComponentSpec> = self.plugin_components.values().collect();
923        entries.sort_by(|a, b| a.name.cmp(&b.name));
924        entries.into_iter()
925    }
926
927    /// Generate a concise text system prompt summarizing every component.
928    ///
929    /// Format: `## Component Catalog` header, a one-line note explaining the
930    /// slot convention, then one `### <Name>` section per component (built-ins
931    /// then plugins, both sorted by name). Each section contains the
932    /// description, a single `Props:` line with `name (Type)` tuples, and
933    /// (when non-empty) a `Slots:` line listing slot field names only.
934    ///
935    /// The prompt is intentionally CONCISE (≤ 10 KB, CONTEXT D-17) — the full
936    /// JSON Schema is NOT embedded. Consumers wanting machine-readable schemas
937    /// use [`Self::json_schema`] or [`Self::component_schema`] (Plan 07 CLI).
938    ///
939    /// Deterministic (CONTEXT D-18): two builds of the same catalog yield
940    /// byte-identical output; order within sections follows alphabetical order
941    /// via [`Self::components_sorted`] and [`Self::plugin_components_sorted`].
942    pub fn prompt(&self) -> String {
943        let mut out = String::with_capacity(8 * 1024);
944        out.push_str("## Component Catalog\n\n");
945        out.push_str("Slot fields are Vec<String> of element IDs; body children come from Element.children.\n\n");
946        for spec in self.components_sorted() {
947            render_component_section(&mut out, spec);
948        }
949        if self.plugin_components.is_empty() {
950            return out;
951        }
952        out.push_str("## Plugin Components\n\n");
953        for spec in self.plugin_components_sorted() {
954            render_component_section(&mut out, spec);
955        }
956        out
957    }
958}
959
960// ── Retired prop-name lint (validate Stage 2b) ────────────────────────────────
961
962/// Element-level prop names retired by the canonical variant/tone/size
963/// migration: `(component type, retired prop, replacement prop)`.
964const RETIRED_PROPS: &[(&str, &str, &str)] = &[
965    ("Card", "variant", "appearance"),
966    ("Badge", "variant", "tone"),
967    ("Alert", "variant", "tone"),
968    ("Toast", "variant", "tone"),
969    ("ActionCard", "variant", "tone"),
970    ("MediaCardGrid", "badge_variant_key", "badge_tone_key"),
971];
972
973/// Recursively flag retired `variant` keys inside action-shaped objects
974/// embedded in props: `confirm: {..}` dialogs and `on_success`/`on_error`
975/// notify outcomes (e.g. inside `row_actions`, `buttons`, `actions` arrays).
976/// These decode through typed structs that ignore unknown keys, so without
977/// this walk an old `confirm.variant: "danger"` would silently lose its
978/// destructive styling.
979fn collect_retired_action_variants(value: &Value, path: &str, out: &mut Vec<String>) {
980    match value {
981        Value::Object(map) => {
982            for (key, child) in map {
983                let child_path = format!("{path}/{key}");
984                if let Value::Object(obj) = child {
985                    let is_confirm = key == "confirm";
986                    let is_notify_outcome = (key == "on_success" || key == "on_error")
987                        && obj.get("type").and_then(Value::as_str) == Some("notify");
988                    if (is_confirm || is_notify_outcome) && obj.contains_key("variant") {
989                        out.push(format!(
990                            "{child_path}/variant: `variant` was renamed to `tone` — update the spec"
991                        ));
992                    }
993                }
994                collect_retired_action_variants(child, &child_path, out);
995            }
996        }
997        Value::Array(arr) => {
998            for (i, child) in arr.iter().enumerate() {
999                collect_retired_action_variants(child, &format!("{path}/{i}"), out);
1000            }
1001        }
1002        _ => {}
1003    }
1004}
1005
1006// ── Prompt generation helpers ─────────────────────────────────────────────────
1007
1008/// Append a single component section to `out`.
1009///
1010/// Shape:
1011/// ```text
1012/// ### Card
1013/// Content container with title and optional footer slot.
1014/// Props: title (String), description (Option<String>), ...
1015/// Slots: footer
1016///
1017/// ```
1018///
1019/// The slot semantics (Vec<String> of element IDs, body children from
1020/// Element.children) are declared once in the catalog header by
1021/// [`Catalog::prompt`] rather than repeated per section.
1022fn render_component_section(out: &mut String, spec: &ComponentSpec) {
1023    out.push_str("### ");
1024    out.push_str(&spec.name);
1025    out.push('\n');
1026    out.push_str(&spec.description);
1027    out.push('\n');
1028
1029    let props_line = render_props_line(&spec.props_schema);
1030    if !props_line.is_empty() {
1031        out.push_str("Props: ");
1032        out.push_str(&props_line);
1033        out.push('\n');
1034    }
1035    if !spec.slot_fields.is_empty() {
1036        out.push_str("Slots: ");
1037        out.push_str(&spec.slot_fields.join(", "));
1038        out.push('\n');
1039    }
1040    out.push('\n');
1041}
1042
1043/// Render the `Props:` line for a schemars-derived Props schema.
1044///
1045/// Walks `schema.properties` in serde-emit order. For each field:
1046/// - `Option<T>` schemas (schemars emits `anyOf: [{...}, {type: null}]`) render as `Option<T>`.
1047/// - Enum fields with ≤ 8 `enum` entries render inline as `name (a|b|c)` —
1048///   including fields referencing a local `$defs` enum (`$ref` resolved).
1049/// - Enum fields with > 8 entries render as `name (one of N — see schema)`.
1050/// - Plain scalar fields render as `name (String)` / `(i64)` / `(bool)`.
1051/// - Array types render as `name (Vec<T>)`.
1052///
1053/// Returns an empty string if the schema has no `properties` map.
1054fn render_props_line(schema: &Value) -> String {
1055    let Some(obj) = schema.as_object() else {
1056        return String::new();
1057    };
1058    let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
1059        return String::new();
1060    };
1061    // Component-local $defs (per_component_schemas keep them) — lets enum-typed
1062    // fields ($ref → $defs entry) render their values inline instead of
1063    // `<see schema>`.
1064    let defs = obj.get("$defs").and_then(|v| v.as_object());
1065    let required: std::collections::HashSet<&str> = obj
1066        .get("required")
1067        .and_then(|v| v.as_array())
1068        .map(|arr| {
1069            arr.iter()
1070                .filter_map(|v| v.as_str())
1071                .collect::<std::collections::HashSet<_>>()
1072        })
1073        .unwrap_or_default();
1074
1075    let parts: Vec<String> = props
1076        .iter()
1077        .map(|(name, field_schema)| {
1078            let ty = render_field_type(field_schema, required.contains(name.as_str()), defs);
1079            format!("{name} ({ty})")
1080        })
1081        .collect();
1082    parts.join(", ")
1083}
1084
1085/// Resolve a `#/$defs/<Name>` reference against a component schema's local
1086/// `$defs`, returning the enum value names ONLY when the target is a plain
1087/// string enum (`{"enum": [...]}`). Non-enum refs return `None` so
1088/// [`render_field_type`]'s `<see schema>` fallback stays unchanged for them.
1089fn resolve_local_enum_ref<'a>(
1090    schema: &'a Value,
1091    defs: Option<&'a serde_json::Map<String, Value>>,
1092) -> Option<Vec<&'a str>> {
1093    let name = schema.get("$ref")?.as_str()?.strip_prefix("#/$defs/")?;
1094    let target = defs?.get(name)?;
1095    let arr = target.get("enum")?.as_array()?;
1096    Some(arr.iter().filter_map(|v| v.as_str()).collect())
1097}
1098
1099/// Render a single field's type string from its JSON Schema.
1100///
1101/// `defs` is the component schema's local `$defs` map (when present) so
1102/// enum-typed fields referenced via `$ref` render their values inline.
1103fn render_field_type(
1104    schema: &Value,
1105    is_required: bool,
1106    defs: Option<&serde_json::Map<String, Value>>,
1107) -> String {
1108    // 1) Detect enum inline: {type: "string", enum: [...]} or {enum: [...]}
1109    if let Some(variants) = schema.get("enum").and_then(|v| v.as_array()) {
1110        let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
1111        let inner = render_enum_inline(&names);
1112        return wrap_optional(inner, is_required);
1113    }
1114    // 2) anyOf / oneOf with null → Option<T>
1115    for key in ["anyOf", "oneOf"] {
1116        if let Some(arr) = schema.get(key).and_then(|v| v.as_array()) {
1117            let has_null = arr
1118                .iter()
1119                .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
1120            let non_null: Vec<&Value> = arr
1121                .iter()
1122                .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
1123                .collect();
1124            if has_null && non_null.len() == 1 {
1125                let inner = render_field_type(non_null[0], true, defs);
1126                return format!("Option<{inner}>");
1127            }
1128        }
1129    }
1130    // 3) type: ["T", "null"] → Option<T>
1131    if let Some(types) = schema.get("type").and_then(|v| v.as_array()) {
1132        let non_null: Vec<&str> = types
1133            .iter()
1134            .filter_map(|v| v.as_str())
1135            .filter(|s| *s != "null")
1136            .collect();
1137        let has_null = types.iter().any(|v| v.as_str() == Some("null"));
1138        if has_null && non_null.len() == 1 {
1139            return format!("Option<{}>", rust_for_json_type(non_null[0], schema, defs));
1140        }
1141    }
1142    // 4) Plain type
1143    if let Some(t) = schema.get("type").and_then(|v| v.as_str()) {
1144        let inner = rust_for_json_type(t, schema, defs);
1145        return wrap_optional(inner, is_required);
1146    }
1147    // 5) $ref to a local plain string enum → inline its values (the canonical
1148    //    Variant/Tone/Size land here; non-enum refs keep the fallback below).
1149    if let Some(names) = resolve_local_enum_ref(schema, defs) {
1150        return wrap_optional(render_enum_inline(&names), is_required);
1151    }
1152    // 6) Fallback: $ref or complex
1153    wrap_optional("<see schema>".to_string(), is_required)
1154}
1155
1156/// Map a JSON Schema `type` + optional `items` to a Rust-ish type name.
1157fn rust_for_json_type(
1158    t: &str,
1159    schema: &Value,
1160    defs: Option<&serde_json::Map<String, Value>>,
1161) -> String {
1162    match t {
1163        "string" => "String".to_string(),
1164        "integer" => "i64".to_string(),
1165        "number" => "f64".to_string(),
1166        "boolean" => "bool".to_string(),
1167        "array" => {
1168            if let Some(items) = schema.get("items") {
1169                let inner = render_field_type(items, true, defs);
1170                format!("Vec<{inner}>")
1171            } else {
1172                "Vec<Value>".to_string()
1173            }
1174        }
1175        "object" => "Object".to_string(),
1176        other => other.to_string(),
1177    }
1178}
1179
1180/// Render an enum's variants inline when count ≤ 8, else collapse.
1181fn render_enum_inline(variants: &[&str]) -> String {
1182    if variants.len() <= 8 {
1183        variants.join("|")
1184    } else {
1185        format!("one of {} — see schema", variants.len())
1186    }
1187}
1188
1189/// Wrap inner type in `Option<...>` when the field is not required.
1190fn wrap_optional(inner: String, is_required: bool) -> String {
1191    if is_required {
1192        inner
1193    } else {
1194        format!("Option<{inner}>")
1195    }
1196}
1197
1198/// Replace every `$data` / `$template` expression object in a value tree with `""`.
1199///
1200/// Used by [`Catalog::validate`] so that specs with runtime data-binding placeholders
1201/// pass static schema validation. Expression objects have the shape
1202/// `{"$data": "/path"}` or `{"$template": "literal {/path}"}` — single-key objects
1203/// whose key is the expression marker. They are resolved at render time by
1204/// [`crate::expression::resolve_expressions`]; the catalog validator must not reject
1205/// them for failing type checks that only apply to the resolved value.
1206fn strip_expr_objects(val: &Value) -> Value {
1207    match val {
1208        Value::Object(map) => {
1209            if map.len() == 1 && (map.contains_key("$data") || map.contains_key("$template")) {
1210                Value::String(String::new())
1211            } else {
1212                Value::Object(
1213                    map.iter()
1214                        .map(|(k, v)| (k.clone(), strip_expr_objects(v)))
1215                        .collect(),
1216                )
1217            }
1218        }
1219        Value::Array(arr) => Value::Array(arr.iter().map(strip_expr_objects).collect()),
1220        other => other.clone(),
1221    }
1222}
1223
1224// ── Global singleton ───────────────────────────────────────────────────────────
1225
1226/// Access the global, immutable component catalog.
1227///
1228/// Lazily initialized on first call using the plugin registry state at that moment.
1229/// Subsequent plugin registrations do NOT propagate into the catalog (D-04).
1230///
1231/// # Panics
1232///
1233/// Panics if [`Catalog::build`] fails. In practice this only occurs if a registered
1234/// plugin returns a malformed JSON Schema from `props_schema()`. Built-in schemas
1235/// are derived at compile time and are always valid.
1236pub fn global_catalog() -> &'static Catalog {
1237    static GLOBAL_CATALOG: OnceLock<Catalog> = OnceLock::new();
1238    GLOBAL_CATALOG.get_or_init(|| {
1239        Catalog::build().expect("catalog build failed — see CatalogError for details")
1240    })
1241}
1242
1243// ── Tests ──────────────────────────────────────────────────────────────────────
1244
1245#[cfg(test)]
1246impl Catalog {
1247    /// Build a catalog from built-in specs only, skipping the global plugin registry.
1248    ///
1249    /// Tests that register plugins with invalid schemas pollute the global registry.
1250    /// This helper produces a clean, plugin-free catalog safe for use in any test order.
1251    pub(crate) fn build_builtins_only() -> Result<Self, CatalogError> {
1252        let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
1253        let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len());
1254        for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
1255            let raw = schema_fn();
1256            let schema = sanitize_schema(raw);
1257            per_component_schemas.insert((*name).to_string(), schema.clone());
1258            components.insert(
1259                (*name).to_string(),
1260                ComponentSpec {
1261                    name: (*name).to_string(),
1262                    description: (*desc).to_string(),
1263                    props_schema: schema,
1264                    is_plugin: false,
1265                    slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
1266                },
1267            );
1268        }
1269        let full_schema = assemble_full_schema(&per_component_schemas)?;
1270        let validator = jsonschema::validator_for(&full_schema)
1271            .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
1272        Ok(Catalog {
1273            components,
1274            plugin_components: HashMap::new(),
1275            full_schema,
1276            per_component_schemas,
1277            validator,
1278        })
1279    }
1280}
1281
1282#[cfg(test)]
1283mod tests {
1284    use super::*;
1285
1286    #[test]
1287    fn builtin_types_count_drift_guard() {
1288        // SINGLE source of truth for the absolute builtin-component count. When
1289        // BUILTIN_TYPES changes, update the number HERE and nowhere else — every
1290        // other test asserts its invariant relationally (against
1291        // BUILTIN_TYPES.len()), so a component addition breaks only this test.
1292        // History: 39 → 40 (CheckboxList) → 42 (DetailPage) → 43 (CheckboxGroup)
1293        // → 44 (MediaCardGrid) → 45 (StreamText) → 47 (SegmentedControl, SidebarLayout)
1294        // → 47 (DropdownMenu replaced by ActionGroup) → 48 (TileGrid) → 49 (FilterTabs)
1295        // → 50 (QuantityStepper) → 51 (Numpad) → 52 (SelectionPanel).
1296        assert_eq!(crate::render::BUILTIN_TYPES.len(), 52);
1297    }
1298
1299    // ── D-19 canonical enum-set drift guard ─────────────────────────────────
1300
1301    /// SINGLE source of truth for the canonical `variant` / `tone` / `size`
1302    /// value sets, in serde declaration order of
1303    /// `component::{Variant, Tone, Size}`. When a canonical enum changes,
1304    /// update the matching array HERE and nowhere else — the drift guard
1305    /// asserts every schema property with one of these names relationally.
1306    const CANONICAL_VARIANT: &[&str] = &["primary", "secondary", "outline", "ghost", "destructive"];
1307    const CANONICAL_TONE: &[&str] = &["neutral", "success", "warning", "destructive"];
1308    const CANONICAL_SIZE: &[&str] = &["sm", "md", "lg"];
1309
1310    /// Map a schema property name to the canonical value set it must carry.
1311    fn canonical_set_for(prop: &str) -> Option<&'static [&'static str]> {
1312        match prop {
1313            "variant" => Some(CANONICAL_VARIANT),
1314            "tone" => Some(CANONICAL_TONE),
1315            "size" => Some(CANONICAL_SIZE),
1316            _ => None,
1317        }
1318    }
1319
1320    /// Extract an enum schema's value set, handling every shape schemars 1.x
1321    /// emits: a `#/$defs/...` `$ref` (followed one hop), a plain `enum` array,
1322    /// an `anyOf`/`oneOf` with a null branch (`Option<Enum>` — unwrapped), and
1323    /// an `anyOf` of `{"const": ...}` entries (per-variant doc comments).
1324    /// Returns `None` when the schema is not enum-shaped.
1325    fn extract_enum_values<'a>(
1326        schema: &'a Value,
1327        defs: &'a serde_json::Map<String, Value>,
1328    ) -> Option<Vec<&'a str>> {
1329        if let Some(name) = schema
1330            .get("$ref")
1331            .and_then(|v| v.as_str())
1332            .and_then(|r| r.strip_prefix("#/$defs/"))
1333        {
1334            return extract_enum_values(defs.get(name)?, defs);
1335        }
1336        if let Some(arr) = schema.get("enum").and_then(|v| v.as_array()) {
1337            return Some(arr.iter().filter_map(|v| v.as_str()).collect());
1338        }
1339        for key in ["anyOf", "oneOf"] {
1340            let Some(arr) = schema.get(key).and_then(|v| v.as_array()) else {
1341                continue;
1342            };
1343            let non_null: Vec<&Value> = arr
1344                .iter()
1345                .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
1346                .collect();
1347            // Option<Enum>: single non-null branch beside a null branch.
1348            if non_null.len() == 1 && non_null.len() < arr.len() {
1349                return extract_enum_values(non_null[0], defs);
1350            }
1351            // Per-variant-doc shape: every branch is {"const": "x", ...}.
1352            let consts: Vec<&str> = non_null
1353                .iter()
1354                .filter_map(|v| v.get("const").and_then(|c| c.as_str()))
1355                .collect();
1356            if !consts.is_empty() && consts.len() == non_null.len() {
1357                return Some(consts);
1358            }
1359        }
1360        None
1361    }
1362
1363    /// Recursively walk a schema subtree, resolving `$ref` against the root
1364    /// `$defs` map (the visited set terminates cycles), and assert that every
1365    /// object property named `variant` / `tone` / `size` carries exactly the
1366    /// canonical value set. Increments `checked` per asserted property so the
1367    /// caller can prove the traversal is not vacuous.
1368    fn walk_canonical_enum_props(
1369        node: &Value,
1370        defs: &serde_json::Map<String, Value>,
1371        visited: &mut std::collections::HashSet<String>,
1372        checked: &mut usize,
1373    ) {
1374        match node {
1375            Value::Object(obj) => {
1376                if let Some(name) = obj
1377                    .get("$ref")
1378                    .and_then(|v| v.as_str())
1379                    .and_then(|r| r.strip_prefix("#/$defs/"))
1380                {
1381                    if visited.insert(name.to_string()) {
1382                        if let Some(target) = defs.get(name) {
1383                            walk_canonical_enum_props(target, defs, visited, checked);
1384                        }
1385                    }
1386                }
1387                if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
1388                    for (prop_name, prop_schema) in props {
1389                        let Some(want) = canonical_set_for(prop_name) else {
1390                            continue;
1391                        };
1392                        let got = extract_enum_values(prop_schema, defs).unwrap_or_else(|| {
1393                            panic!(
1394                                "schema property '{prop_name}' must be enum-typed with the \
1395                                 canonical vocabulary, got non-enum schema: {prop_schema}"
1396                            )
1397                        });
1398                        assert_eq!(
1399                            got.as_slice(),
1400                            want,
1401                            "schema property '{prop_name}' carries a non-canonical value set \
1402                             {got:?} (canonical: {want:?})"
1403                        );
1404                        *checked += 1;
1405                    }
1406                }
1407                for child in obj.values() {
1408                    walk_canonical_enum_props(child, defs, visited, checked);
1409                }
1410            }
1411            Value::Array(arr) => {
1412                for item in arr {
1413                    walk_canonical_enum_props(item, defs, visited, checked);
1414                }
1415            }
1416            _ => {}
1417        }
1418    }
1419
1420    #[test]
1421    fn variant_tone_size_enum_sets_drift_guard() {
1422        // D-19: canonical-vocabulary divergence must be a build failure. The
1423        // canonical value sets are pinned ONCE in CANONICAL_VARIANT / _TONE /
1424        // _SIZE above; this guard (1) asserts the three canonical $defs
1425        // directly, then (2) walks every component's props subtree and
1426        // (3) every root $defs entry transitively ($ref-resolved, no
1427        // exclusions — OQ-1 normalized the action-level fields to `tone`), so
1428        // a future `size: xs` anywhere in the catalog fails HERE.
1429        let cat = Catalog::build_builtins_only().expect("build succeeds");
1430        let schema = cat.json_schema();
1431        let defs = schema
1432            .get("$defs")
1433            .and_then(|v| v.as_object())
1434            .expect("assembled schema has a root $defs map");
1435
1436        // 1) The three canonical $defs are enums with exactly the canonical values.
1437        for (def_name, want) in [
1438            ("Variant", CANONICAL_VARIANT),
1439            ("Tone", CANONICAL_TONE),
1440            ("Size", CANONICAL_SIZE),
1441        ] {
1442            let def = defs
1443                .get(def_name)
1444                .unwrap_or_else(|| panic!("$defs/{def_name} missing from the assembled schema"));
1445            let got = extract_enum_values(def, defs)
1446                .unwrap_or_else(|| panic!("$defs/{def_name} is not an enum schema: {def}"));
1447            assert_eq!(
1448                got.as_slice(),
1449                want,
1450                "$defs/{def_name} value set drifted from the canonical enum"
1451            );
1452        }
1453
1454        // 2) Walk every component's oneOf props subtree transitively.
1455        let one_of = defs
1456            .get("Element")
1457            .and_then(|e| e.get("oneOf"))
1458            .and_then(|v| v.as_array())
1459            .expect("$defs/Element/oneOf array");
1460        assert_eq!(
1461            one_of.len(),
1462            crate::render::BUILTIN_TYPES.len(),
1463            "oneOf must carry one entry per builtin component"
1464        );
1465        let mut checked = 0usize;
1466        for entry in one_of {
1467            let props = entry
1468                .pointer("/allOf/1/properties/props")
1469                .unwrap_or_else(|| {
1470                    panic!("oneOf entry missing allOf[1].properties.props: {entry}")
1471                });
1472            let mut visited = std::collections::HashSet::new();
1473            walk_canonical_enum_props(props, defs, &mut visited, &mut checked);
1474        }
1475
1476        // 3) Walk every root $defs entry directly — action-level fields
1477        //    (ConfirmDialog.tone, ActionOutcome::Notify.tone inside
1478        //    $defs/Action) and any hoisted def must conform even if
1479        //    unreachable from a props subtree.
1480        let mut visited = std::collections::HashSet::new();
1481        for def in defs.values() {
1482            walk_canonical_enum_props(def, defs, &mut visited, &mut checked);
1483        }
1484
1485        assert!(
1486            checked >= 10,
1487            "walker asserted only {checked} variant/tone/size properties — \
1488             the schema traversal is broken (expected at least 10 across the catalog)"
1489        );
1490    }
1491
1492    #[test]
1493    fn builtin_specs_len_matches_dispatch() {
1494        // Relational: every builtin type must have exactly one catalog spec.
1495        // The absolute count is pinned once, in builtin_types_count_drift_guard.
1496        assert_eq!(BUILTIN_SPECS.len(), crate::render::BUILTIN_TYPES.len());
1497    }
1498
1499    #[test]
1500    fn builtin_specs_names_match_dispatch() {
1501        use std::collections::HashSet;
1502        let specs: HashSet<&str> = BUILTIN_SPECS.iter().map(|(n, ..)| *n).collect();
1503        let types: HashSet<&str> = crate::render::BUILTIN_TYPES.iter().copied().collect();
1504        assert_eq!(specs, types, "BUILTIN_SPECS names must match BUILTIN_TYPES");
1505    }
1506
1507    #[test]
1508    fn build_populates_all_builtins() {
1509        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1510        let cat = Catalog::build_builtins_only().expect("build succeeds");
1511        for name in crate::render::BUILTIN_TYPES.iter() {
1512            assert!(
1513                cat.components.contains_key(*name),
1514                "built-in '{name}' missing from catalog.components"
1515            );
1516            let spec = &cat.components[*name];
1517            assert_eq!(spec.name, *name);
1518            assert!(
1519                !spec.description.is_empty(),
1520                "'{name}' has empty description"
1521            );
1522            assert!(
1523                spec.props_schema.is_object(),
1524                "'{name}' props_schema is not a JSON object"
1525            );
1526            assert!(!spec.is_plugin);
1527        }
1528    }
1529
1530    #[test]
1531    fn build_card_has_footer_slot() {
1532        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1533        let cat = Catalog::build_builtins_only().expect("build succeeds");
1534        let card = &cat.components["Card"];
1535        assert_eq!(card.slot_fields, vec!["footer"]);
1536    }
1537
1538    #[test]
1539    fn build_modal_has_footer_slot() {
1540        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1541        let cat = Catalog::build_builtins_only().expect("build succeeds");
1542        let modal = &cat.components["Modal"];
1543        assert_eq!(modal.slot_fields, vec!["footer"]);
1544    }
1545
1546    #[test]
1547    fn build_pageheader_has_actions_slot() {
1548        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1549        let cat = Catalog::build_builtins_only().expect("build succeeds");
1550        let ph = &cat.components["PageHeader"];
1551        assert_eq!(ph.slot_fields, vec!["actions"]);
1552    }
1553
1554    #[test]
1555    fn build_text_has_no_slots() {
1556        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1557        let cat = Catalog::build_builtins_only().expect("build succeeds");
1558        assert!(cat.components["Text"].slot_fields.is_empty());
1559    }
1560
1561    #[test]
1562    fn build_populates_per_component_schemas() {
1563        // Use build_builtins_only() to avoid pollution from BadPlugin_117.
1564        let cat = Catalog::build_builtins_only().expect("build succeeds");
1565        assert_eq!(
1566            cat.per_component_schemas.len(),
1567            BUILTIN_SPECS.len() + cat.plugin_components.len()
1568        );
1569    }
1570
1571    #[test]
1572    fn sanitize_schema_rewrites_definitions_to_dollar_defs() {
1573        let raw = serde_json::json!({
1574            "type": "object",
1575            "definitions": { "Foo": { "type": "string" } },
1576            "properties": {
1577                "x": { "$ref": "#/definitions/Foo" }
1578            }
1579        });
1580        let out = sanitize_schema(raw);
1581        assert!(out.get("definitions").is_none());
1582        assert!(out.get("$defs").is_some());
1583        assert_eq!(
1584            out["properties"]["x"]["$ref"].as_str().unwrap(),
1585            "#/$defs/Foo"
1586        );
1587    }
1588
1589    #[test]
1590    fn sanitize_schema_is_idempotent() {
1591        let raw = serde_json::json!({
1592            "type": "object",
1593            "$defs": { "Foo": { "type": "string" } },
1594            "properties": {
1595                "x": { "$ref": "#/$defs/Foo" }
1596            }
1597        });
1598        let once = sanitize_schema(raw.clone());
1599        let twice = sanitize_schema(once.clone());
1600        assert_eq!(once, twice);
1601        // Existing $defs should remain, no definitions key introduced.
1602        assert!(twice.get("definitions").is_none());
1603        assert!(twice.get("$defs").is_some());
1604    }
1605
1606    #[test]
1607    fn json_schema_has_spec_envelope_shape() {
1608        // Use build_builtins_only() to avoid global plugin registry pollution
1609        // from build_discovers_plugins_and_rejects_invalid_schema (BadPlugin_117).
1610        let cat = Catalog::build_builtins_only().expect("build");
1611        let schema = cat.json_schema();
1612        assert_eq!(schema["$id"], "ferro-json-ui/v2");
1613        assert_eq!(schema["type"], "object");
1614        let required: Vec<&str> = schema["required"]
1615            .as_array()
1616            .unwrap()
1617            .iter()
1618            .map(|v| v.as_str().unwrap())
1619            .collect();
1620        assert!(required.contains(&"$schema"));
1621        assert!(required.contains(&"root"));
1622        assert!(required.contains(&"elements"));
1623    }
1624
1625    #[test]
1626    fn json_schema_has_action_and_visibility_defs() {
1627        let cat = Catalog::build_builtins_only().expect("build");
1628        let schema = cat.json_schema();
1629        assert!(
1630            schema["$defs"]["Action"].is_object(),
1631            "$defs/Action missing"
1632        );
1633        assert!(
1634            schema["$defs"]["Visibility"].is_object(),
1635            "$defs/Visibility missing"
1636        );
1637        assert!(
1638            schema["$defs"]["Element"].is_object(),
1639            "$defs/Element missing"
1640        );
1641    }
1642
1643    #[test]
1644    fn json_schema_oneof_covers_all_builtins() {
1645        let cat = Catalog::build_builtins_only().expect("build");
1646        let schema = cat.json_schema();
1647        // oneOf is at the Element level (discriminates on element.type, not props.type).
1648        let one_of = schema["$defs"]["Element"]["oneOf"]
1649            .as_array()
1650            .expect("Element.oneOf is an array");
1651
1652        // Extract every const discriminator from the allOf[0] branch.
1653        let mut discriminators: std::collections::HashSet<String> =
1654            std::collections::HashSet::new();
1655        for variant in one_of {
1656            let c = variant["allOf"][0]["properties"]["type"]["const"]
1657                .as_str()
1658                .expect("every variant pins a type const");
1659            discriminators.insert(c.to_string());
1660        }
1661
1662        for name in crate::render::BUILTIN_TYPES.iter() {
1663            assert!(
1664                discriminators.contains(*name),
1665                "oneOf is missing discriminator for '{name}'"
1666            );
1667        }
1668
1669        // Built-ins only — exactly BUILTIN_TYPES.len() variants.
1670        assert_eq!(
1671            discriminators.len(),
1672            crate::render::BUILTIN_TYPES.len(),
1673            "oneOf variant count mismatch"
1674        );
1675    }
1676
1677    #[test]
1678    fn json_schema_is_valid() {
1679        use jsonschema::draft202012;
1680        let cat = Catalog::build_builtins_only().expect("build");
1681        let schema = cat.json_schema();
1682        assert!(
1683            draft202012::meta::is_valid(schema),
1684            "assembled full_schema did not meta-validate as Draft 2020-12"
1685        );
1686    }
1687
1688    #[test]
1689    fn validator_is_compiled_once_and_usable() {
1690        let cat = Catalog::build_builtins_only().expect("build");
1691        // The validator field is private — we prove it's real by validating
1692        // a minimal valid spec value. If the validator were stale / null /
1693        // placeholder, this would fail or mis-report.
1694        let minimal_valid = serde_json::json!({
1695            "$schema": "ferro-json-ui/v2",
1696            "root": "r",
1697            "elements": {
1698                "r": { "type": "Text", "props": { "content": "hi" } }
1699            }
1700        });
1701        // Should succeed — full-schema envelope accepts this shape.
1702        assert!(cat.validator.is_valid(&minimal_valid));
1703    }
1704
1705    #[test]
1706    fn validator_rejects_wrong_schema_version() {
1707        let cat = Catalog::build_builtins_only().expect("build");
1708        let wrong_version = serde_json::json!({
1709            "$schema": "ferro-json-ui/v99-wrong",
1710            "root": "r",
1711            "elements": {
1712                "r": { "type": "Text", "props": { "content": "hi" } }
1713            }
1714        });
1715        assert!(
1716            !cat.validator.is_valid(&wrong_version),
1717            "validator should reject unknown $schema version via const"
1718        );
1719    }
1720
1721    #[test]
1722    fn oneof_variants_are_deterministic_sorted() {
1723        let cat1 = Catalog::build_builtins_only().expect("build 1");
1724        let cat2 = Catalog::build_builtins_only().expect("build 2");
1725        // Byte-exact equality guarantees deterministic output (CONTEXT D-18).
1726        assert_eq!(
1727            serde_json::to_string(cat1.json_schema()).unwrap(),
1728            serde_json::to_string(cat2.json_schema()).unwrap()
1729        );
1730    }
1731
1732    // ── validate() tests (Plan 04) ────────────────────────────────────────────
1733
1734    /// Build a minimal valid Spec with one Element of the given type + props.
1735    fn test_spec_with(type_name: &str, props: Value) -> crate::spec::Spec {
1736        use crate::spec::{Element, Spec};
1737        use std::collections::HashMap;
1738        let mut elements = HashMap::new();
1739        elements.insert(
1740            "r".to_string(),
1741            Element {
1742                type_name: type_name.to_string(),
1743                props,
1744                children: Vec::new(),
1745                action: None,
1746                visible: None,
1747                each: None,
1748                if_: None,
1749            },
1750        );
1751        Spec {
1752            schema: crate::spec::SCHEMA_VERSION.to_string(),
1753            root: "r".to_string(),
1754            elements,
1755            title: None,
1756            layout: None,
1757            fill_viewport: false,
1758            data: Value::Null,
1759            design: None,
1760        }
1761    }
1762
1763    #[test]
1764    fn validate_positive_per_type() {
1765        // Representative subset of built-ins — confirms validate() passes for
1766        // minimally valid elements. Full 39-type coverage lives in Plan 07.
1767        let cat = Catalog::build_builtins_only().expect("build");
1768        let cases: Vec<(&str, Value)> = vec![
1769            ("Text", serde_json::json!({ "content": "hi" })),
1770            ("Button", serde_json::json!({ "label": "Save" })),
1771            ("Badge", serde_json::json!({ "label": "New" })),
1772            ("Separator", serde_json::json!({})),
1773        ];
1774        for (ty, props) in cases {
1775            let spec = test_spec_with(ty, props.clone());
1776            match cat.validate(&spec) {
1777                Ok(()) => {}
1778                Err(errs) => panic!("validate({ty}) failed: {errs:?}"),
1779            }
1780        }
1781    }
1782
1783    #[test]
1784    fn validate_unknown_type() {
1785        let cat = Catalog::build_builtins_only().expect("build");
1786        let spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1787        let errs = cat.validate(&spec).expect_err("should fail");
1788        assert!(
1789            errs.iter().any(|e| matches!(
1790                e,
1791                CatalogError::UnknownType { type_name, .. } if type_name == "NotARealComponent"
1792            )),
1793            "expected UnknownType for NotARealComponent; got {errs:?}"
1794        );
1795    }
1796
1797    #[test]
1798    fn validate_missing_required_prop() {
1799        // CardProps.title is required (no Option, no #[serde(default)]).
1800        // Passing {} props should produce PropsInvalid.
1801        let cat = Catalog::build_builtins_only().expect("build");
1802        let spec = test_spec_with("Card", serde_json::json!({}));
1803        let errs = cat.validate(&spec).expect_err("should fail");
1804        assert!(
1805            errs.iter().any(|e| matches!(
1806                e,
1807                CatalogError::PropsInvalid { type_name, .. } if type_name == "Card"
1808            )),
1809            "expected PropsInvalid for missing required 'title'; got {errs:?}"
1810        );
1811    }
1812
1813    #[test]
1814    fn validate_rejects_retired_prop_names() {
1815        // Prop names renamed in the canonical vocabulary migration must fail
1816        // validation (Stage 2b) rather than be silently dropped by serde.
1817        let cat = Catalog::build_builtins_only().expect("build");
1818        let cases: Vec<(&str, Value, &str)> = vec![
1819            (
1820                "Badge",
1821                serde_json::json!({ "label": "Paid", "variant": "success" }),
1822                "tone",
1823            ),
1824            (
1825                "Card",
1826                serde_json::json!({ "title": "T", "variant": "elevated" }),
1827                "appearance",
1828            ),
1829            (
1830                "MediaCardGrid",
1831                serde_json::json!({
1832                    "data_path": "/rows",
1833                    "title_key": "name",
1834                    "badge_variant_key": "status"
1835                }),
1836                "badge_tone_key",
1837            ),
1838        ];
1839        for (ty, props, new_name) in cases {
1840            let spec = test_spec_with(ty, props);
1841            let errs = cat.validate(&spec).expect_err("should fail");
1842            assert!(
1843                errs.iter().any(|e| matches!(
1844                    e,
1845                    CatalogError::PropsInvalid { type_name, errors, .. }
1846                        if type_name == ty && errors.iter().any(|m| m.contains(new_name))
1847                )),
1848                "expected retired-prop PropsInvalid for {ty} mentioning `{new_name}`; got {errs:?}"
1849            );
1850        }
1851    }
1852
1853    #[test]
1854    fn validate_rejects_retired_confirm_and_notify_variant() {
1855        // `variant` inside props-embedded `confirm` dialogs and notify
1856        // outcomes was renamed to `tone`; the walk must catch it at any depth.
1857        let cat = Catalog::build_builtins_only().expect("build");
1858        let spec = test_spec_with(
1859            "DataTable",
1860            serde_json::json!({
1861                "data_path": "/rows",
1862                "columns": [{ "key": "name", "label": "Name" }],
1863                "row_actions": [{
1864                    "label": "Delete",
1865                    "action": {
1866                        "handler": "rows.destroy",
1867                        "method": "DELETE",
1868                        "confirm": { "title": "Delete?", "variant": "danger" },
1869                        "on_success": {
1870                            "type": "notify",
1871                            "message": "Deleted",
1872                            "variant": "error"
1873                        }
1874                    }
1875                }]
1876            }),
1877        );
1878        let errs = cat.validate(&spec).expect_err("should fail");
1879        let retired_msgs: Vec<&String> = errs
1880            .iter()
1881            .filter_map(|e| match e {
1882                CatalogError::PropsInvalid { errors, .. } => Some(errors),
1883                _ => None,
1884            })
1885            .flatten()
1886            .filter(|m| m.contains("renamed to `tone`"))
1887            .collect();
1888        assert_eq!(
1889            retired_msgs.len(),
1890            2,
1891            "expected confirm + notify retired-variant errors; got {errs:?}"
1892        );
1893    }
1894
1895    #[test]
1896    fn validate_accepts_canonical_prop_names() {
1897        // The renamed props themselves must pass Stage 2b.
1898        let cat = Catalog::build_builtins_only().expect("build");
1899        let cases: Vec<(&str, Value)> = vec![
1900            (
1901                "Badge",
1902                serde_json::json!({ "label": "Paid", "tone": "success" }),
1903            ),
1904            (
1905                "Card",
1906                serde_json::json!({ "title": "T", "appearance": "elevated" }),
1907            ),
1908        ];
1909        for (ty, props) in cases {
1910            let spec = test_spec_with(ty, props.clone());
1911            if let Err(errs) = cat.validate(&spec) {
1912                panic!("validate({ty}) with canonical props failed: {errs:?}");
1913            }
1914        }
1915    }
1916
1917    #[test]
1918    fn validate_bad_schema_version() {
1919        let cat = Catalog::build_builtins_only().expect("build");
1920        let mut spec = test_spec_with("Text", serde_json::json!({ "content": "hi" }));
1921        spec.schema = "ferro-json-ui/v99-wrong".to_string();
1922        let errs = cat.validate(&spec).expect_err("should fail");
1923        assert!(
1924            errs.iter()
1925                .any(|e| matches!(e, CatalogError::SpecInvalid { .. })),
1926            "expected SpecInvalid for wrong $schema version; got {errs:?}"
1927        );
1928    }
1929
1930    #[test]
1931    fn validate_pre_dispatch_short_circuits() {
1932        // Stage 1 must short-circuit: unknown type + malformed envelope →
1933        // only UnknownType surfaces (not SpecInvalid or PropsInvalid).
1934        let cat = Catalog::build_builtins_only().expect("build");
1935        let mut spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1936        spec.schema = "ferro-json-ui/v99-wrong".to_string();
1937        let errs = cat.validate(&spec).expect_err("should fail");
1938
1939        let has_unknown = errs
1940            .iter()
1941            .any(|e| matches!(e, CatalogError::UnknownType { .. }));
1942        let has_spec_invalid = errs
1943            .iter()
1944            .any(|e| matches!(e, CatalogError::SpecInvalid { .. }));
1945        let has_props_invalid = errs
1946            .iter()
1947            .any(|e| matches!(e, CatalogError::PropsInvalid { .. }));
1948
1949        assert!(has_unknown, "expected UnknownType");
1950        assert!(
1951            !has_spec_invalid,
1952            "Stage 3 ran despite Stage 1 failing: {errs:?}"
1953        );
1954        assert!(
1955            !has_props_invalid,
1956            "Stage 2 ran despite Stage 1 failing: {errs:?}"
1957        );
1958    }
1959
1960    #[test]
1961    fn validator_is_cached_not_recompiled() {
1962        // Structural guarantee: self.validator is a plain field, not recompiled
1963        // per validate() call. This test approximates that by running validate()
1964        // 100 times against a single Catalog without panic or regression.
1965        let cat = Catalog::build_builtins_only().expect("build");
1966        for _ in 0..100 {
1967            let spec = test_spec_with("Text", serde_json::json!({ "content": "x" }));
1968            assert!(cat.validate(&spec).is_ok());
1969        }
1970    }
1971
1972    #[test]
1973    fn validate_accumulates_multiple_errors_across_elements() {
1974        // Two elements with missing required props → two PropsInvalid errors.
1975        use crate::spec::{Element, Spec};
1976        use std::collections::HashMap;
1977        let cat = Catalog::build_builtins_only().expect("build");
1978        let mut elements = HashMap::new();
1979        elements.insert(
1980            "a".to_string(),
1981            Element {
1982                type_name: "Card".to_string(),
1983                props: serde_json::json!({}), // missing required "title"
1984                children: Vec::new(),
1985                action: None,
1986                visible: None,
1987                each: None,
1988                if_: None,
1989            },
1990        );
1991        elements.insert(
1992            "b".to_string(),
1993            Element {
1994                type_name: "Button".to_string(),
1995                props: serde_json::json!({}), // missing required "label"
1996                children: Vec::new(),
1997                action: None,
1998                visible: None,
1999                each: None,
2000                if_: None,
2001            },
2002        );
2003        let spec = Spec {
2004            schema: crate::spec::SCHEMA_VERSION.to_string(),
2005            root: "a".to_string(),
2006            elements,
2007            title: None,
2008            layout: None,
2009            fill_viewport: false,
2010            data: Value::Null,
2011            design: None,
2012        };
2013        let errs = cat.validate(&spec).expect_err("should fail");
2014        let props_invalid_count = errs
2015            .iter()
2016            .filter(|e| matches!(e, CatalogError::PropsInvalid { .. }))
2017            .count();
2018        assert!(
2019            props_invalid_count >= 2,
2020            "expected at least 2 PropsInvalid errors; got {errs:?}"
2021        );
2022    }
2023
2024    /// Combined plugin discovery + invalid schema rejection test.
2025    ///
2026    /// Uses unique names (`GoodPlugin_117`, `BadPlugin_117`) to avoid collisions
2027    /// with other test registrations. The bad plugin is registered after the good
2028    /// one to confirm discovery of the good plugin precedes the rejection.
2029    #[test]
2030    fn build_discovers_plugins_and_rejects_invalid_schema() {
2031        use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
2032
2033        struct GoodPlugin;
2034        impl JsonUiPlugin for GoodPlugin {
2035            fn component_type(&self) -> &str {
2036                "GoodPlugin_117"
2037            }
2038            fn props_schema(&self) -> Value {
2039                serde_json::json!({ "type": "object" })
2040            }
2041            fn render(&self, _: &Value, _: &Value) -> String {
2042                String::new()
2043            }
2044            fn css_assets(&self) -> Vec<Asset> {
2045                vec![]
2046            }
2047            fn js_assets(&self) -> Vec<Asset> {
2048                vec![]
2049            }
2050            fn init_script(&self) -> Option<String> {
2051                None
2052            }
2053        }
2054
2055        register_plugin(GoodPlugin);
2056
2057        // Positive discovery: GoodPlugin should appear in plugin_components.
2058        let cat = Catalog::build().expect("build succeeds with valid plugin only");
2059        assert!(
2060            cat.plugin_components.contains_key("GoodPlugin_117"),
2061            "plugin 'GoodPlugin_117' should have been discovered"
2062        );
2063        assert!(cat.plugin_components["GoodPlugin_117"].is_plugin);
2064
2065        // Now register a bad plugin and confirm build fails with the plugin name embedded.
2066        struct BadPlugin;
2067        impl JsonUiPlugin for BadPlugin {
2068            fn component_type(&self) -> &str {
2069                "BadPlugin_117"
2070            }
2071            fn props_schema(&self) -> Value {
2072                // JSON Schema requires `type` to be a string or array of strings.
2073                // Number 42 is invalid → validator_for() rejects it.
2074                serde_json::json!({ "type": 42 })
2075            }
2076            fn render(&self, _: &Value, _: &Value) -> String {
2077                String::new()
2078            }
2079            fn css_assets(&self) -> Vec<Asset> {
2080                vec![]
2081            }
2082            fn js_assets(&self) -> Vec<Asset> {
2083                vec![]
2084            }
2085            fn init_script(&self) -> Option<String> {
2086                None
2087            }
2088        }
2089
2090        register_plugin(BadPlugin);
2091        match Catalog::build() {
2092            Err(CatalogError::BuildFailed(msg)) => {
2093                assert!(
2094                    msg.contains("BadPlugin_117"),
2095                    "error should mention plugin name, got: {msg}"
2096                );
2097            }
2098            Err(other) => panic!("expected BuildFailed mentioning BadPlugin_117, got: {other:?}"),
2099            Ok(_) => panic!("expected build to fail due to invalid plugin schema"),
2100        }
2101    }
2102
2103    // ── component_schema / sorted accessor tests (Plan 05) ───────────────────
2104
2105    #[test]
2106    fn component_schema_returns_props_only() {
2107        // ROADMAP SC-5 canonical example: catalog.component_schema("Card") must
2108        // return the CardProps schema (NOT the Element wrapper from
2109        // $defs/Element.properties.props in the full schema).
2110        let cat = Catalog::build_builtins_only().expect("build");
2111        let schema = cat
2112            .component_schema("Card")
2113            .expect("Card is a built-in component");
2114
2115        // A Props schema is an object with a `properties` map. The Element
2116        // envelope would have a `type` + `props` + `children` layout — we
2117        // assert the Props-only shape by checking for CardProps fields.
2118        let obj = schema
2119            .as_object()
2120            .expect("Card props schema is a JSON object");
2121
2122        // Expect "type": "object" or equivalent (schemars uses `type` or `oneOf`).
2123        assert!(
2124            obj.contains_key("type") || obj.contains_key("oneOf") || obj.contains_key("anyOf"),
2125            "CardProps schema should be a structural object schema; got {obj:?}"
2126        );
2127
2128        // Expect CardProps-specific field "title" exists in `properties`.
2129        if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
2130            assert!(
2131                props.contains_key("title"),
2132                "CardProps schema.properties should include 'title'; got keys: {:?}",
2133                props.keys().collect::<Vec<_>>()
2134            );
2135        } else {
2136            panic!(
2137                "CardProps schema missing top-level 'properties' map — \
2138                 sanitizer or Plan 02 may be wrong. Got: {}",
2139                serde_json::to_string_pretty(schema).unwrap_or_default()
2140            );
2141        }
2142
2143        // Must NOT be the Element envelope (would mean we accidentally returned
2144        // full_schema["$defs"]["Element"] or similar — CONTEXT D-19).
2145        let is_element_wrapper = obj
2146            .get("properties")
2147            .and_then(|v| v.as_object())
2148            .map(|p| p.contains_key("children") && p.contains_key("props"))
2149            .unwrap_or(false);
2150        assert!(
2151            !is_element_wrapper,
2152            "component_schema('Card') returned an Element wrapper; must be Props-only (CONTEXT D-19)"
2153        );
2154    }
2155
2156    #[test]
2157    fn component_schema_none_for_unknown() {
2158        let cat = Catalog::build_builtins_only().expect("build");
2159        assert!(
2160            cat.component_schema("NotARealComponent_117_05").is_none(),
2161            "unknown component must return None"
2162        );
2163        // Empty string is also "unknown".
2164        assert!(cat.component_schema("").is_none());
2165    }
2166
2167    #[test]
2168    fn component_schema_resolves_every_builtin() {
2169        // Parallel safety net for SC-5: every name in BUILTIN_TYPES must have a
2170        // per-component schema. If any is missing, Plan 02's BUILTIN_SPECS table
2171        // or the build loop dropped an entry.
2172        let cat = Catalog::build_builtins_only().expect("build");
2173        for name in crate::render::BUILTIN_TYPES.iter() {
2174            assert!(
2175                cat.component_schema(name).is_some(),
2176                "built-in '{name}' has no per-component schema"
2177            );
2178        }
2179    }
2180
2181    #[test]
2182    fn components_sorted_yields_ascending_by_name() {
2183        let cat = Catalog::build_builtins_only().expect("build");
2184        let names: Vec<String> = cat
2185            .components_sorted()
2186            .map(|spec| spec.name.clone())
2187            .collect();
2188        assert_eq!(names.len(), crate::render::BUILTIN_TYPES.len());
2189        let mut sorted = names.clone();
2190        sorted.sort();
2191        assert_eq!(
2192            names, sorted,
2193            "components_sorted must yield ascending order"
2194        );
2195
2196        // plugin_components_sorted returns the plugin side; may be empty.
2197        let plugin_names: Vec<String> = cat
2198            .plugin_components_sorted()
2199            .map(|spec| spec.name.clone())
2200            .collect();
2201        let mut plugin_sorted = plugin_names.clone();
2202        plugin_sorted.sort();
2203        assert_eq!(
2204            plugin_names, plugin_sorted,
2205            "plugin_components_sorted must yield ascending order"
2206        );
2207    }
2208
2209    // ── prompt() tests (Plan 06) ─────────────────────────────────────────────
2210
2211    #[test]
2212    fn prompt_under_size_budget() {
2213        let cat = Catalog::build_builtins_only().expect("build");
2214        let prompt = cat.prompt();
2215        let bytes = prompt.len();
2216        // Budget bumped from 8 KB to 9 KB in Phase 162 Plan 01 (CheckboxList added, 40 components).
2217        // Budget bumped from 9 KB to 10 KB in Phase 175 Plan 04 (CheckboxGroup alias added, 43 components).
2218        // Budget bumped from 10 KB to 11 KB in Phase 169 Plan 02 (StreamText added, 45 components).
2219        // Budget bumped from 11 KB to 12 KB in Phase 251 Plan 03 ($ref'd enum
2220        // values inlined in prop docs — canonical Variant/Tone/Size surfaced).
2221        // Budget bumped from 12 KB to 13 KB in Phase 256 Plan 03 (QuantityStepper + Numpad +
2222        // SelectionPanel added — 52 components total).
2223        assert!(
2224            bytes <= 13 * 1024,
2225            "prompt() is {bytes} bytes, exceeds 13 KB budget (CONTEXT D-17)"
2226        );
2227    }
2228
2229    #[test]
2230    fn prompt_mentions_every_builtin() {
2231        let cat = Catalog::build_builtins_only().expect("build");
2232        let prompt = cat.prompt();
2233        for name in crate::render::BUILTIN_TYPES.iter() {
2234            let heading = format!("### {name}\n");
2235            assert!(
2236                prompt.contains(&heading),
2237                "prompt() missing section heading for '{name}'"
2238            );
2239        }
2240    }
2241
2242    #[test]
2243    fn prompt_inlines_canonical_enum_values() {
2244        // Enum-typed props referenced via $ref must surface their values
2245        // inline — an agent reading the prompt sees the exact canonical
2246        // vocabulary, not `<see schema>`.
2247        let cat = Catalog::build_builtins_only().expect("build");
2248        let prompt = cat.prompt();
2249        for values in [
2250            CANONICAL_VARIANT.join("|"),
2251            CANONICAL_TONE.join("|"),
2252            CANONICAL_SIZE.join("|"),
2253        ] {
2254            assert!(
2255                prompt.contains(&values),
2256                "prompt() must inline the canonical enum values '{values}'"
2257            );
2258        }
2259    }
2260
2261    #[test]
2262    fn prompt_is_deterministic() {
2263        let cat1 = Catalog::build_builtins_only().expect("build 1");
2264        let cat2 = Catalog::build_builtins_only().expect("build 2");
2265        assert_eq!(
2266            cat1.prompt(),
2267            cat2.prompt(),
2268            "prompt() must be deterministic"
2269        );
2270    }
2271
2272    #[test]
2273    fn prompt_documents_slot_fields() {
2274        // CardProps has slot_fields = ["footer"] (set in Plan 02). The prompt
2275        // must include a `Slots:` line for Card.
2276        let cat = Catalog::build_builtins_only().expect("build");
2277        let prompt = cat.prompt();
2278        let card_start = prompt.find("### Card\n").expect("Card section present");
2279        let card_slice = &prompt[card_start..];
2280        // End at the next ### heading (or EOF).
2281        let end = card_slice[3..]
2282            .find("### ")
2283            .map(|i| i + 3)
2284            .unwrap_or(card_slice.len());
2285        let card_section = &card_slice[..end];
2286        assert!(
2287            card_section.contains("Slots: footer"),
2288            "Card section missing 'Slots: footer' line:\n{card_section}"
2289        );
2290    }
2291
2292    #[test]
2293    fn prompt_is_not_raw_json_schema() {
2294        let cat = Catalog::build_builtins_only().expect("build");
2295        let prompt = cat.prompt();
2296        assert!(
2297            prompt.starts_with("## Component Catalog"),
2298            "prompt() should start with Markdown header, not JSON"
2299        );
2300        assert!(
2301            !prompt.contains("\"$schema\""),
2302            "prompt() must not embed raw JSON Schema (ROADMAP caveat)"
2303        );
2304    }
2305
2306    #[test]
2307    fn catalog_contains_checkbox_group() {
2308        let cat = Catalog::build_builtins_only().expect("build");
2309        assert!(
2310            cat.component_schema("CheckboxGroup").is_some(),
2311            "CheckboxGroup must be registered in BUILTIN_SPECS as an alias for CheckboxList"
2312        );
2313    }
2314
2315    // ── Stage 2b el.action walk tests (Plan 252-02) ──────────────────────────
2316
2317    /// Build a minimal spec from a JSON string (goes through `Spec::from_json`
2318    /// so the element-level `action` field is typed-deserialized).
2319    fn spec_from_json_string(json: &str) -> crate::spec::Spec {
2320        crate::spec::Spec::from_json(json).expect("test spec must parse")
2321    }
2322
2323    #[test]
2324    fn validate_rejects_retired_el_action_confirm_variant() {
2325        // A typed element-level `action.confirm.variant` (retired in Phase 251)
2326        // must be caught by Stage 2b after the el.action walk is added.
2327        // The error must mention the `/action` path prefix.
2328        let cat = Catalog::build_builtins_only().expect("build");
2329        let spec = spec_from_json_string(
2330            r#"{
2331                "$schema": "ferro-json-ui/v2",
2332                "root": "btn",
2333                "elements": {
2334                    "btn": {
2335                        "type": "Button",
2336                        "props": { "label": "Delete" },
2337                        "action": {
2338                            "handler": "orders.destroy",
2339                            "method": "POST",
2340                            "confirm": {
2341                                "title": "Delete?",
2342                                "message": "x",
2343                                "variant": "danger"
2344                            }
2345                        }
2346                    }
2347                }
2348            }"#,
2349        );
2350        let errs = cat
2351            .validate(&spec)
2352            .expect_err("should fail: confirm.variant is a retired prop");
2353        assert!(
2354            errs.iter().any(|e| matches!(
2355                e,
2356                CatalogError::PropsInvalid { errors, .. }
2357                    if errors.iter().any(|m| m.contains("/action") && m.contains("variant"))
2358            )),
2359            "expected PropsInvalid mentioning /action and variant; got {errs:?}"
2360        );
2361    }
2362
2363    #[test]
2364    fn validate_accepts_canonical_el_action_confirm_tone() {
2365        // The canonical `confirm.tone` field must not produce any false positive.
2366        let cat = Catalog::build_builtins_only().expect("build");
2367        let spec = spec_from_json_string(
2368            r#"{
2369                "$schema": "ferro-json-ui/v2",
2370                "root": "btn",
2371                "elements": {
2372                    "btn": {
2373                        "type": "Button",
2374                        "props": { "label": "Delete" },
2375                        "action": {
2376                            "handler": "orders.destroy",
2377                            "method": "POST",
2378                            "confirm": {
2379                                "title": "Delete?",
2380                                "message": "x",
2381                                "tone": "destructive"
2382                            }
2383                        }
2384                    }
2385                }
2386            }"#,
2387        );
2388        if let Err(errs) = cat.validate(&spec) {
2389            panic!("validate with canonical confirm.tone failed: {errs:?}");
2390        }
2391    }
2392
2393    #[test]
2394    fn global_catalog_includes_stream_text() {
2395        let cat = Catalog::build_builtins_only().expect("build");
2396        assert!(
2397            cat.components.contains_key("StreamText"),
2398            "catalog must include StreamText"
2399        );
2400        let spec = &cat.components["StreamText"];
2401        assert_eq!(spec.name, "StreamText");
2402        assert!(
2403            spec.description.contains("event: done"),
2404            "StreamText description must mention 'event: done'; got: {}",
2405            spec.description
2406        );
2407        assert!(
2408            spec.props_schema.is_object(),
2409            "StreamText props_schema must be a JSON object"
2410        );
2411        assert!(!spec.is_plugin);
2412    }
2413
2414    /// A Tile template element with a $data-bound price_cents (Option<u64>) must
2415    /// pass catalog validation when spec.data is null (the builder default).
2416    /// Regression: strip_expr_objects turned {"$data":..} into "" which failed
2417    /// the anyOf[integer,null] JSON Schema for price_cents (D-14 Critical Finding).
2418    #[test]
2419    fn catalog_each_template_null_data() {
2420        use crate::spec::{Element, Spec};
2421        use serde_json::json;
2422        let cat = Catalog::build_builtins_only().expect("build");
2423        let spec = Spec::builder()
2424            .element(
2425                "grid",
2426                Element::new("TileGrid")
2427                    .prop("data_path", "/data/items")
2428                    .prop("form_id", "f")
2429                    .child("tile_tmpl"),
2430            )
2431            .element(
2432                "tile_tmpl",
2433                Element::new("Tile")
2434                    .each("/data/items", "p")
2435                    .prop("item_id", json!({"$data": "/p/id"}))
2436                    .prop("name", json!({"$data": "/p/name"}))
2437                    .prop("price", json!({"$data": "/p/price"}))
2438                    .prop("field", json!({"$data": "/p/field"}))
2439                    .prop("price_cents", json!({"$data": "/p/price_cents"})),
2440            )
2441            .build()
2442            .expect("spec builds");
2443        if let Err(errs) = cat.validate(&spec) {
2444            panic!("catalog_each_template_null_data failed: {errs:?}");
2445        }
2446    }
2447
2448    /// Same spec as catalog_each_template_null_data but with populated data.
2449    /// The fixture nests rows under a top-level `"data"` key so `/data/items`
2450    /// actually resolves — `.build()` runs `validate_directives`, exercising
2451    /// the path-resolves-to-array branch (D-14); `cat.validate` then covers
2452    /// the catalog's $each-template guard with populated data.
2453    #[test]
2454    fn catalog_each_template_populated_data() {
2455        use crate::spec::{Element, Spec};
2456        use serde_json::json;
2457        let cat = Catalog::build_builtins_only().expect("build");
2458        let spec = Spec::builder()
2459            .data(json!({
2460                "data": {
2461                    "items": [
2462                        {"id": "1", "name": "A", "price": "1,00", "price_cents": 100, "field": "qty_1"}
2463                    ]
2464                }
2465            }))
2466            .element(
2467                "grid",
2468                Element::new("TileGrid")
2469                    .prop("data_path", "/data/items")
2470                    .prop("form_id", "f")
2471                    .child("tile_tmpl"),
2472            )
2473            .element(
2474                "tile_tmpl",
2475                Element::new("Tile")
2476                    .each("/data/items", "p")
2477                    .prop("item_id", json!({"$data": "/p/id"}))
2478                    .prop("name", json!({"$data": "/p/name"}))
2479                    .prop("price", json!({"$data": "/p/price"}))
2480                    .prop("field", json!({"$data": "/p/field"}))
2481                    .prop("price_cents", json!({"$data": "/p/price_cents"})),
2482            )
2483            .build()
2484            .expect("spec builds");
2485        if let Err(errs) = cat.validate(&spec) {
2486            panic!("catalog_each_template_populated_data failed: {errs:?}");
2487        }
2488    }
2489
2490    /// Negative counterpart of catalog_each_template_populated_data: when the
2491    /// `$each` path resolves to a non-array, `.build()` must reject the spec
2492    /// with `EachPathNotArray` (the same branch, failure side).
2493    #[test]
2494    fn catalog_each_template_path_not_array_rejected_at_build() {
2495        use crate::spec::{Element, Spec, SpecError};
2496        use serde_json::json;
2497        let err = Spec::builder()
2498            .data(json!({"data": {"items": {"not": "an array"}}}))
2499            .element("tile_tmpl", Element::new("Tile").each("/data/items", "p"))
2500            .build()
2501            .expect_err("non-array $each path must fail spec build");
2502        assert!(
2503            matches!(err, SpecError::EachPathNotArray { .. }),
2504            "expected EachPathNotArray, got: {err:?}"
2505        );
2506    }
2507
2508    /// The assembled full Spec schema must expose every `Spec` root field —
2509    /// agents discover the spec shape from this document (MCP `json_ui_schema`),
2510    /// so an omitted field is undiscoverable and schema-strict consumers would
2511    /// reject valid specs.
2512    #[test]
2513    fn full_schema_root_exposes_all_spec_fields() {
2514        let cat = Catalog::build_builtins_only().expect("build");
2515        let schema = cat.json_schema();
2516        let props = schema
2517            .get("properties")
2518            .and_then(|v| v.as_object())
2519            .expect("root properties object");
2520        for key in [
2521            "$schema",
2522            "root",
2523            "elements",
2524            "title",
2525            "layout",
2526            "fill_viewport",
2527            "data",
2528            "design",
2529        ] {
2530            assert!(props.contains_key(key), "root schema must expose '{key}'");
2531        }
2532        assert!(
2533            schema.pointer("/$defs/DesignMeta").is_some(),
2534            "DesignMeta def must be hoisted into $defs"
2535        );
2536    }
2537}