Skip to main content

ferro_json_ui/
resolve.rs

1//! Resolvers for v2 JSON-UI Spec element maps.
2//!
3//! Walks a `Spec`'s flat element map and resolves action handler names to
4//! URLs, or populates per-field validation errors on form-like elements.
5//!
6//! Phase 115: flat iteration only. No tree descent — children are ID
7//! strings, not nested structs. Action resolution is per-element.
8
9use std::collections::HashMap;
10
11use serde_json::Value;
12
13use crate::action::{Action, ActionHandler};
14use crate::spec::{Element, Spec};
15
16/// Resolve a single action using the callback. Literal paths (starting
17/// with '/') are passed through as-is so callers can use
18/// `Action::get("/dashboard/...")` without registering a named route.
19///
20/// `data` is the `spec.data` payload — used to resolve `ActionHandler::Binding`
21/// references (F14, Phase 165). Binding-shape handlers resolve against the
22/// JSON pointer in `spec.data`; the resolved string is then treated as a
23/// literal handler (resolver lookup or `/path` pass-through).
24fn resolve_action(
25    action: &mut Action,
26    data: &serde_json::Value,
27    resolver: &impl Fn(&str) -> Option<String>,
28) {
29    if action.url.is_some() {
30        return;
31    }
32    // Materialise the handler to a literal string. Binding-shape handlers
33    // resolve against spec.data; missing bindings degrade to no-op so the
34    // diagnostic comment surfaces in render.
35    let literal: Option<String> = match &action.handler {
36        ActionHandler::Literal(s) => Some(s.clone()),
37        ActionHandler::Binding(d) => crate::data::resolve_path(data, &d.data)
38            .and_then(|v| v.as_str())
39            .map(|s| s.to_string()),
40    };
41    let Some(s) = literal else { return };
42
43    if s.starts_with('/') {
44        action.url = Some(s);
45        return;
46    }
47    if let Some(url) = resolver(&s) {
48        action.url = Some(url);
49    }
50}
51
52/// Resolve every `Element.action` AND every Action-shaped object nested
53/// inside `Element.props` via the provided resolver closure.
54///
55/// The props walk handles fields such as `FormProps.action`,
56/// `EmptyStateProps.action`, `DataTableProps.row_actions[].action`,
57/// `SwitchProps.action`, and any future props that carry an Action
58/// payload — without each renderer having to re-implement URL resolution.
59///
60/// Mutates in place. Silent on missing handlers — use
61/// `resolve_actions_strict` if you want to collect missing names.
62pub fn resolve_actions(spec: &mut Spec, resolver: impl Fn(&str) -> Option<String>) {
63    let data = spec.data.clone();
64    for el in spec.elements.values_mut() {
65        if let Some(action) = el.action.as_mut() {
66            resolve_action(action, &data, &resolver);
67        }
68        resolve_actions_in_value(&mut el.props, &data, &resolver);
69    }
70}
71
72/// Walk a `serde_json::Value` recursively. Every object that looks like a
73/// raw Action (carries a `handler` field but no resolved `url`) gets a
74/// `url` populated using the same rules as `resolve_action`:
75///
76/// - Literal `"/path"` handler → url is the same path.
77/// - Literal route-name handler → url comes from the resolver.
78/// - `{"$data": "/spec/path"}` binding → resolved against `spec.data`,
79///   then the literal rule applies.
80///
81/// Recursion descends through every nested object and array so deeply
82/// nested action shapes (e.g. `row_actions[].action`) are reached without
83/// per-component plumbing.
84fn resolve_actions_in_value(
85    value: &mut Value,
86    data: &Value,
87    resolver: &impl Fn(&str) -> Option<String>,
88) {
89    match value {
90        Value::Object(map) => {
91            let has_handler = map.contains_key("handler");
92            let already_resolved = matches!(map.get("url"), Some(Value::String(_)));
93            if has_handler && !already_resolved {
94                if let Some(url) = resolve_props_handler_to_url(map.get("handler"), data, resolver)
95                {
96                    map.insert("url".to_string(), Value::String(url));
97                }
98            }
99            for v in map.values_mut() {
100                resolve_actions_in_value(v, data, resolver);
101            }
102        }
103        Value::Array(arr) => {
104            for v in arr.iter_mut() {
105                resolve_actions_in_value(v, data, resolver);
106            }
107        }
108        _ => {}
109    }
110}
111
112/// Materialise a JSON `handler` value to a URL string. Mirrors
113/// [`resolve_action`] but operates on raw JSON shapes (used in props,
114/// before per-type deserialization).
115fn resolve_props_handler_to_url(
116    handler: Option<&Value>,
117    data: &Value,
118    resolver: &impl Fn(&str) -> Option<String>,
119) -> Option<String> {
120    let literal: String = match handler? {
121        Value::String(s) => s.clone(),
122        Value::Object(map) if map.len() == 1 => {
123            let path = map.get("$data").and_then(|v| v.as_str())?;
124            crate::data::resolve_path(data, path)
125                .and_then(|v| v.as_str())
126                .map(|s| s.to_string())?
127        }
128        _ => return None,
129    };
130    if literal.starts_with('/') {
131        Some(literal)
132    } else {
133        resolver(&literal)
134    }
135}
136
137/// Strict variant: returns `Err(missing_handlers)` if any handler did not
138/// resolve to a URL. Literal `/path` handlers are always considered
139/// resolved.
140pub fn resolve_actions_strict(
141    spec: &mut Spec,
142    resolver: impl Fn(&str) -> Option<String>,
143) -> Result<(), Vec<String>> {
144    let data = spec.data.clone();
145    let mut missing: Vec<String> = Vec::new();
146    for el in spec.elements.values_mut() {
147        if let Some(action) = el.action.as_mut() {
148            resolve_action(action, &data, &resolver);
149            if action.url.is_none() {
150                missing.push(action.handler.as_str().to_string());
151            }
152        }
153        resolve_actions_in_value(&mut el.props, &data, &resolver);
154    }
155    if missing.is_empty() {
156        Ok(())
157    } else {
158        Err(missing)
159    }
160}
161
162/// Populate validation errors onto any element whose props contain a
163/// `"name"` field (or `"field"` field) matching an error key.
164pub fn resolve_errors(spec: &mut Spec, errors: &HashMap<String, Vec<String>>) {
165    for el in spec.elements.values_mut() {
166        attach_errors(el, errors, false);
167    }
168}
169
170/// Variant that writes the full error bag onto every element (regardless
171/// of name match).
172pub fn resolve_errors_all(spec: &mut Spec, errors: &HashMap<String, Vec<String>>) {
173    for el in spec.elements.values_mut() {
174        attach_errors(el, errors, true);
175    }
176}
177
178fn attach_errors(el: &mut Element, errors: &HashMap<String, Vec<String>>, all: bool) {
179    let Some(props_obj) = el.props.as_object_mut() else {
180        return;
181    };
182    // Match by either `name` or `field` prop (inputs use `field`, other
183    // elements commonly use `name`).
184    let key = props_obj
185        .get("name")
186        .or_else(|| props_obj.get("field"))
187        .and_then(|v| v.as_str())
188        .map(String::from);
189    if let Some(k) = key {
190        if let Some(msgs) = errors.get(&k) {
191            if let Some(first) = msgs.first() {
192                props_obj.insert("error".to_string(), Value::String(first.clone()));
193            }
194        }
195    } else if all {
196        if let Ok(errors_value) = serde_json::to_value(errors) {
197            props_obj.insert("errors".to_string(), errors_value);
198        }
199    }
200}
201
202// ---------------------------------------------------------------------------
203// Section — Directive expansion (Phase 163: $each, $if)
204// ---------------------------------------------------------------------------
205
206/// Expand `$each` / `$if` directives in `spec` against `spec.data`.
207///
208/// Pass order:
209/// 1. `$if`-falsy elements are REMOVED from `spec.elements`.
210/// 2. `$each` templated elements are REPLACED by N clones with
211///    auto-suffixed IDs (`{tmpl_id}-0` .. `{tmpl_id}-(N-1)`).
212/// 3. Every parent's `children` list is rewritten to reference the new clone
213///    IDs (or pruned if the child was `$if`-deleted or `$each` with empty
214///    rows).
215///
216/// When `$if` AND `$each` co-occur on the same element, `$if` is evaluated
217/// first; if false, the element is removed before `$each` expansion (no
218/// clones produced). This is the planner-locked ordering (163-03-PLAN #5).
219///
220/// Correlated child indexes: when a templated element's child points to
221/// another templated element with the SAME `{path, as}` directive, the
222/// cloned parent at index `i` references the cloned child at index `i`.
223/// Mismatched-each child references are caught at validation time
224/// (`SpecError::MismatchedEach` — Plan 04).
225///
226/// This pass is IDEMPOTENT: after expansion, every clone has its directive
227/// fields cleared (`each = None`, `if_ = None`), so a second call is a no-op.
228///
229/// Pipeline position: must run BEFORE `resolve_actions` and
230/// `resolve_expressions` so those passes operate on the post-expansion
231/// element set.
232pub fn expand_directives(spec: &mut Spec) {
233    let data = spec.data.clone();
234    // Pass 1: remove $if-falsy elements (templates included — a falsy $if on
235    // a templated element prevents its $each expansion entirely).
236    let if_removed = remove_if_falsy(spec, &data);
237    // Pass 2: expand $each templated elements (those that survived $if).
238    let each_expanded = expand_each(spec, &data);
239    // Pass 3: rewrite parent children lists to reference new IDs / prune removed ones.
240    rewrite_parent_children(spec, &if_removed, &each_expanded);
241}
242
243/// Remove elements whose `$if` predicate evaluates false; return the set of
244/// removed IDs so `rewrite_parent_children` can prune dangling references.
245/// Surviving elements have `if_` cleared for idempotency.
246fn remove_if_falsy(spec: &mut Spec, data: &serde_json::Value) -> std::collections::HashSet<String> {
247    let mut to_delete: Vec<String> = Vec::new();
248    for (id, el) in spec.elements.iter() {
249        if let Some(predicate) = &el.if_ {
250            // SOLE predicate evaluator — D-04 reuse mandate.
251            if !predicate.evaluate(data) {
252                to_delete.push(id.clone());
253            }
254        }
255    }
256    let removed: std::collections::HashSet<String> = to_delete.iter().cloned().collect();
257    for id in &to_delete {
258        spec.elements.remove(id);
259    }
260    // Strip if_ on the survivors so a second pass is a no-op.
261    for el in spec.elements.values_mut() {
262        if el.if_.is_some() {
263            el.if_ = None;
264        }
265    }
266    removed
267}
268
269/// Expand every `$each`-templated element into N clones; return a map from
270/// each template ID to its ordered list of clone IDs (empty Vec if the
271/// data array was missing / empty / non-array).
272fn expand_each(
273    spec: &mut Spec,
274    data: &serde_json::Value,
275) -> std::collections::HashMap<String, Vec<String>> {
276    // Snapshot templates BEFORE mutation so iteration order is deterministic
277    // and we can read sibling directives during correlated-child rewriting.
278    let templates: Vec<(String, Element)> = spec
279        .elements
280        .iter()
281        .filter_map(|(id, el)| el.each.as_ref().map(|_| (id.clone(), el.clone())))
282        .collect();
283
284    let template_directives: std::collections::HashMap<String, crate::spec::EachDirective> =
285        templates
286            .iter()
287            .map(|(id, el)| (id.clone(), el.each.clone().unwrap()))
288            .collect();
289
290    let mut expanded: std::collections::HashMap<String, Vec<String>> =
291        std::collections::HashMap::new();
292
293    for (tmpl_id, tmpl_el) in &templates {
294        let each = tmpl_el.each.as_ref().unwrap();
295        let rows: Vec<serde_json::Value> = crate::data::resolve_path(data, &each.path)
296            .and_then(|v| v.as_array())
297            .cloned()
298            .unwrap_or_default();
299        let mut clone_ids: Vec<String> = Vec::with_capacity(rows.len());
300        for (i, row) in rows.iter().enumerate() {
301            let clone_id = format!("{tmpl_id}-{i}");
302            let mut clone = tmpl_el.clone();
303            clone.each = None; // strip directive — idempotent
304            clone.if_ = None;
305            // Pre-resolve /{as}/... paths in props against the current row.
306            inline_resolve_row_paths(&mut clone.props, &each.as_, row);
307            // Phase 165 F14: pre-resolve /{as}/... action.handler bindings to
308            // a literal string so per-row navigation works (handler accepts
309            // {"$data": "/{as}/action_url"} → ActionHandler::Literal("/dashboard/.../...")).
310            if let Some(action) = clone.action.as_mut() {
311                inline_resolve_row_action(action, &each.as_, row);
312            }
313            // Rewrite this clone's children list for correlated-each siblings.
314            for child in clone.children.iter_mut() {
315                if let Some(child_each) = template_directives.get(child) {
316                    if child_each.path == each.path && child_each.as_ == each.as_ {
317                        *child = format!("{child}-{i}");
318                    }
319                    // Different {path, as} would be caught by the validator
320                    // (MismatchedEach, Plan 04). At runtime we leave the ID
321                    // literal; the render layer emits a missing-id comment
322                    // if it does not exist.
323                }
324            }
325            spec.elements.insert(clone_id.clone(), clone);
326            clone_ids.push(clone_id);
327        }
328        spec.elements.remove(tmpl_id);
329        expanded.insert(tmpl_id.clone(), clone_ids);
330    }
331
332    expanded
333}
334
335/// Walk `value` and replace every `{"$data": "/{as}/..."}` expression that
336/// references the loop variable with the literal value from `row`. Paths NOT
337/// starting with `/{as}/` are left untouched (they resolve against
338/// `spec.data` later via `resolve_expressions`).
339fn inline_resolve_row_paths(value: &mut serde_json::Value, as_name: &str, row: &serde_json::Value) {
340    let prefix = format!("/{as_name}/");
341    inline_walk(value, &prefix, row, as_name);
342}
343
344/// Phase 165 F14: pre-resolve `ActionHandler::Binding` references that point
345/// into the current `$each` row.
346///
347/// `{"$data": "/{as}/field"}` becomes `ActionHandler::Literal(row.field)`.
348/// `{"$data": "/global_field"}` is left untouched — `resolve_actions` will
349/// resolve it against `spec.data` later.
350fn inline_resolve_row_action(
351    action: &mut crate::action::Action,
352    as_name: &str,
353    row: &serde_json::Value,
354) {
355    use crate::action::ActionHandler;
356    let prefix = format!("/{as_name}/");
357    if let ActionHandler::Binding(d) = &action.handler {
358        if let Some(rest) = d.data.strip_prefix(&prefix) {
359            let resolved = crate::data::resolve_path(row, &format!("/{rest}"))
360                .and_then(|v| v.as_str())
361                .map(|s| s.to_string());
362            if let Some(s) = resolved {
363                action.handler = ActionHandler::Literal(s);
364            }
365            // Missing field: leave binding; resolve_actions will degrade to None.
366        } else if d.data == format!("/{as_name}") {
367            // `{"$data": "/cell"}` — whole row as string when row is a string.
368            if let Some(s) = row.as_str() {
369                action.handler = ActionHandler::Literal(s.to_string());
370            }
371        }
372        // Non-row paths stay as Binding; resolve_actions handles them later.
373    }
374}
375
376fn inline_walk(
377    value: &mut serde_json::Value,
378    prefix: &str,
379    row: &serde_json::Value,
380    as_name: &str,
381) {
382    match value {
383        serde_json::Value::Object(map) => {
384            if map.len() == 1 {
385                // Single-key {"$data": "/{as}/..."} → row-scoped literal.
386                if let Some(serde_json::Value::String(path)) = map.get("$data") {
387                    if let Some(rest) = path.strip_prefix(prefix) {
388                        let resolved = crate::data::resolve_path(row, &format!("/{rest}"))
389                            .cloned()
390                            .unwrap_or(serde_json::Value::Null);
391                        *value = resolved;
392                        return;
393                    } else if path == &format!("/{as_name}") {
394                        // `{"$data": "/order"}` — bind the whole row.
395                        *value = row.clone();
396                        return;
397                    }
398                }
399                // Single-key {"$template": "... {/{as}/field} ..."} → interpolate
400                // row-scoped placeholders inline; leave non-row markers for
401                // downstream resolve_expressions.
402                if let Some(serde_json::Value::String(tpl)) = map.get("$template") {
403                    let interpolated = interpolate_row_template(tpl, prefix, row, as_name);
404                    if !contains_template_marker(&interpolated) {
405                        *value = serde_json::Value::String(interpolated);
406                        return;
407                    } else {
408                        map.insert(
409                            "$template".to_string(),
410                            serde_json::Value::String(interpolated),
411                        );
412                        return;
413                    }
414                }
415            }
416            for v in map.values_mut() {
417                inline_walk(v, prefix, row, as_name);
418            }
419        }
420        serde_json::Value::Array(arr) => {
421            for v in arr.iter_mut() {
422                inline_walk(v, prefix, row, as_name);
423            }
424        }
425        _ => {}
426    }
427}
428
429fn interpolate_row_template(
430    tpl: &str,
431    prefix: &str,
432    row: &serde_json::Value,
433    as_name: &str,
434) -> String {
435    let mut out = String::with_capacity(tpl.len());
436    let mut chars = tpl.chars().peekable();
437    while let Some(c) = chars.next() {
438        if c == '{' {
439            let mut path = String::new();
440            let mut closed = false;
441            for nc in chars.by_ref() {
442                if nc == '}' {
443                    closed = true;
444                    break;
445                }
446                path.push(nc);
447            }
448            if closed {
449                if let Some(rest) = path.strip_prefix(prefix) {
450                    let resolved = crate::data::resolve_path(row, &format!("/{rest}"))
451                        .map(value_to_string)
452                        .unwrap_or_default();
453                    out.push_str(&resolved);
454                } else if path == format!("/{as_name}") {
455                    out.push_str(&value_to_string(row));
456                } else {
457                    // Non-row template marker — leave intact for the
458                    // downstream resolve_expressions pass.
459                    out.push('{');
460                    out.push_str(&path);
461                    out.push('}');
462                }
463            } else {
464                out.push('{');
465                out.push_str(&path);
466            }
467        } else {
468            out.push(c);
469        }
470    }
471    out
472}
473
474fn contains_template_marker(s: &str) -> bool {
475    // Heuristic: any `{/...}` substring left in the string indicates a
476    // downstream-resolvable placeholder.
477    let mut chars = s.chars().peekable();
478    while let Some(c) = chars.next() {
479        if c == '{' && matches!(chars.peek(), Some('/')) {
480            return true;
481        }
482    }
483    false
484}
485
486fn value_to_string(v: &serde_json::Value) -> String {
487    match v {
488        serde_json::Value::String(s) => s.clone(),
489        serde_json::Value::Null => String::new(),
490        other => other.to_string(),
491    }
492}
493
494/// Rewrite every element's `children` list so:
495/// - IDs of `$if`-removed elements are pruned.
496/// - IDs of `$each`-templated elements are replaced by their ordered clone
497///   IDs (or dropped if the array was empty).
498/// - Other IDs pass through unchanged.
499///
500/// Skips children that were ALREADY rewritten to correlated-child suffixes
501/// by `expand_each` (those reference clones that exist in the map and are
502/// NOT keys in `each_expanded`).
503fn rewrite_parent_children(
504    spec: &mut Spec,
505    if_removed: &std::collections::HashSet<String>,
506    each_expanded: &std::collections::HashMap<String, Vec<String>>,
507) {
508    for el in spec.elements.values_mut() {
509        let mut new_children: Vec<String> = Vec::with_capacity(el.children.len());
510        for child in el.children.drain(..) {
511            if if_removed.contains(&child) {
512                continue; // pruned
513            }
514            if let Some(clones) = each_expanded.get(&child) {
515                new_children.extend(clones.iter().cloned());
516            } else {
517                new_children.push(child);
518            }
519        }
520        el.children = new_children;
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use crate::action::{Action, HttpMethod};
528    use crate::spec::{Element, Spec};
529
530    fn action(handler: &str) -> Action {
531        Action {
532            handler: ActionHandler::Literal(handler.to_string()),
533            url: None,
534            method: HttpMethod::Post,
535            confirm: None,
536            on_success: None,
537            on_error: None,
538            target: None,
539        }
540    }
541
542    #[test]
543    fn resolve_actions_populates_url_from_resolver() {
544        let mut spec = Spec::builder()
545            .element("btn", Element::new("Button").action(action("users.create")))
546            .build()
547            .unwrap();
548
549        resolve_actions(&mut spec, |h| {
550            if h == "users.create" {
551                Some("/users".to_string())
552            } else {
553                None
554            }
555        });
556
557        let el = spec.elements.get("btn").unwrap();
558        assert_eq!(el.action.as_ref().unwrap().url.as_deref(), Some("/users"));
559    }
560
561    // ── F14: ActionHandler binding resolution ───────────────────────────
562
563    fn action_with_binding(path: &str) -> Action {
564        Action {
565            handler: ActionHandler::Binding(crate::spec::DataRef {
566                data: path.to_string(),
567            }),
568            url: None,
569            method: HttpMethod::Get,
570            confirm: None,
571            on_success: None,
572            on_error: None,
573            target: None,
574        }
575    }
576
577    #[test]
578    fn resolve_actions_resolves_binding_to_literal_path_via_spec_data() {
579        let mut spec = Spec::builder()
580            .data(serde_json::json!({ "row": { "url": "/dashboard/orders/42" } }))
581            .element(
582                "btn",
583                Element::new("Button").action(action_with_binding("/row/url")),
584            )
585            .build()
586            .unwrap();
587
588        resolve_actions(&mut spec, |_| None);
589
590        let el = spec.elements.get("btn").unwrap();
591        assert_eq!(
592            el.action.as_ref().unwrap().url.as_deref(),
593            Some("/dashboard/orders/42"),
594            "binding pointing to a `/path` string in spec.data resolves to action.url"
595        );
596    }
597
598    #[test]
599    fn resolve_actions_resolves_binding_to_named_handler_via_resolver() {
600        let mut spec = Spec::builder()
601            .data(serde_json::json!({ "row": { "handler": "users.show" } }))
602            .element(
603                "btn",
604                Element::new("Button").action(action_with_binding("/row/handler")),
605            )
606            .build()
607            .unwrap();
608
609        resolve_actions(&mut spec, |name| {
610            (name == "users.show").then(|| "/users/show".to_string())
611        });
612
613        let el = spec.elements.get("btn").unwrap();
614        assert_eq!(
615            el.action.as_ref().unwrap().url.as_deref(),
616            Some("/users/show"),
617            "binding resolved to handler name flows through the resolver"
618        );
619    }
620
621    #[test]
622    fn resolve_actions_binding_missing_data_leaves_url_unset() {
623        let mut spec = Spec::builder()
624            .data(serde_json::json!({}))
625            .element(
626                "btn",
627                Element::new("Button").action(action_with_binding("/missing")),
628            )
629            .build()
630            .unwrap();
631
632        resolve_actions(&mut spec, |_| Some("UNEXPECTED".to_string()));
633
634        let el = spec.elements.get("btn").unwrap();
635        assert!(
636            el.action.as_ref().unwrap().url.is_none(),
637            "missing binding data leaves url unset (renderer emits diagnostic)"
638        );
639    }
640
641    #[test]
642    fn resolve_actions_skips_when_url_already_resolved() {
643        let mut spec = Spec::builder()
644            .data(serde_json::json!({ "row": { "url": "/from-data" } }))
645            .element("btn", {
646                let mut a = action_with_binding("/row/url");
647                a.url = Some("/preset".to_string());
648                Element::new("Button").action(a)
649            })
650            .build()
651            .unwrap();
652
653        resolve_actions(&mut spec, |_| None);
654
655        let el = spec.elements.get("btn").unwrap();
656        assert_eq!(el.action.as_ref().unwrap().url.as_deref(), Some("/preset"));
657    }
658
659    #[test]
660    fn each_inlines_row_action_url_to_literal() {
661        // $each over /cells; each row has action_url; per-row CalendarCell-style
662        // action.handler binding points at /{as}/action_url. expand_each must
663        // pre-resolve it to ActionHandler::Literal(row.action_url) so
664        // resolve_actions can then set action.url.
665        let mut spec = parse_spec(serde_json::json!({
666            "$schema": "ferro-json-ui/v2",
667            "root": "grid",
668            "elements": {
669                "grid": {
670                    "type": "Grid",
671                    "props": {},
672                    "children": ["cell"]
673                },
674                "cell": {
675                    "type": "CalendarCell",
676                    "$each": {"path": "/cells", "as": "c"},
677                    "props": {"day": {"$data": "/c/day"}},
678                    "action": {
679                        "handler": {"$data": "/c/action_url"},
680                        "method": "GET"
681                    }
682                }
683            },
684            "data": {
685                "cells": [
686                    {"day": 17, "action_url": "/dashboard/calendario?date=2026-05-17"},
687                    {"day": 18, "action_url": "/dashboard/calendario?date=2026-05-18"}
688                ]
689            }
690        }));
691
692        expand_directives(&mut spec);
693        resolve_actions(&mut spec, |_| None);
694
695        let cell0 = spec.elements.get("cell-0").expect("expanded cell-0");
696        let cell1 = spec.elements.get("cell-1").expect("expanded cell-1");
697        assert_eq!(
698            cell0.action.as_ref().unwrap().url.as_deref(),
699            Some("/dashboard/calendario?date=2026-05-17"),
700            "$each inlines /c/action_url to a literal; resolve_actions promotes literal to url"
701        );
702        assert_eq!(
703            cell1.action.as_ref().unwrap().url.as_deref(),
704            Some("/dashboard/calendario?date=2026-05-18")
705        );
706    }
707
708    #[test]
709    fn each_leaves_non_row_action_bindings_for_resolve_actions() {
710        // Binding to /global_field (not /{as}/...) survives $each unchanged,
711        // then resolve_actions resolves it against spec.data.
712        let mut spec = parse_spec(serde_json::json!({
713            "$schema": "ferro-json-ui/v2",
714            "root": "grid",
715            "elements": {
716                "grid": {
717                    "type": "Grid",
718                    "props": {},
719                    "children": ["item"]
720                },
721                "item": {
722                    "type": "Button",
723                    "$each": {"path": "/items", "as": "i"},
724                    "props": {"label": {"$data": "/i/label"}},
725                    "action": {
726                        "handler": {"$data": "/global_link"},
727                        "method": "GET"
728                    }
729                }
730            },
731            "data": {
732                "items": [{"label": "a"}, {"label": "b"}],
733                "global_link": "/dashboard/shared"
734            }
735        }));
736
737        expand_directives(&mut spec);
738        resolve_actions(&mut spec, |_| None);
739
740        let i0 = spec.elements.get("item-0").unwrap();
741        let i1 = spec.elements.get("item-1").unwrap();
742        assert_eq!(
743            i0.action.as_ref().unwrap().url.as_deref(),
744            Some("/dashboard/shared")
745        );
746        assert_eq!(
747            i1.action.as_ref().unwrap().url.as_deref(),
748            Some("/dashboard/shared")
749        );
750    }
751
752    #[test]
753    fn resolve_actions_passes_through_literal_paths() {
754        let mut spec = Spec::builder()
755            .element("btn", Element::new("Button").action(action("/dashboard")))
756            .build()
757            .unwrap();
758
759        resolve_actions(&mut spec, |_| None);
760
761        let el = spec.elements.get("btn").unwrap();
762        assert_eq!(
763            el.action.as_ref().unwrap().url.as_deref(),
764            Some("/dashboard")
765        );
766    }
767
768    #[test]
769    fn resolve_actions_strict_reports_missing() {
770        let mut spec = Spec::builder()
771            .element(
772                "btn",
773                Element::new("Button").action(action("missing.handler")),
774            )
775            .build()
776            .unwrap();
777
778        let result = resolve_actions_strict(&mut spec, |_| None);
779        assert!(result.is_err());
780        assert_eq!(result.unwrap_err(), vec!["missing.handler".to_string()]);
781    }
782
783    #[test]
784    fn resolve_errors_matches_by_name_prop() {
785        let mut spec = Spec::builder()
786            .element("email", Element::new("Input").prop("name", "email"))
787            .build()
788            .unwrap();
789
790        let mut errors: HashMap<String, Vec<String>> = HashMap::new();
791        errors.insert("email".to_string(), vec!["required".to_string()]);
792
793        resolve_errors(&mut spec, &errors);
794
795        let el = spec.elements.get("email").unwrap();
796        let err_val = el.props.as_object().unwrap().get("error").unwrap();
797        assert_eq!(err_val, &serde_json::json!("required"));
798    }
799
800    #[test]
801    fn resolve_errors_matches_by_field_prop() {
802        let mut spec = Spec::builder()
803            .element("email", Element::new("Input").prop("field", "email"))
804            .build()
805            .unwrap();
806
807        let mut errors: HashMap<String, Vec<String>> = HashMap::new();
808        errors.insert("email".to_string(), vec!["required".to_string()]);
809
810        resolve_errors(&mut spec, &errors);
811
812        let el = spec.elements.get("email").unwrap();
813        let err_val = el.props.as_object().unwrap().get("error").unwrap();
814        assert_eq!(err_val, &serde_json::json!("required"));
815    }
816
817    #[test]
818    fn resolve_errors_all_writes_full_bag_when_no_match() {
819        let mut spec = Spec::builder()
820            .element("card", Element::new("Card").prop("title", "t"))
821            .build()
822            .unwrap();
823
824        let mut errors: HashMap<String, Vec<String>> = HashMap::new();
825        errors.insert("email".to_string(), vec!["required".to_string()]);
826
827        resolve_errors_all(&mut spec, &errors);
828
829        let el = spec.elements.get("card").unwrap();
830        let err_val = el.props.as_object().unwrap().get("errors").unwrap();
831        assert_eq!(err_val["email"], serde_json::json!(["required"]));
832    }
833
834    // -----------------------------------------------------------------------
835    // expand_directives tests (Phase 163 Plan 03) — $each / $if expansion
836    // -----------------------------------------------------------------------
837
838    fn parse_spec(json: serde_json::Value) -> Spec {
839        serde_json::from_value::<Spec>(json).expect("spec parses")
840    }
841
842    #[test]
843    fn expand_if_falsy_deletes_element() {
844        let mut spec = parse_spec(serde_json::json!({
845            "$schema": "ferro-json-ui/v2",
846            "root": "btn",
847            "elements": {
848                "btn": {
849                    "type": "Button",
850                    "$if": {"path": "/show", "operator": "eq", "value": true},
851                    "props": {"label": "Hi"}
852                }
853            },
854            "data": {"show": false}
855        }));
856        expand_directives(&mut spec);
857        assert!(!spec.elements.contains_key("btn"));
858    }
859
860    #[test]
861    fn expand_if_truthy_retains_element() {
862        let mut spec = parse_spec(serde_json::json!({
863            "$schema": "ferro-json-ui/v2",
864            "root": "btn",
865            "elements": {
866                "btn": {
867                    "type": "Button",
868                    "$if": {"path": "/show", "operator": "eq", "value": true},
869                    "props": {"label": "Hi"}
870                }
871            },
872            "data": {"show": true}
873        }));
874        expand_directives(&mut spec);
875        let el = spec.elements.get("btn").expect("btn retained");
876        assert!(
877            el.if_.is_none(),
878            "if_ stripped post-expansion for idempotency"
879        );
880    }
881
882    #[test]
883    fn expand_if_uses_visibility_evaluate() {
884        // Compound And — exercises Visibility::And evaluation path verbatim.
885        let mut spec = parse_spec(serde_json::json!({
886            "$schema": "ferro-json-ui/v2",
887            "root": "btn",
888            "elements": {
889                "btn": {
890                    "type": "Button",
891                    "$if": {"and": [
892                        {"path": "/a", "operator": "eq", "value": true},
893                        {"path": "/b", "operator": "eq", "value": true}
894                    ]},
895                    "props": {"label": "Hi"}
896                }
897            },
898            "data": {"a": true, "b": false}
899        }));
900        expand_directives(&mut spec);
901        // And of (true, false) is false → element removed.
902        assert!(!spec.elements.contains_key("btn"));
903    }
904
905    #[test]
906    fn expand_each_produces_n_elements() {
907        let mut spec = parse_spec(serde_json::json!({
908            "$schema": "ferro-json-ui/v2",
909            "root": "order_card",
910            "elements": {
911                "order_card": {
912                    "type": "Card",
913                    "$each": {"path": "/orders", "as": "order"},
914                    "props": {"title": {"$data": "/order/order_number"}}
915                }
916            },
917            "data": {"orders": [
918                {"order_number": "ORD-1"},
919                {"order_number": "ORD-2"},
920                {"order_number": "ORD-3"}
921            ]}
922        }));
923        expand_directives(&mut spec);
924        assert!(spec.elements.contains_key("order_card-0"));
925        assert!(spec.elements.contains_key("order_card-1"));
926        assert!(spec.elements.contains_key("order_card-2"));
927        assert!(!spec.elements.contains_key("order_card"));
928        let c0 = spec.elements.get("order_card-0").unwrap();
929        assert_eq!(c0.props.get("title").unwrap(), &serde_json::json!("ORD-1"));
930    }
931
932    #[test]
933    fn expand_each_auto_suffixes_ids() {
934        let mut spec = parse_spec(serde_json::json!({
935            "$schema": "ferro-json-ui/v2",
936            "root": "order_card",
937            "elements": {
938                "order_card": {
939                    "type": "Card",
940                    "$each": {"path": "/orders", "as": "order"},
941                    "props": {}
942                }
943            },
944            "data": {"orders": [{"x":1},{"x":2}]}
945        }));
946        expand_directives(&mut spec);
947        for id in ["order_card-0", "order_card-1"] {
948            let el = spec.elements.get(id).unwrap();
949            assert!(el.each.is_none(), "{id} should have each stripped");
950            assert!(el.if_.is_none(), "{id} should have if_ stripped");
951        }
952    }
953
954    #[test]
955    fn expand_each_pre_resolves_row_paths() {
956        let mut spec = parse_spec(serde_json::json!({
957            "$schema": "ferro-json-ui/v2",
958            "root": "order_card",
959            "elements": {
960                "order_card": {
961                    "type": "Card",
962                    "$each": {"path": "/orders", "as": "order"},
963                    "props": {"title": {"$data": "/order/order_number"}}
964                }
965            },
966            "data": {"orders": [{"order_number": "ORD-7"}]}
967        }));
968        expand_directives(&mut spec);
969        let c0 = spec.elements.get("order_card-0").unwrap();
970        assert_eq!(
971            c0.props.get("title").unwrap(),
972            &serde_json::json!("ORD-7"),
973            "/order/X must be pre-resolved to a literal value"
974        );
975    }
976
977    #[test]
978    fn expand_each_correlates_child_indexes() {
979        let mut spec = parse_spec(serde_json::json!({
980            "$schema": "ferro-json-ui/v2",
981            "root": "root",
982            "elements": {
983                "root": {
984                    "type": "Grid",
985                    "props": {},
986                    "children": ["card"]
987                },
988                "card": {
989                    "type": "Card",
990                    "$each": {"path": "/orders", "as": "order"},
991                    "props": {},
992                    "children": ["badge"]
993                },
994                "badge": {
995                    "type": "Badge",
996                    "$each": {"path": "/orders", "as": "order"},
997                    "props": {"label": {"$data": "/order/status"}}
998                }
999            },
1000            "data": {"orders": [{"status": "A"}, {"status": "B"}]}
1001        }));
1002        expand_directives(&mut spec);
1003        let card0 = spec.elements.get("card-0").unwrap();
1004        assert_eq!(card0.children, vec!["badge-0".to_string()]);
1005        let card1 = spec.elements.get("card-1").unwrap();
1006        assert_eq!(card1.children, vec!["badge-1".to_string()]);
1007        let root = spec.elements.get("root").unwrap();
1008        assert_eq!(
1009            root.children,
1010            vec!["card-0".to_string(), "card-1".to_string()]
1011        );
1012    }
1013
1014    #[test]
1015    fn expand_parent_children_rewritten_for_each() {
1016        let mut spec = parse_spec(serde_json::json!({
1017            "$schema": "ferro-json-ui/v2",
1018            "root": "root",
1019            "elements": {
1020                "root": {
1021                    "type": "Grid",
1022                    "props": {},
1023                    "children": ["card"]
1024                },
1025                "card": {
1026                    "type": "Card",
1027                    "$each": {"path": "/orders", "as": "order"},
1028                    "props": {}
1029                }
1030            },
1031            "data": {"orders": [{"x":1},{"x":2},{"x":3}]}
1032        }));
1033        expand_directives(&mut spec);
1034        let root = spec.elements.get("root").unwrap();
1035        assert_eq!(
1036            root.children,
1037            vec![
1038                "card-0".to_string(),
1039                "card-1".to_string(),
1040                "card-2".to_string()
1041            ]
1042        );
1043    }
1044
1045    #[test]
1046    fn expand_parent_children_pruned_for_if() {
1047        let mut spec = parse_spec(serde_json::json!({
1048            "$schema": "ferro-json-ui/v2",
1049            "root": "root",
1050            "elements": {
1051                "root": {
1052                    "type": "Grid",
1053                    "props": {},
1054                    "children": ["btn"]
1055                },
1056                "btn": {
1057                    "type": "Button",
1058                    "$if": {"path": "/flag", "operator": "eq", "value": true},
1059                    "props": {"label": "Hi"}
1060                }
1061            },
1062            "data": {"flag": false}
1063        }));
1064        expand_directives(&mut spec);
1065        let root = spec.elements.get("root").unwrap();
1066        assert!(root.children.is_empty(), "pruned $if-false child");
1067        assert!(!spec.elements.contains_key("btn"));
1068    }
1069
1070    #[test]
1071    fn expand_if_first_then_each() {
1072        // Element has BOTH $if (falsy) AND $each. $if removes the template before $each runs.
1073        let mut spec = parse_spec(serde_json::json!({
1074            "$schema": "ferro-json-ui/v2",
1075            "root": "card",
1076            "elements": {
1077                "card": {
1078                    "type": "Card",
1079                    "$if": {"path": "/show", "operator": "eq", "value": true},
1080                    "$each": {"path": "/orders", "as": "order"},
1081                    "props": {}
1082                }
1083            },
1084            "data": {"show": false, "orders": [{"x":1},{"x":2}]}
1085        }));
1086        expand_directives(&mut spec);
1087        for id in ["card", "card-0", "card-1"] {
1088            assert!(
1089                !spec.elements.contains_key(id),
1090                "{id} must not exist when $if removed the template"
1091            );
1092        }
1093    }
1094
1095    #[test]
1096    fn expand_each_empty_array_produces_zero_clones() {
1097        let mut spec = parse_spec(serde_json::json!({
1098            "$schema": "ferro-json-ui/v2",
1099            "root": "root",
1100            "elements": {
1101                "root": {
1102                    "type": "Grid",
1103                    "props": {},
1104                    "children": ["card"]
1105                },
1106                "card": {
1107                    "type": "Card",
1108                    "$each": {"path": "/orders", "as": "order"},
1109                    "props": {}
1110                }
1111            },
1112            "data": {"orders": []}
1113        }));
1114        expand_directives(&mut spec);
1115        assert!(!spec.elements.contains_key("card"));
1116        let root = spec.elements.get("root").unwrap();
1117        assert!(root.children.is_empty());
1118    }
1119
1120    #[test]
1121    fn expand_directives_idempotent() {
1122        let mut spec = parse_spec(serde_json::json!({
1123            "$schema": "ferro-json-ui/v2",
1124            "root": "root",
1125            "elements": {
1126                "root": {
1127                    "type": "Grid",
1128                    "props": {},
1129                    "children": ["card"]
1130                },
1131                "card": {
1132                    "type": "Card",
1133                    "$each": {"path": "/orders", "as": "order"},
1134                    "props": {"title": {"$data": "/order/name"}}
1135                }
1136            },
1137            "data": {"orders": [{"name": "A"}, {"name": "B"}]}
1138        }));
1139        expand_directives(&mut spec);
1140        let snapshot_after_first = serde_json::to_value(&spec.elements).unwrap();
1141        expand_directives(&mut spec);
1142        let snapshot_after_second = serde_json::to_value(&spec.elements).unwrap();
1143        assert_eq!(
1144            snapshot_after_first, snapshot_after_second,
1145            "expand_directives must be idempotent"
1146        );
1147    }
1148}