Skip to main content

ferro_json_ui/
expression.rs

1//! Server-side expression resolver for v2 JSON-UI Specs.
2//!
3//! Walks every `Element.props` in a spec's flat element map and substitutes
4//! two narrow expression object shapes against `Spec.data`:
5//!
6//! - `{"$data": "/slash/path"}` — replaced with the JSON value at the path,
7//!   preserving type. Missing paths resolve to `Value::Null`.
8//! - `{"$template": "literal {/path} more"}` — replaced with a `Value::String`
9//!   built by substituting every `{slash-path}` placeholder via
10//!   `resolve_path_string`. Missing placeholders interpolate to `""`.
11//!
12//! **Infallible:** malformed expressions (non-string value, sibling keys)
13//! degrade to literal JSON — no panic, no log, no `Result`.
14//!
15//! **Single-pass:** expressions inside resolved `$data` output are NOT
16//! re-resolved. This is the inner-platform-effect firewall (Phase 118 D-07).
17//!
18//! **Scope:** only `el.props` is walked. `Spec.data`, `Spec.title`,
19//! `Spec.layout`, `el.children`, `el.action`, `el.visible` are untouched.
20//!
21//! **Pipeline order:** must run after `resolve_actions` and before
22//! `Catalog::validate` (Phase 118 D-08).
23
24use serde_json::{Map, Value};
25
26use crate::data::{resolve_path, resolve_path_string};
27use crate::spec::Spec;
28
29const EXPR_DATA_KEY: &str = "$data";
30const EXPR_TEMPLATE_KEY: &str = "$template";
31
32/// Resolve every `$data` and `$template` expression in `spec.elements[*].props`.
33///
34/// Mutates in place. Returns `()` — infallible.
35pub fn resolve_expressions(spec: &mut Spec) {
36    let data = spec.data.clone();
37    for el in spec.elements.values_mut() {
38        resolve_value(&mut el.props, &data);
39    }
40}
41
42fn resolve_value(val: &mut Value, data: &Value) {
43    match val {
44        Value::Object(map) => {
45            if let Some(path) = is_data_expr(map) {
46                let path = path.to_owned();
47                *val = resolve_path(data, &path).cloned().unwrap_or(Value::Null);
48                // Single-pass: do NOT recurse into the resolved value (D-07).
49            } else if let Some(tmpl) = is_template_expr(map) {
50                let tmpl = tmpl.to_owned();
51                *val = Value::String(substitute_template(&tmpl, data));
52                // Single-pass: do NOT recurse into the resolved string.
53            } else {
54                for v in map.values_mut() {
55                    resolve_value(v, data);
56                }
57            }
58        }
59        Value::Array(arr) => {
60            for v in arr.iter_mut() {
61                resolve_value(v, data);
62            }
63        }
64        _ => {}
65    }
66}
67
68fn is_data_expr(obj: &Map<String, Value>) -> Option<&str> {
69    if obj.len() == 1 {
70        if let Some(Value::String(path)) = obj.get(EXPR_DATA_KEY) {
71            return Some(path.as_str());
72        }
73    }
74    None
75}
76
77fn is_template_expr(obj: &Map<String, Value>) -> Option<&str> {
78    if obj.len() == 1 {
79        if let Some(Value::String(tmpl)) = obj.get(EXPR_TEMPLATE_KEY) {
80            return Some(tmpl.as_str());
81        }
82    }
83    None
84}
85
86fn substitute_template(template: &str, data: &Value) -> String {
87    let mut out = String::with_capacity(template.len());
88    let mut chars = template.chars().peekable();
89
90    while let Some(ch) = chars.next() {
91        match ch {
92            '\\' => match chars.peek() {
93                Some('{') => {
94                    out.push('{');
95                    chars.next();
96                }
97                Some('}') => {
98                    out.push('}');
99                    chars.next();
100                }
101                Some('\\') => {
102                    out.push('\\');
103                    chars.next();
104                }
105                _ => out.push('\\'),
106            },
107            '{' => {
108                let mut path = String::new();
109                let mut closed = false;
110                for inner in chars.by_ref() {
111                    if inner == '}' {
112                        closed = true;
113                        break;
114                    }
115                    path.push(inner);
116                }
117                if closed {
118                    let trimmed = path.trim();
119                    let resolved = resolve_path_string(data, trimmed).unwrap_or_default();
120                    out.push_str(&resolved);
121                } else {
122                    out.push('{');
123                    out.push_str(&path);
124                }
125            }
126            _ => out.push(ch),
127        }
128    }
129    out
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::spec::{Element, Spec};
136    use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
137    use serde_json::json;
138
139    /// Build a one-element spec with `props` stored under key "x" and run
140    /// `resolve_expressions`. Returns the post-resolution value at `props.x`.
141    fn run(data: Value, props: Value) -> Value {
142        let mut spec = Spec::builder()
143            .data(data)
144            .element("root", Element::new("Text").prop("x", props))
145            .build()
146            .unwrap();
147        resolve_expressions(&mut spec);
148        spec.elements
149            .get("root")
150            .unwrap()
151            .props
152            .get("x")
153            .cloned()
154            .unwrap_or(Value::Null)
155    }
156
157    // --- $data: success cases -------------------------------------------------
158    #[test]
159    fn data_simple_path() {
160        let out = run(json!({ "name": "Alice" }), json!({ "$data": "/name" }));
161        assert_eq!(out, json!("Alice"));
162    }
163
164    #[test]
165    fn data_nested_path() {
166        let out = run(
167            json!({ "user": { "name": "Bob" } }),
168            json!({ "$data": "/user/name" }),
169        );
170        assert_eq!(out, json!("Bob"));
171    }
172
173    #[test]
174    fn data_array_index() {
175        let out = run(
176            json!({ "items": ["x", "y"] }),
177            json!({ "$data": "/items/0" }),
178        );
179        assert_eq!(out, json!("x"));
180    }
181
182    #[test]
183    fn data_preserves_number() {
184        let out = run(json!({ "count": 42 }), json!({ "$data": "/count" }));
185        assert_eq!(out, json!(42));
186        assert!(out.is_number());
187    }
188
189    #[test]
190    fn data_preserves_bool() {
191        let out = run(json!({ "active": true }), json!({ "$data": "/active" }));
192        assert_eq!(out, json!(true));
193        assert!(out.is_boolean());
194    }
195
196    #[test]
197    fn data_preserves_object() {
198        let payload = json!({ "user": { "name": "Carol", "age": 30 } });
199        let out = run(payload.clone(), json!({ "$data": "/user" }));
200        assert_eq!(out, json!({ "name": "Carol", "age": 30 }));
201        assert!(out.is_object());
202    }
203
204    #[test]
205    fn data_preserves_array() {
206        let payload = json!({ "items": [1, 2, 3] });
207        let out = run(payload, json!({ "$data": "/items" }));
208        assert_eq!(out, json!([1, 2, 3]));
209        assert!(out.is_array());
210    }
211
212    #[test]
213    fn data_missing_path() {
214        let out = run(json!({ "name": "Alice" }), json!({ "$data": "/missing" }));
215        assert_eq!(out, Value::Null);
216    }
217
218    // --- $data: passthrough cases ---------------------------------------------
219    #[test]
220    fn data_non_string_value() {
221        let input = json!({ "$data": 42 });
222        let out = run(json!({}), input.clone());
223        assert_eq!(out, input);
224    }
225
226    #[test]
227    fn data_sibling_keys() {
228        let input = json!({ "$data": "/x", "class": "y" });
229        let out = run(json!({ "x": "resolved" }), input.clone());
230        assert_eq!(out, input);
231    }
232
233    #[test]
234    fn data_null_value() {
235        let input = json!({ "$data": null });
236        let out = run(json!({}), input.clone());
237        assert_eq!(out, input);
238    }
239
240    // --- $template: success cases ---------------------------------------------
241    #[test]
242    fn template_single_placeholder() {
243        let out = run(
244            json!({ "name": "Alice" }),
245            json!({ "$template": "Hi, {/name}!" }),
246        );
247        assert_eq!(out, json!("Hi, Alice!"));
248    }
249
250    #[test]
251    fn template_multiple_placeholders() {
252        let out = run(
253            json!({ "a": "v1", "b": "v2" }),
254            json!({ "$template": "{/a} and {/b}" }),
255        );
256        assert_eq!(out, json!("v1 and v2"));
257    }
258
259    #[test]
260    fn template_no_placeholder() {
261        let out = run(json!({}), json!({ "$template": "static text" }));
262        assert_eq!(out, json!("static text"));
263    }
264
265    #[test]
266    fn template_missing_placeholder() {
267        let out = run(json!({}), json!({ "$template": "before {/missing} after" }));
268        assert_eq!(out, json!("before  after"));
269    }
270
271    #[test]
272    fn template_whitespace_trimmed() {
273        let out = run(
274            json!({ "name": "Alice" }),
275            json!({ "$template": "{ /name }" }),
276        );
277        assert_eq!(out, json!("Alice"));
278    }
279
280    // --- $template: escape cases ----------------------------------------------
281    #[test]
282    fn template_escaped_open_brace() {
283        let out = run(json!({}), json!({ "$template": "\\{not a placeholder}" }));
284        assert_eq!(out, json!("{not a placeholder}"));
285    }
286
287    #[test]
288    fn template_escaped_close_brace() {
289        let out = run(json!({}), json!({ "$template": "text\\}" }));
290        assert_eq!(out, json!("text}"));
291    }
292
293    #[test]
294    fn template_escaped_backslash() {
295        let out = run(json!({}), json!({ "$template": "a\\\\b" }));
296        assert_eq!(out, json!("a\\b"));
297    }
298
299    #[test]
300    fn template_unclosed_brace() {
301        let out = run(json!({}), json!({ "$template": "{/missing_close" }));
302        assert_eq!(out, json!("{/missing_close"));
303    }
304
305    // --- $template: passthrough cases -----------------------------------------
306    #[test]
307    fn template_non_string_value() {
308        let input = json!({ "$template": 42 });
309        let out = run(json!({}), input.clone());
310        assert_eq!(out, input);
311    }
312
313    // --- nested expressions ---------------------------------------------------
314    #[test]
315    fn nested_in_array() {
316        let out = run(
317            json!({ "x": "resolved" }),
318            json!([{ "key": "lit" }, { "$data": "/x" }]),
319        );
320        assert_eq!(out, json!([{ "key": "lit" }, "resolved"]));
321    }
322
323    #[test]
324    fn nested_in_object_values() {
325        let out = run(
326            json!({ "x": "resolved" }),
327            json!({ "inner": { "$data": "/x" } }),
328        );
329        assert_eq!(out, json!({ "inner": "resolved" }));
330    }
331
332    // --- scope restrictions ---------------------------------------------------
333    #[test]
334    fn does_not_touch_spec_data() {
335        let data = json!({ "marker": { "$data": "/should_not_resolve" }, "target": "v" });
336        let mut spec = Spec::builder()
337            .data(data.clone())
338            .element(
339                "root",
340                Element::new("Text").prop("x", json!({ "$data": "/target" })),
341            )
342            .build()
343            .unwrap();
344        resolve_expressions(&mut spec);
345        assert_eq!(
346            spec.data, data,
347            "spec.data must be untouched even when it contains $data markers"
348        );
349        assert_eq!(
350            spec.elements.get("root").unwrap().props.get("x"),
351            Some(&json!("v"))
352        );
353    }
354
355    #[test]
356    fn single_pass_no_recursion() {
357        let out = run(
358            json!({ "outer": { "$data": "/inner" }, "inner": "never" }),
359            json!({ "$data": "/outer" }),
360        );
361        assert_eq!(
362            out,
363            json!({ "$data": "/inner" }),
364            "single-pass: $data output containing a marker must NOT be re-resolved"
365        );
366    }
367
368    #[test]
369    fn does_not_touch_children() {
370        let mut spec = Spec::builder()
371            .data(json!({ "child1": "resolved" }))
372            .element(
373                "root",
374                Element::new("Text")
375                    .prop("x", json!("literal"))
376                    .child("child1"),
377            )
378            .element("child1", Element::new("Text").prop("y", json!("leaf")))
379            .build()
380            .unwrap();
381        let before = spec.elements.get("root").unwrap().children.clone();
382        resolve_expressions(&mut spec);
383        assert_eq!(spec.elements.get("root").unwrap().children, before);
384    }
385
386    #[test]
387    fn does_not_touch_visible() {
388        let visible = Visibility::Condition(VisibilityCondition {
389            path: "/flag".to_string(),
390            operator: VisibilityOperator::Exists,
391            value: None,
392        });
393        let mut spec = Spec::builder()
394            .data(json!({ "flag": true }))
395            .element(
396                "root",
397                Element::new("Text")
398                    .prop("x", json!("lit"))
399                    .visible(visible.clone()),
400            )
401            .build()
402            .unwrap();
403        resolve_expressions(&mut spec);
404        assert_eq!(spec.elements.get("root").unwrap().visible, Some(visible));
405    }
406
407    #[test]
408    fn plugin_props_walk_identically() {
409        let mut spec = Spec::builder()
410            .data(json!({ "x": "resolved" }))
411            .element(
412                "root",
413                Element::new("MyPlugin").prop("x", json!({ "$data": "/x" })),
414            )
415            .build()
416            .unwrap();
417        resolve_expressions(&mut spec);
418        assert_eq!(
419            spec.elements.get("root").unwrap().props.get("x"),
420            Some(&json!("resolved")),
421            "plugin-typed props resolve identically to built-in props (D-14)"
422        );
423    }
424}