Skip to main content

forme/
template.rs

1//! Template expression evaluator.
2//!
3//! Takes a template JSON tree (with `$ref`, `$each`, `$if`, operators) and a
4//! data JSON object, resolves all expressions, and produces a plain document
5//! JSON tree that can be deserialized into a `Document` and rendered.
6//!
7//! This enables a hosted API workflow: store template JSON + dynamic data →
8//! produce PDFs without a JavaScript runtime.
9
10use serde_json::{Map, Value};
11use std::collections::HashMap;
12
13use crate::FormeError;
14
15/// Evaluation context that holds data and scoped bindings (from `$each`).
16pub struct EvalContext {
17    data: Value,
18    scope: HashMap<String, Value>,
19}
20
21impl EvalContext {
22    pub fn new(data: Value) -> Self {
23        EvalContext {
24            data,
25            scope: HashMap::new(),
26        }
27    }
28
29    /// Resolve a dot-separated path against scope first, then root data.
30    fn resolve_ref(&self, path: &str) -> Option<&Value> {
31        let parts: Vec<&str> = path.split('.').collect();
32        if parts.is_empty() {
33            return None;
34        }
35
36        // Check scope first (for $each bindings like "$item")
37        if let Some(scoped) = self.scope.get(parts[0]) {
38            return traverse(scoped, &parts[1..]);
39        }
40
41        // Fall back to root data
42        traverse(&self.data, &parts)
43    }
44
45    /// Create a child context with an additional binding.
46    fn with_binding(&self, key: &str, value: Value) -> EvalContext {
47        let mut scope = self.scope.clone();
48        scope.insert(key.to_string(), value);
49        EvalContext {
50            data: self.data.clone(),
51            scope,
52        }
53    }
54}
55
56/// Traverse a JSON value by dot-path segments.
57fn traverse<'a>(value: &'a Value, parts: &[&str]) -> Option<&'a Value> {
58    let mut current = value;
59    for part in parts {
60        match current {
61            Value::Object(map) => {
62                current = map.get(*part)?;
63            }
64            Value::Array(arr) => {
65                let idx: usize = part.parse().ok()?;
66                current = arr.get(idx)?;
67            }
68            _ => return None,
69        }
70    }
71    Some(current)
72}
73
74/// Evaluate a template JSON tree with data, producing a resolved document.
75pub fn evaluate_template(template: &Value, data: &Value) -> Result<Value, FormeError> {
76    let ctx = EvalContext::new(data.clone());
77    evaluate_node(template, &ctx).ok_or_else(|| {
78        FormeError::TemplateError("Template evaluation produced no output".to_string())
79    })
80}
81
82/// Evaluate a single node in the template tree.
83fn evaluate_node(node: &Value, ctx: &EvalContext) -> Option<Value> {
84    match node {
85        Value::Object(map) => {
86            // Check for expression nodes first
87            if let Some(result) = evaluate_expr_object(map, ctx) {
88                return result;
89            }
90
91            // Regular object — evaluate all values recursively
92            let mut result = Map::new();
93            for (key, val) in map {
94                if let Some(evaluated) = evaluate_node(val, ctx) {
95                    result.insert(key.clone(), evaluated);
96                }
97            }
98            Some(Value::Object(result))
99        }
100        Value::Array(arr) => {
101            let mut result = Vec::new();
102            for item in arr {
103                if let Some(evaluated) = evaluate_node(item, ctx) {
104                    // $each results get flattened
105                    if is_flatten_marker(&evaluated) {
106                        if let Value::Array(inner) =
107                            evaluated.get("__flatten").unwrap_or(&Value::Null)
108                        {
109                            result.extend(inner.clone());
110                        }
111                    } else {
112                        result.push(evaluated);
113                    }
114                }
115            }
116            Some(Value::Array(result))
117        }
118        // Primitives pass through unchanged
119        _ => Some(node.clone()),
120    }
121}
122
123fn is_flatten_marker(v: &Value) -> bool {
124    matches!(v, Value::Object(map) if map.contains_key("__flatten"))
125}
126
127/// Try to evaluate an object as an expression node.
128/// Returns `Some(Some(value))` if it was an expression that produced a value,
129/// `Some(None)` if it was an expression that produced nothing (e.g. false $if),
130/// `None` if this is not an expression object.
131fn evaluate_expr_object(map: &Map<String, Value>, ctx: &EvalContext) -> Option<Option<Value>> {
132    // $ref — data lookup
133    if let Some(path) = map.get("$ref") {
134        if let Value::String(path_str) = path {
135            return Some(ctx.resolve_ref(path_str).cloned());
136        }
137        return Some(None);
138    }
139
140    // $each — array iteration
141    if let Some(source) = map.get("$each") {
142        return Some(evaluate_each(source, map, ctx));
143    }
144
145    // $if — conditional rendering
146    if let Some(condition) = map.get("$if") {
147        return Some(evaluate_if(condition, map, ctx));
148    }
149
150    // $cond — ternary value [condition, if_true, if_false]
151    if let Some(args) = map.get("$cond") {
152        return Some(evaluate_cond(args, ctx));
153    }
154
155    // Comparison operators
156    if let Some(args) = map.get("$eq") {
157        return Some(evaluate_comparison(args, ctx, CompareOp::Eq));
158    }
159    if let Some(args) = map.get("$ne") {
160        return Some(evaluate_comparison(args, ctx, CompareOp::Ne));
161    }
162    if let Some(args) = map.get("$gt") {
163        return Some(evaluate_comparison(args, ctx, CompareOp::Gt));
164    }
165    if let Some(args) = map.get("$lt") {
166        return Some(evaluate_comparison(args, ctx, CompareOp::Lt));
167    }
168    if let Some(args) = map.get("$gte") {
169        return Some(evaluate_comparison(args, ctx, CompareOp::Gte));
170    }
171    if let Some(args) = map.get("$lte") {
172        return Some(evaluate_comparison(args, ctx, CompareOp::Lte));
173    }
174
175    // Arithmetic operators
176    if let Some(args) = map.get("$add") {
177        return Some(evaluate_arithmetic(args, ctx, |a, b| a + b));
178    }
179    if let Some(args) = map.get("$sub") {
180        return Some(evaluate_arithmetic(args, ctx, |a, b| a - b));
181    }
182    if let Some(args) = map.get("$mul") {
183        return Some(evaluate_arithmetic(args, ctx, |a, b| a * b));
184    }
185    if let Some(args) = map.get("$div") {
186        return Some(evaluate_arithmetic(args, ctx, |a, b| {
187            if b != 0.0 {
188                a / b
189            } else {
190                0.0
191            }
192        }));
193    }
194
195    // String transforms
196    if let Some(arg) = map.get("$upper") {
197        return Some(evaluate_string_transform(arg, ctx, |s| s.to_uppercase()));
198    }
199    if let Some(arg) = map.get("$lower") {
200        return Some(evaluate_string_transform(arg, ctx, |s| s.to_lowercase()));
201    }
202
203    // $concat — string concatenation
204    if let Some(args) = map.get("$concat") {
205        return Some(evaluate_concat(args, ctx));
206    }
207
208    // $format — number formatting [value, format_string]
209    if let Some(args) = map.get("$format") {
210        return Some(evaluate_format(args, ctx));
211    }
212
213    // $count — array length
214    if let Some(arg) = map.get("$count") {
215        return Some(evaluate_count(arg, ctx));
216    }
217
218    // Not an expression object
219    None
220}
221
222// ─── Expression evaluators ──────────────────────────────────────────
223
224fn evaluate_each(source: &Value, map: &Map<String, Value>, ctx: &EvalContext) -> Option<Value> {
225    let resolved_source = evaluate_node(source, ctx)?;
226    let arr = match &resolved_source {
227        Value::Array(a) => a,
228        _ => return Some(Value::Array(vec![])),
229    };
230
231    if arr.is_empty() {
232        let mut marker = Map::new();
233        marker.insert("__flatten".to_string(), Value::Array(vec![]));
234        return Some(Value::Object(marker));
235    }
236
237    let binding_name = map.get("as").and_then(|v| v.as_str()).unwrap_or("$item");
238
239    let template = map.get("template")?;
240
241    let mut results = Vec::new();
242    for item in arr {
243        let child_ctx = ctx.with_binding(binding_name, item.clone());
244        if let Some(evaluated) = evaluate_node(template, &child_ctx) {
245            results.push(evaluated);
246        }
247    }
248
249    // Return a flatten marker so parent arrays can flatten these results
250    let mut marker = Map::new();
251    marker.insert("__flatten".to_string(), Value::Array(results));
252    Some(Value::Object(marker))
253}
254
255fn evaluate_if(condition: &Value, map: &Map<String, Value>, ctx: &EvalContext) -> Option<Value> {
256    let resolved_cond = evaluate_node(condition, ctx)?;
257    if is_truthy(&resolved_cond) {
258        map.get("then").and_then(|t| evaluate_node(t, ctx))
259    } else {
260        map.get("else").and_then(|e| evaluate_node(e, ctx))
261    }
262}
263
264fn evaluate_cond(args: &Value, ctx: &EvalContext) -> Option<Value> {
265    let arr = args.as_array()?;
266    if arr.len() != 3 {
267        return None;
268    }
269    let condition = evaluate_node(&arr[0], ctx)?;
270    if is_truthy(&condition) {
271        evaluate_node(&arr[1], ctx)
272    } else {
273        evaluate_node(&arr[2], ctx)
274    }
275}
276
277enum CompareOp {
278    Eq,
279    Ne,
280    Gt,
281    Lt,
282    Gte,
283    Lte,
284}
285
286fn evaluate_comparison(args: &Value, ctx: &EvalContext, op: CompareOp) -> Option<Value> {
287    let arr = args.as_array()?;
288    if arr.len() != 2 {
289        return None;
290    }
291    let a = evaluate_node(&arr[0], ctx)?;
292    let b = evaluate_node(&arr[1], ctx)?;
293    Some(Value::Bool(compare_values(&a, &b, &op)))
294}
295
296/// Compare two JSON values. Numbers compared as f64, otherwise equality only.
297fn compare_values(a: &Value, b: &Value, op: &CompareOp) -> bool {
298    match (as_f64(a), as_f64(b)) {
299        (Some(na), Some(nb)) => match op {
300            CompareOp::Eq => na == nb,
301            CompareOp::Ne => na != nb,
302            CompareOp::Gt => na > nb,
303            CompareOp::Lt => na < nb,
304            CompareOp::Gte => na >= nb,
305            CompareOp::Lte => na <= nb,
306        },
307        _ => match op {
308            CompareOp::Eq => a == b,
309            CompareOp::Ne => a != b,
310            // Non-numeric ordered comparisons: compare string representations
311            CompareOp::Gt | CompareOp::Lt | CompareOp::Gte | CompareOp::Lte => {
312                match (a.as_str(), b.as_str()) {
313                    (Some(sa), Some(sb)) => match op {
314                        CompareOp::Gt => sa > sb,
315                        CompareOp::Lt => sa < sb,
316                        CompareOp::Gte => sa >= sb,
317                        CompareOp::Lte => sa <= sb,
318                        _ => unreachable!(),
319                    },
320                    _ => false,
321                }
322            }
323        },
324    }
325}
326
327fn as_f64(v: &Value) -> Option<f64> {
328    match v {
329        Value::Number(n) => n.as_f64(),
330        _ => None,
331    }
332}
333
334fn evaluate_arithmetic(args: &Value, ctx: &EvalContext, op: fn(f64, f64) -> f64) -> Option<Value> {
335    let arr = args.as_array()?;
336    if arr.len() != 2 {
337        return None;
338    }
339    let a = evaluate_node(&arr[0], ctx).and_then(|v| as_f64(&v))?;
340    let b = evaluate_node(&arr[1], ctx).and_then(|v| as_f64(&v))?;
341    let result = op(a, b);
342    Some(serde_json::Number::from_f64(result).map_or(Value::Null, Value::Number))
343}
344
345fn evaluate_string_transform(
346    arg: &Value,
347    ctx: &EvalContext,
348    transform: fn(&str) -> String,
349) -> Option<Value> {
350    let resolved = evaluate_node(arg, ctx)?;
351    let s = value_to_string(&resolved)?;
352    Some(Value::String(transform(&s)))
353}
354
355fn evaluate_concat(args: &Value, ctx: &EvalContext) -> Option<Value> {
356    let arr = args.as_array()?;
357    let mut result = String::new();
358    for item in arr {
359        let resolved = evaluate_node(item, ctx)?;
360        result.push_str(&value_to_string(&resolved)?);
361    }
362    Some(Value::String(result))
363}
364
365fn evaluate_format(args: &Value, ctx: &EvalContext) -> Option<Value> {
366    let arr = args.as_array()?;
367    if arr.len() != 2 {
368        return None;
369    }
370    let value = evaluate_node(&arr[0], ctx).and_then(|v| as_f64(&v))?;
371    let format_str = evaluate_node(&arr[1], ctx)?;
372    let fmt = format_str.as_str()?;
373
374    // Parse format string like "0.00" to determine decimal places
375    let decimal_places = if let Some(dot_pos) = fmt.find('.') {
376        fmt.len() - dot_pos - 1
377    } else {
378        0
379    };
380
381    Some(Value::String(format!(
382        "{:.prec$}",
383        value,
384        prec = decimal_places
385    )))
386}
387
388fn evaluate_count(arg: &Value, ctx: &EvalContext) -> Option<Value> {
389    let resolved = evaluate_node(arg, ctx)?;
390    match &resolved {
391        Value::Array(arr) => Some(Value::Number(serde_json::Number::from(arr.len()))),
392        _ => Some(Value::Number(serde_json::Number::from(0))),
393    }
394}
395
396// ─── Helpers ────────────────────────────────────────────────────────
397
398/// Determine if a JSON value is truthy.
399fn is_truthy(v: &Value) -> bool {
400    match v {
401        Value::Null => false,
402        Value::Bool(b) => *b,
403        Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
404        Value::String(s) => !s.is_empty(),
405        Value::Array(a) => !a.is_empty(),
406        Value::Object(_) => true,
407    }
408}
409
410/// Convert a JSON value to a string for string operations.
411fn value_to_string(v: &Value) -> Option<String> {
412    match v {
413        Value::String(s) => Some(s.clone()),
414        Value::Number(n) => Some(n.to_string()),
415        Value::Bool(b) => Some(b.to_string()),
416        Value::Null => Some("".to_string()),
417        _ => None,
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use serde_json::json;
425
426    #[test]
427    fn test_resolve_ref_simple() {
428        let ctx = EvalContext::new(json!({"name": "Alice"}));
429        assert_eq!(ctx.resolve_ref("name"), Some(&json!("Alice")));
430    }
431
432    #[test]
433    fn test_resolve_ref_nested() {
434        let ctx = EvalContext::new(json!({"user": {"name": "Bob"}}));
435        assert_eq!(ctx.resolve_ref("user.name"), Some(&json!("Bob")));
436    }
437
438    #[test]
439    fn test_resolve_ref_scope_first() {
440        let ctx = EvalContext::new(json!({"name": "root"}));
441        let child = ctx.with_binding("$item", json!({"name": "scoped"}));
442        assert_eq!(child.resolve_ref("$item.name"), Some(&json!("scoped")));
443    }
444
445    #[test]
446    fn test_resolve_ref_missing() {
447        let ctx = EvalContext::new(json!({"name": "Alice"}));
448        assert_eq!(ctx.resolve_ref("missing"), None);
449    }
450
451    #[test]
452    fn test_is_truthy() {
453        assert!(!is_truthy(&json!(null)));
454        assert!(!is_truthy(&json!(false)));
455        assert!(!is_truthy(&json!(0)));
456        assert!(!is_truthy(&json!("")));
457        assert!(!is_truthy(&json!([])));
458
459        assert!(is_truthy(&json!(true)));
460        assert!(is_truthy(&json!(1)));
461        assert!(is_truthy(&json!("hello")));
462        assert!(is_truthy(&json!([1])));
463        assert!(is_truthy(&json!({"a": 1})));
464    }
465
466    #[test]
467    fn test_evaluate_ref() {
468        let template = json!({"$ref": "name"});
469        let data = json!({"name": "Alice"});
470        let result = evaluate_template(&template, &data).unwrap();
471        assert_eq!(result, json!("Alice"));
472    }
473
474    #[test]
475    fn test_passthrough() {
476        let template = json!({"type": "Text", "content": "hello"});
477        let data = json!({});
478        let result = evaluate_template(&template, &data).unwrap();
479        assert_eq!(result, json!({"type": "Text", "content": "hello"}));
480    }
481}