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