Skip to main content

dataflow_rs/engine/functions/
template.rs

1//! # Template
2//!
3//! A config field whose authored JSON is a JSONLogic expression. Every
4//! parameter of every built-in function is one of these, and custom handlers
5//! declare them for their own config.
6//!
7//! A literal is JSONLogic for itself, so the static spelling an author already
8//! writes — `"data.output"`, `30000`, `{"X-Env": "prod"}` — is a valid
9//! `Template`. Those fold to a constant at compile time and are cached, so a
10//! statically-authored parameter does no per-message work. See
11//! [`Template::is_constant`].
12
13use crate::engine::error::{DataflowError, Result};
14use crate::engine::task_context::TaskContext;
15use datalogic_rs::Logic;
16use datavalue::OwnedDataValue;
17use serde::{Deserialize, Deserializer};
18use serde_json::Value;
19use std::borrow::Cow;
20use std::sync::Arc;
21
22/// Coerce an already-evaluated value to a *plain* string: a string yields its
23/// contents, anything else its compact JSON form.
24///
25/// Mirrors [`crate::engine::executor::eval_to_plain_string`] exactly, for the
26/// constant-cache path that never reaches the evaluator.
27/// `constant_and_evaluated_plain_strings_agree` pins the two together.
28pub(crate) fn plain_string_of(value: &OwnedDataValue) -> String {
29    match value {
30        OwnedDataValue::String(s) => s.clone(),
31        other => other.to_string(),
32    }
33}
34
35/// A config field whose authored JSON is a JSONLogic expression.
36///
37/// Deserializes from any JSON value and keeps it verbatim; the expression is
38/// compiled once at engine construction (see [`crate::AsyncFunctionHandler::compile_input`])
39/// and evaluated per message on the worker thread's pooled arena — unless it
40/// folded to a constant, in which case the value is computed once at
41/// construction and handed back directly.
42///
43/// # Literals and the `$` escape
44///
45/// A JSON scalar or array authored here is a literal: `"data.out"` resolves to
46/// the string `data.out`, `30000` to the number. An *object* is where care is
47/// needed, because the engine evaluates in templating mode: a single-key object
48/// whose key matches an operator name is that operator. `{"cat": ["a", "b"]}`
49/// resolves to `"ab"`, not to the object.
50///
51/// Prefix the key with [`Engine::template_key_escape`](crate::Engine::template_key_escape)
52/// (`$`) to force the literal reading: `{"$cat": ["a", "b"]}` resolves to the
53/// object `{"cat": ["a", "b"]}`. One prefix is stripped from every template key,
54/// so a genuinely `$`-prefixed key doubles up — `{"$$oid": …}` emits `$oid`.
55///
56/// Before that escape existed a literal object with a colliding key was
57/// inexpressible, which is why this type used to be documented as opt-in per
58/// field. It no longer is: any config field may be a `Template`.
59#[derive(Debug, Clone)]
60pub struct Template {
61    raw: Value,
62    /// Everything [`Self::compile`] produces, behind one pointer.
63    ///
64    /// Boxed to keep `Template` small. Every parameter of every built-in is one
65    /// of these — `HttpCallConfig` alone holds eight — and they live inside
66    /// `FunctionConfig`, whose size is the size of its largest variant. Inline,
67    /// the compiled state made that enum large enough for
68    /// `clippy::large_enum_variant`. The indirection costs one deref on a path
69    /// that is either cached or about to run a JSONLogic evaluation anyway.
70    compiled: Option<Box<Compiled>>,
71}
72
73/// What compiling a [`Template`] produces.
74#[derive(Debug, Clone)]
75struct Compiled {
76    logic: Arc<Logic>,
77    /// `Some` when the expression folded to a compile-time constant — the value
78    /// every `resolve_*` returns without touching the evaluator.
79    constant: Option<OwnedDataValue>,
80}
81
82// Hand-written rather than `#[serde(from = "Value")]` plus `impl From<Value>`:
83// a container-level `from` builds the target solely through `From`, so a
84// field-level `#[serde(skip)]` on `compiled` would be inert and misleading next
85// to a manual `From` impl anyway. This is the same five lines, explicit about
86// which path runs.
87impl<'de> Deserialize<'de> for Template {
88    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
89        Ok(Self {
90            raw: Value::deserialize(d)?,
91            compiled: None,
92        })
93    }
94}
95
96impl Default for Template {
97    /// A `Template` over JSON `null`.
98    ///
99    /// Exists so config structs that derive `Default` still can. It is a
100    /// placeholder, not a usable parameter — every config carrying one names
101    /// the field as required, so a `Default`-built config is a base to fill in.
102    fn default() -> Self {
103        Self::from(Value::Null)
104    }
105}
106
107impl From<Value> for Template {
108    /// An uncompiled `Template` over `raw`. For hosts and tests building a
109    /// config struct directly rather than deserializing one; `LogicCompiler`
110    /// still has to compile it before any `resolve_*` call will succeed.
111    fn from(raw: Value) -> Self {
112        Self {
113            raw,
114            compiled: None,
115        }
116    }
117}
118
119impl Template {
120    /// Compile the expression. Called once at engine construction via
121    /// [`crate::AsyncFunctionHandler::compile_input`] or its receiver-taking
122    /// twin [`crate::AsyncFunctionHandler::compile_input_with`]. `label` is used only in
123    /// the error message, matching `LogicCompiler`'s
124    /// `"<what> for task <id> in workflow <id>"` convention.
125    ///
126    /// # Errors
127    ///
128    /// [`DataflowError::LogicEvaluation`] if the expression fails to compile,
129    /// with `label` prefixed onto the message.
130    pub fn compile(&mut self, c: &TemplateCompiler, label: &str) -> Result<()> {
131        let compiled = c
132            .engine
133            .compile_arc(&self.raw)
134            .map_err(|e| DataflowError::LogicEvaluation(format!("{label}: {e}")))?;
135
136        // The datalogic compiler folds every static sub-expression it can
137        // prove, so an expression with no data dependency — which is what a
138        // statically-authored parameter is — collapses to a single literal
139        // node. Evaluate it once here and keep the result: that is what makes
140        // "every parameter is JSONLogic" cost nothing for the static spelling.
141        //
142        // `is_constant`, not `is_static`. `is_static` also reports true for a
143        // rule the compiler *tried* to fold and could not because folding
144        // errored (`{"/": [1, 0]}` divides by zero); evaluating those here
145        // would move a runtime error to build time. A constant rule, by
146        // contrast, has already been reduced to a value and cannot fail.
147        let constant = if compiled.is_constant() {
148            let empty = OwnedDataValue::Object(Vec::new());
149            Some(
150                crate::engine::executor::eval_to_owned(&c.engine, &compiled, &empty)
151                    .map_err(|e| DataflowError::LogicEvaluation(format!("{label}: {e}")))?,
152            )
153        } else {
154            None
155        };
156
157        self.compiled = Some(Box::new(Compiled {
158            logic: compiled,
159            constant,
160        }));
161        Ok(())
162    }
163
164    /// Whether the expression folded to a compile-time constant, so every
165    /// `resolve_*` call returns a cached value instead of evaluating.
166    ///
167    /// True for the static spelling of any parameter — a scalar, an array, or
168    /// an object template with no `var` in it. False once anything reads the
169    /// message. Callers that precompute a derived form (a split write path, for
170    /// instance) branch on this.
171    ///
172    /// Meaningless before [`Self::compile`]: an uncompiled `Template` reports
173    /// `false` because nothing has been folded yet, not because the expression
174    /// is dynamic.
175    pub fn is_constant(&self) -> bool {
176        self.constant().is_some()
177    }
178
179    /// The folded constant, when the expression compiled to one.
180    fn constant(&self) -> Option<&OwnedDataValue> {
181        self.compiled.as_ref().and_then(|c| c.constant.as_ref())
182    }
183
184    /// The folded constant coerced to a plain string, when the expression
185    /// folded to one.
186    ///
187    /// Lets a caller do at compile time what [`Self::resolve_string`] would
188    /// otherwise defer to the first message — which is how [`PathTemplate`]
189    /// precomputes a static write path.
190    ///
191    /// [`PathTemplate`]: crate::PathTemplate
192    pub fn constant_string(&self) -> Option<String> {
193        self.constant().map(plain_string_of)
194    }
195
196    /// The parameter's value for this message: the cached constant when the
197    /// expression folded, otherwise a fresh evaluation.
198    ///
199    /// This is the sanctioned read for a config parameter. [`Self::eval`] is
200    /// the same thing without the constant cache, kept for handlers that hold
201    /// a `Template` they compiled themselves.
202    ///
203    /// # Errors
204    ///
205    /// As [`Self::eval`].
206    pub fn resolve(&self, ctx: &TaskContext<'_>) -> Result<OwnedDataValue> {
207        if let Some(v) = self.constant() {
208            return Ok(v.clone());
209        }
210        if let Some(v) = self.uncompiled_literal() {
211            return Ok(v);
212        }
213        self.eval(ctx)
214    }
215
216    /// The authored value, for a config that never went through
217    /// `LogicCompiler` — a struct built by hand in a test, a benchmark, or a
218    /// host helper.
219    ///
220    /// Only JSON scalars qualify. A scalar is unambiguously itself in
221    /// JSONLogic, so reading it directly cannot disagree with what compilation
222    /// would have produced. An object may be an operator call and an array's
223    /// elements may each be one, so those still need the compiler and fall
224    /// through to the "never compiled" error.
225    ///
226    /// This is what keeps the pre-3.9 contract for directly-constructed
227    /// configs: before, these parameters were plain `String`/`u64` fields that
228    /// needed no compilation at all.
229    fn uncompiled_literal(&self) -> Option<OwnedDataValue> {
230        if self.compiled.is_some() {
231            return None;
232        }
233        match &self.raw {
234            Value::String(_) | Value::Number(_) | Value::Bool(_) => {
235                Some(OwnedDataValue::from(&self.raw))
236            }
237            _ => None,
238        }
239    }
240
241    /// As [`Self::resolve`], coerced to a *plain* string — a string result
242    /// yields its contents, anything else its compact JSON form. Use this
243    /// wherever the value becomes a URL path, a header value, a topic name or a
244    /// write path, where JSON quoting would be wrong.
245    ///
246    /// # Errors
247    ///
248    /// As [`Self::eval`].
249    pub fn resolve_string(&self, ctx: &TaskContext<'_>) -> Result<String> {
250        if let Some(v) = self.constant() {
251            return Ok(plain_string_of(v));
252        }
253        if let Some(v) = self.uncompiled_literal() {
254            return Ok(plain_string_of(&v));
255        }
256        self.eval_to_plain_string(ctx)
257    }
258
259    /// As [`Self::resolve_string`], against a context already resident in
260    /// `arena`, for callers inside an arena scope that hold no [`TaskContext`].
261    ///
262    /// The built-in sync executors (`map`, `parse`, `publish`) run against an
263    /// [`ArenaContext`](crate::engine::executor::ArenaContext) that earlier
264    /// tasks in the same stretch already populated. Routing them through
265    /// `TaskContext` would re-walk the whole owned context into the arena per
266    /// parameter, which is exactly the cost that context exists to avoid.
267    ///
268    /// # Errors
269    ///
270    /// As [`Self::resolve_string`].
271    pub(crate) fn resolve_string_in_arena(
272        &self,
273        p: crate::engine::functions::path_template::ParamCtx<'_>,
274    ) -> Result<String> {
275        Ok(self.resolve_str_in_arena(p)?.into_owned())
276    }
277
278    /// As [`Self::resolve_string_in_arena`], borrowing when it can.
279    ///
280    /// A constant string parameter — the static spelling of a source, a target,
281    /// a topic — is already a `String` on the compiled template, so returning
282    /// it by value allocates on every message. These resolve per task per
283    /// message on the sync path, where that allocation is precisely the cost
284    /// the constant cache exists to avoid.
285    ///
286    /// # Errors
287    ///
288    /// As [`Self::resolve_string`].
289    pub(crate) fn resolve_str_in_arena(
290        &self,
291        p: crate::engine::functions::path_template::ParamCtx<'_>,
292    ) -> Result<Cow<'_, str>> {
293        match self.constant() {
294            Some(OwnedDataValue::String(s)) => return Ok(Cow::Borrowed(s)),
295            Some(other) => return Ok(Cow::Owned(plain_string_of(other))),
296            None => {}
297        }
298        // Uncompiled literal string: borrow straight off the authored JSON.
299        if self.compiled.is_none() {
300            if let Value::String(s) = &self.raw {
301                return Ok(Cow::Borrowed(s));
302            }
303            if let Some(v) = self.uncompiled_literal() {
304                return Ok(Cow::Owned(plain_string_of(&v)));
305            }
306        }
307        let logic = self.compiled_or_err("resolve_str_in_arena")?;
308        let evaluated = p
309            .engine()
310            .evaluate(logic, *p.context(), p.arena())
311            .map_err(|e| DataflowError::LogicEvaluation(e.to_string()))?;
312        Ok(Cow::Owned(match evaluated {
313            datavalue::DataValue::String(s) => s.to_string(),
314            other => other.to_string(),
315        }))
316    }
317
318    /// The compiled logic, or the "never compiled" error naming `method`.
319    fn compiled_or_err(&self, method: &str) -> Result<&Logic> {
320        self.compiled.as_ref().map(|c| &*c.logic).ok_or_else(|| {
321            DataflowError::LogicEvaluation(format!(
322                "Template::{method} called before Template::compile — the engine did not \
323                 compile this field at construction time"
324            ))
325        })
326    }
327
328    /// As [`Self::resolve`], as a `u64` — for parameters like `timeout_ms`.
329    ///
330    /// # Errors
331    ///
332    /// As [`Self::eval`], plus [`DataflowError::Validation`] when the result is
333    /// not a number that fits a `u64`. A timeout that evaluated to `null`
334    /// because its path was missing is a configuration error worth reporting,
335    /// not something to silently default.
336    pub fn resolve_u64(&self, ctx: &TaskContext<'_>, label: &str) -> Result<u64> {
337        let value = self.resolve(ctx)?;
338        match &value {
339            OwnedDataValue::Number(n) => {
340                // Reject NaN, negatives and anything past u64 range before the
341                // `as` cast, which would otherwise saturate or produce 0.
342                let f = n.as_f64();
343                (f.is_finite() && f >= 0.0 && f <= u64::MAX as f64).then_some(f as u64)
344            }
345            _ => None,
346        }
347        .ok_or_else(|| {
348            DataflowError::Validation(format!(
349                "{label} must evaluate to a non-negative number, got {value}"
350            ))
351        })
352    }
353
354    /// Evaluate against the message context, on the worker thread's pooled bump
355    /// arena.
356    ///
357    /// # Errors
358    ///
359    /// [`DataflowError::LogicEvaluation`] if [`Self::compile`] was never called —
360    /// naming the field is the caller's job via `label`, since this type has no
361    /// field name of its own to report — or if evaluation itself fails.
362    pub fn eval(&self, ctx: &TaskContext<'_>) -> Result<OwnedDataValue> {
363        let logic = self.compiled.as_ref().map(|c| &*c.logic).ok_or_else(|| {
364            DataflowError::LogicEvaluation(
365                "Template::eval called before Template::compile — the engine did not compile \
366                 this field at construction time"
367                    .to_string(),
368            )
369        })?;
370        ctx.eval(logic)
371    }
372
373    /// As [`Self::eval`], deserialized into `T`.
374    ///
375    /// Routes through `serde_json::Value` — [`TaskContext::eval_json`] then
376    /// `serde_json::from_value` — so it costs one extra walk and rebuild past
377    /// [`Self::eval`]. Prefer `eval` when `T` is `OwnedDataValue` or when you
378    /// only need to inspect the result, not deserialize it into a caller type.
379    ///
380    /// # Errors
381    ///
382    /// As [`Self::eval`], plus a deserialization error if the evaluated JSON does
383    /// not fit `T`.
384    pub fn eval_into<T: serde::de::DeserializeOwned>(&self, ctx: &TaskContext<'_>) -> Result<T> {
385        let logic = self.compiled.as_ref().map(|c| &*c.logic).ok_or_else(|| {
386            DataflowError::LogicEvaluation(
387                "Template::eval_into called before Template::compile — the engine did not \
388                 compile this field at construction time"
389                    .to_string(),
390            )
391        })?;
392        let json = ctx.eval_json(logic)?;
393        serde_json::from_value(json).map_err(DataflowError::from_serde)
394    }
395
396    /// As [`Self::eval`], coerced to a *plain* string via
397    /// [`TaskContext::eval_to_plain_string`] — a JSON string result yields its
398    /// contents, anything else its compact JSON form. Use this when the result
399    /// is going into a URL path or a message key, where JSON quoting would be
400    /// wrong.
401    ///
402    /// # Errors
403    ///
404    /// As [`Self::eval`].
405    pub fn eval_to_plain_string(&self, ctx: &TaskContext<'_>) -> Result<String> {
406        let logic = self.compiled.as_ref().map(|c| &*c.logic).ok_or_else(|| {
407            DataflowError::LogicEvaluation(
408                "Template::eval_to_plain_string called before Template::compile — the engine \
409                 did not compile this field at construction time"
410                    .to_string(),
411            )
412        })?;
413        ctx.eval_to_plain_string(logic)
414    }
415
416    /// The authored JSON, unchanged. For handlers that need to report or
417    /// re-serialize their own config.
418    pub fn as_json(&self) -> &Value {
419        &self.raw
420    }
421
422    /// Whether [`Self::compile`] has run. Mainly for tests and for callers that
423    /// want to assert the build pass reached them.
424    pub fn is_compiled(&self) -> bool {
425        self.compiled.is_some()
426    }
427}
428
429/// Handed to [`crate::AsyncFunctionHandler::compile_input`] /
430/// [`crate::AsyncFunctionHandler::compile_input_with`] to compile a handler's
431/// `Template` fields at engine construction.
432///
433/// Wraps the same `Arc<datalogic_rs::Engine>` `LogicCompiler` uses internally,
434/// so a compiled `Template` is evaluable by the engine that will run the
435/// message. A newtype rather than a bare `Arc<datalogic_rs::Engine>` so fields
436/// can be added later without changing `compile_input`'s signature.
437pub struct TemplateCompiler {
438    engine: Arc<datalogic_rs::Engine>,
439}
440
441impl TemplateCompiler {
442    pub(crate) fn new(engine: Arc<datalogic_rs::Engine>) -> Self {
443        Self { engine }
444    }
445
446    /// The shared datalogic engine, for handlers that need to compile something
447    /// other than a `Template` field directly.
448    pub fn engine(&self) -> &datalogic_rs::Engine {
449        &self.engine
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::engine::message::Message;
457    use serde_json::json;
458
459    fn engine() -> Arc<datalogic_rs::Engine> {
460        Arc::new(crate::engine::compiler::datalogic_engine_builder().build())
461    }
462
463    fn template_from(v: Value) -> Template {
464        serde_json::from_value(v).unwrap()
465    }
466
467    #[test]
468    fn deserializes_from_every_json_shape_and_as_json_is_verbatim() {
469        for v in [
470            json!({"a": 1}),
471            json!([1, 2, 3]),
472            json!("hello"),
473            json!(42),
474            json!(true),
475            json!(null),
476            json!({}),
477        ] {
478            let t = template_from(v.clone());
479            assert_eq!(t.as_json(), &v);
480            assert!(!t.is_compiled());
481        }
482    }
483
484    #[test]
485    fn eval_before_compile_errors_without_panicking() {
486        let dl = engine();
487        let mut m = Message::from_value(&json!({}));
488        let ctx = TaskContext::new(&mut m, &dl);
489        let t = template_from(json!({"var": "data.x"}));
490
491        match t.eval(&ctx) {
492            Err(DataflowError::LogicEvaluation(msg)) => {
493                assert!(
494                    msg.contains("compile"),
495                    "message should name the cause: {msg}"
496                );
497            }
498            other => panic!("expected LogicEvaluation, got {other:?}"),
499        }
500    }
501
502    #[test]
503    fn compile_on_a_malformed_expression_names_the_label() {
504        // datalogic-rs's templating mode is deliberately permissive at compile
505        // time: an unrecognised operator key compiles as a literal (or, at the
506        // top level, a structured-object template) rather than erroring — this
507        // is existing engine behaviour, not something `Template` controls, and
508        // it is why a static "known operators" table would mislead (see #26's
509        // scope notes). The one thing that reliably fails to *compile* — as
510        // opposed to failing at *evaluation* — is rule nesting past the
511        // engine's `MAX_COMPILE_DEPTH` (256), verified directly against
512        // datalogic-rs 5.1.1 before writing this test.
513        let c = TemplateCompiler::new(engine());
514        let mut too_deep = json!(1);
515        for _ in 0..300 {
516            too_deep = json!({"var": too_deep});
517        }
518        let mut t = template_from(too_deep);
519
520        match t.compile(&c, "my_field for task t in workflow w") {
521            Err(DataflowError::LogicEvaluation(msg)) => {
522                assert!(
523                    msg.contains("my_field for task t in workflow w"),
524                    "got: {msg}"
525                );
526            }
527            other => panic!("expected LogicEvaluation, got {other:?}"),
528        }
529    }
530
531    #[test]
532    fn a_literal_template_evaluates_to_that_literal() {
533        let dl = engine();
534        let c = TemplateCompiler::new(Arc::clone(&dl));
535        let mut m = Message::from_value(&json!({}));
536        let ctx = TaskContext::new(&mut m, &dl);
537
538        for v in [
539            json!("hello"),
540            json!(42),
541            json!({}),
542            json!({"a": 1, "b": 2}),
543        ] {
544            let mut t = template_from(v.clone());
545            t.compile(&c, "lbl").unwrap();
546            assert_eq!(t.eval_into::<Value>(&ctx).unwrap(), v);
547        }
548    }
549
550    #[test]
551    fn an_operator_named_key_evaluates_unless_it_is_escaped() {
552        // The reason `Template` used to be opt-in per field, and the reason it
553        // no longer needs to be. Templating makes a single-key object an
554        // operator invocation; the `$` escape is what makes the literal
555        // reading expressible at all.
556        let dl = engine();
557        let c = TemplateCompiler::new(Arc::clone(&dl));
558        let mut m = Message::from_value(&json!({}));
559        let ctx = TaskContext::new(&mut m, &dl);
560
561        let mut op = template_from(json!({"cat": ["a", "b"]}));
562        op.compile(&c, "lbl").unwrap();
563        assert_eq!(op.eval_into::<Value>(&ctx).unwrap(), json!("ab"));
564
565        let mut escaped = template_from(json!({"$cat": ["a", "b"]}));
566        escaped.compile(&c, "lbl").unwrap();
567        assert_eq!(
568            escaped.eval_into::<Value>(&ctx).unwrap(),
569            json!({"cat": ["a", "b"]}),
570            "an escaped key must emit the literal object"
571        );
572
573        // One prefix is stripped, so a genuinely `$`-prefixed key doubles up.
574        let mut doubled = template_from(json!({"$$oid": "abc"}));
575        doubled.compile(&c, "lbl").unwrap();
576        assert_eq!(
577            doubled.eval_into::<Value>(&ctx).unwrap(),
578            json!({"$oid": "abc"})
579        );
580    }
581
582    #[test]
583    fn the_static_spelling_of_every_parameter_folds_to_a_constant() {
584        // This is what makes "every parameter is JSONLogic" free: the way an
585        // author already writes a parameter costs no per-message evaluation.
586        let c = TemplateCompiler::new(engine());
587        for v in [
588            json!("data.output"),
589            json!(30000),
590            json!(true),
591            json!(["a", "b"]),
592            json!({"cat": ["a", "b"]}), // folds: no data dependency
593        ] {
594            let mut t = template_from(v.clone());
595            t.compile(&c, "lbl").unwrap();
596            assert!(t.is_constant(), "{v} should fold to a constant");
597        }
598
599        // Anything that reads the message cannot fold.
600        for v in [
601            json!({"var": "data.x"}),
602            json!({"cat": [{"var": "data.x"}]}),
603        ] {
604            let mut t = template_from(v.clone());
605            t.compile(&c, "lbl").unwrap();
606            assert!(!t.is_constant(), "{v} must not fold");
607        }
608    }
609
610    #[test]
611    fn constant_and_evaluated_plain_strings_agree() {
612        // `resolve_string` short-circuits the evaluator for a constant, so its
613        // coercion is a second implementation of `eval_to_plain_string`. If the
614        // two ever disagree, a static parameter and its dynamic twin would put
615        // different bytes in a URL.
616        let dl = engine();
617        let c = TemplateCompiler::new(Arc::clone(&dl));
618        let mut m = Message::from_value(&json!({}));
619        let ctx = TaskContext::new(&mut m, &dl);
620
621        for v in [
622            json!("abc"),
623            json!(7),
624            json!(true),
625            json!(null),
626            json!(["a", 1]),
627        ] {
628            let mut t = template_from(v.clone());
629            t.compile(&c, "lbl").unwrap();
630            assert!(t.is_constant(), "{v} should fold");
631            assert_eq!(
632                t.resolve_string(&ctx).unwrap(),
633                t.eval_to_plain_string(&ctx).unwrap(),
634                "cached and evaluated coercion disagree for {v}"
635            );
636        }
637    }
638
639    #[test]
640    fn an_escaped_key_does_not_fold_to_a_constant() {
641        // Worth pinning because it is counter-intuitive and costs something:
642        // `{"$cat": …}` has no data dependency, yet the compiler keeps it as a
643        // node rather than folding it, so an escaped literal is re-materialised
644        // per message where an unescaped one is cached.
645        //
646        // Only `resolve` (and its typed siblings) are affected — the *value* is
647        // identical either way, which is what the assertion below fixes. If a
648        // future datalogic release starts folding escaped keys this test fails
649        // and the only change needed is to delete it.
650        let dl = engine();
651        let c = TemplateCompiler::new(Arc::clone(&dl));
652        let mut m = Message::from_value(&json!({}));
653        let ctx = TaskContext::new(&mut m, &dl);
654
655        let mut t = template_from(json!({"$a": 1}));
656        t.compile(&c, "lbl").unwrap();
657        assert!(!t.is_constant(), "escaped keys are not folded today");
658        assert_eq!(t.eval_into::<Value>(&ctx).unwrap(), json!({"a": 1}));
659        assert_eq!(
660            t.resolve_string(&ctx).unwrap(),
661            t.eval_to_plain_string(&ctx).unwrap()
662        );
663    }
664
665    #[test]
666    fn resolve_u64_accepts_numbers_and_rejects_everything_else() {
667        let dl = engine();
668        let c = TemplateCompiler::new(Arc::clone(&dl));
669        let mut m = Message::from_value(&json!({}));
670        let ctx = TaskContext::new(&mut m, &dl);
671
672        let mut ok = template_from(json!(30000));
673        ok.compile(&c, "lbl").unwrap();
674        assert_eq!(ok.resolve_u64(&ctx, "timeout_ms").unwrap(), 30000);
675
676        // A missing path resolves to null rather than erroring, so without this
677        // check a mistyped timeout would silently become 0.
678        for bad in [json!(null), json!("30000"), json!(-1), json!({"a": 1})] {
679            let mut t = template_from(bad.clone());
680            t.compile(&c, "lbl").unwrap();
681            let err = t
682                .resolve_u64(&ctx, "timeout_ms")
683                .expect_err("{bad} must be rejected");
684            assert!(err.to_string().contains("timeout_ms"), "{err}");
685        }
686    }
687
688    #[test]
689    fn eval_to_plain_string_unquotes_and_coerces_non_strings() {
690        let dl = engine();
691        let c = TemplateCompiler::new(Arc::clone(&dl));
692        let mut m = Message::from_value(&json!({}));
693        let ctx = TaskContext::new(&mut m, &dl);
694
695        let mut string_t = template_from(json!("abc"));
696        string_t.compile(&c, "lbl").unwrap();
697        assert_eq!(string_t.eval_to_plain_string(&ctx).unwrap(), "abc");
698
699        let mut num_t = template_from(json!(7));
700        num_t.compile(&c, "lbl").unwrap();
701        assert_eq!(num_t.eval_to_plain_string(&ctx).unwrap(), "7");
702
703        let mut obj_t = template_from(json!({"a": 1}));
704        obj_t.compile(&c, "lbl").unwrap();
705        assert_eq!(obj_t.eval_to_plain_string(&ctx).unwrap(), "{\"a\":1}");
706    }
707
708    #[test]
709    fn eval_to_plain_string_before_compile_errors_without_panicking() {
710        let mut m = Message::from_value(&json!({}));
711        let dl = engine();
712        let ctx = TaskContext::new(&mut m, &dl);
713        let t = template_from(json!("abc"));
714
715        match t.eval_to_plain_string(&ctx) {
716            Err(DataflowError::LogicEvaluation(msg)) => {
717                assert!(
718                    msg.contains("compile"),
719                    "message should name the cause: {msg}"
720                );
721            }
722            other => panic!("expected LogicEvaluation, got {other:?}"),
723        }
724    }
725
726    #[test]
727    fn non_ascii_result_round_trips() {
728        let dl = engine();
729        let c = TemplateCompiler::new(Arc::clone(&dl));
730        let mut m = Message::from_value(&json!({}));
731        let ctx = TaskContext::new(&mut m, &dl);
732
733        let mut t = template_from(json!({"cat": ["über-", "größe"]}));
734        t.compile(&c, "lbl").unwrap();
735        assert_eq!(t.eval_into::<String>(&ctx).unwrap(), "über-größe");
736    }
737
738    #[test]
739    fn reading_an_absent_path_matches_the_engines_missing_path_result() {
740        let dl = engine();
741        let c = TemplateCompiler::new(Arc::clone(&dl));
742        let mut m = Message::from_value(&json!({}));
743        let ctx = TaskContext::new(&mut m, &dl);
744
745        let mut t = template_from(json!({"var": "data.nope"}));
746        t.compile(&c, "lbl").unwrap();
747        // Not an error — the same "resolves to Null" behaviour as the built-in
748        // *_logic fields on a missing path.
749        assert_eq!(t.eval(&ctx).unwrap(), OwnedDataValue::Null);
750    }
751}