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