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