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