Skip to main content

dataflow_rs/engine/
compiler.rs

1//! # Workflow Compilation Module
2//!
3//! Pre-compiles all JSONLogic expressions used by workflows and tasks at engine
4//! initialization. Each compiled `Arc<Logic>` is stored directly on the
5//! workflow/task/config struct that owns it — no central `logic_cache`, no
6//! index lookup, no bounds check on the hot path. The `Engine` is wrapped in
7//! `Arc` and is `Send + Sync` so the entire stack is safe to share across
8//! Tokio worker threads.
9
10use crate::engine::error::{DataflowError, Result};
11use crate::engine::functions::integration::{EnrichConfig, HttpCallConfig, PublishKafkaConfig};
12use crate::engine::functions::template::{Template, TemplateCompiler};
13use crate::engine::functions::{FilterConfig, LogConfig, MapConfig, ValidationConfig};
14use crate::engine::{FunctionConfig, Workflow};
15use datalogic_rs::{Engine, Logic};
16use log::debug;
17use serde_json::Value;
18use std::sync::Arc;
19
20/// Compiles JSONLogic expressions and stamps them onto workflow/task/config
21/// structs as `Option<Arc<Logic>>` slots.
22pub struct LogicCompiler {
23    /// Shared datalogic Engine used both for compilation and (later) evaluation.
24    engine: Arc<Engine>,
25    /// Handed to `AsyncFunctionHandler::compile_input` and used internally to
26    /// compile `Template` fields on the built-in integration configs. Wraps the
27    /// same `engine`, so a `Template` compiled here or by a custom handler is
28    /// evaluable by the engine that will run the message.
29    template_compiler: TemplateCompiler,
30}
31
32impl Default for LogicCompiler {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl LogicCompiler {
39    /// Create a new LogicCompiler with a fresh datalogic `Engine` configured for
40    /// templating mode (preserves object structure in JSONLogic operations).
41    pub fn new() -> Self {
42        let engine = Arc::new(Engine::builder().with_templating(true).build());
43        let template_compiler = TemplateCompiler::new(Arc::clone(&engine));
44        Self {
45            engine,
46            template_compiler,
47        }
48    }
49
50    /// Get the Engine instance
51    pub fn engine(&self) -> Arc<Engine> {
52        Arc::clone(&self.engine)
53    }
54
55    /// Consume the compiler and return the shared engine.
56    pub fn into_engine(self) -> Arc<Engine> {
57        self.engine
58    }
59
60    /// Compile all workflows and their tasks, returning them sorted by priority.
61    /// Returns `Err` on the first validation or compilation failure — engine
62    /// construction is fail-loud so misconfigured workflows can't silently
63    /// disappear at runtime.
64    pub fn compile_workflows(&self, workflows: Vec<Workflow>) -> Result<Vec<Workflow>> {
65        let mut compiled_workflows = Vec::with_capacity(workflows.len());
66
67        for mut workflow in workflows {
68            workflow.validate()?;
69
70            // Populate the cached Arc<str> ids so audit emission can refcount-bump
71            // rather than reallocate per AuditTrail entry.
72            workflow.id_arc = Arc::from(workflow.id.as_str());
73            for task in &mut workflow.tasks {
74                task.id_arc = Arc::from(task.id.as_str());
75            }
76
77            // Pre-split `temp_data.{counter}` so a loop sweep never re-splits
78            // the write path.
79            if let Some(loop_config) = workflow.loop_config.as_mut() {
80                loop_config.precompute_counter_path();
81            }
82
83            // Compile the workflow condition (defaults to `true`, which folds
84            // to `None` so the hot path skips the eval — see `compile_condition`).
85            let label = format!("workflow {} condition", workflow.id);
86            workflow.compiled_condition = self.compile_condition(&workflow.condition, &label)?;
87            debug!("Workflow {} condition compiled", workflow.id);
88
89            // Compile task conditions and function-specific logic.
90            self.compile_workflow_tasks(&mut workflow)?;
91
92            // Stamp whether every task is a synchronous built-in. A fully-sync
93            // workflow can be folded into a shared cross-workflow `with_arena`
94            // scope (no `.await`), so the message context is deep-walked into
95            // the arena once per *run* of consecutive fully-sync workflows
96            // instead of once per workflow. Any async/custom task forces the
97            // per-workflow `.await` path.
98            workflow.fully_sync = workflow.tasks.iter().all(|t| t.function.is_sync_builtin());
99
100            compiled_workflows.push(workflow);
101        }
102
103        // Sort by priority once at construction time
104        compiled_workflows.sort_by_key(|w| w.priority);
105        Ok(compiled_workflows)
106    }
107
108    /// Compile task conditions and function logic for a workflow
109    fn compile_workflow_tasks(&self, workflow: &mut Workflow) -> Result<()> {
110        for task in &mut workflow.tasks {
111            let label = format!("task {} condition (workflow {})", task.id, workflow.id);
112            task.compiled_condition = self.compile_condition(&task.condition, &label)?;
113
114            // Compile function-specific logic (map transformations, validation rules, …)
115            self.compile_function_logic(&mut task.function, &task.id, &workflow.id)?;
116        }
117        Ok(())
118    }
119
120    /// Compile function-specific logic based on function type
121    fn compile_function_logic(
122        &self,
123        function: &mut FunctionConfig,
124        task_id: &str,
125        workflow_id: &str,
126    ) -> Result<()> {
127        match function {
128            FunctionConfig::Map { input, .. } => {
129                self.compile_map_logic(input, task_id, workflow_id)
130            }
131            FunctionConfig::Validation { input, .. } => {
132                self.compile_validation_logic(input, task_id, workflow_id)
133            }
134            FunctionConfig::Filter { input, .. } => {
135                self.compile_filter_logic(input, task_id, workflow_id)
136            }
137            FunctionConfig::Log { input, .. } => {
138                self.compile_log_logic(input, task_id, workflow_id)
139            }
140            FunctionConfig::HttpCall { input, .. } => {
141                self.compile_http_call_logic(input, task_id, workflow_id)
142            }
143            FunctionConfig::Enrich { input, .. } => {
144                self.compile_enrich_logic(input, task_id, workflow_id)
145            }
146            FunctionConfig::PublishKafka { input, .. } => {
147                self.compile_publish_kafka_logic(input, task_id, workflow_id)
148            }
149            // No JSONLogic to compile, but the `data.{target}` write path is
150            // precomputed here (path string + pre-split parts) so the hot
151            // path never re-formats or re-splits it.
152            FunctionConfig::ParseJson { input, .. } | FunctionConfig::ParseXml { input, .. } => {
153                input.precompute_target_path();
154                Ok(())
155            }
156            FunctionConfig::PublishJson { input, .. }
157            | FunctionConfig::PublishXml { input, .. } => {
158                input.precompute_target_path();
159                Ok(())
160            }
161            // Custom and other functions don't need pre-compilation
162            _ => Ok(()),
163        }
164    }
165
166    /// Compile a JSONLogic expression and return the `Arc<Logic>`. Errors are
167    /// surfaced as `DataflowError::LogicEvaluation` with the supplied
168    /// context label for debugging.
169    fn compile(&self, logic: &Value, ctx_label: &str) -> Result<Arc<Logic>> {
170        self.engine
171            .compile_arc(logic)
172            .map_err(|e| DataflowError::LogicEvaluation(format!("{}: {}", ctx_label, e)))
173    }
174
175    /// Compile a workflow/task *condition*, returning `None` when the source is
176    /// the literal `true`. A `None` condition is treated as "always run" by
177    /// `evaluate_condition` / `evaluate_condition_in_arena`, so the hot path
178    /// skips the `engine.evaluate` call — and, in the sync stretch, the
179    /// per-task arena context slice build — entirely for the overwhelmingly
180    /// common default `condition: true`. datalogic already folds a literal
181    /// `true` to a near-free literal-fast-path eval; this avoids even setting
182    /// up the call. Non-literal conditions (including `false` and any real
183    /// expression) compile as normal.
184    fn compile_condition(&self, condition: &Value, ctx_label: &str) -> Result<Option<Arc<Logic>>> {
185        if matches!(condition, Value::Bool(true)) {
186            return Ok(None);
187        }
188        Ok(Some(self.compile(condition, ctx_label)?))
189    }
190
191    /// Compile map transformation logic
192    fn compile_map_logic(
193        &self,
194        config: &mut MapConfig,
195        task_id: &str,
196        workflow_id: &str,
197    ) -> Result<()> {
198        for mapping in &mut config.mappings {
199            // Pre-split the dot path so the hot path doesn't re-split per
200            // write. The `#` prefix is preserved here — it's the explicit
201            // "treat this as an object key, not an array index" hint that
202            // `set_nested_value` consumes when deciding container shape; the
203            // strip happens at lookup time inside `*_parts` helpers.
204            let parts: Vec<Arc<str>> = mapping.path.split('.').map(Arc::from).collect();
205            mapping.path_parts = Arc::from(parts.into_boxed_slice());
206            mapping.path_arc = Arc::from(mapping.path.as_str());
207
208            let label = format!(
209                "map logic for task {} in workflow {} (path {})",
210                task_id, workflow_id, mapping.path
211            );
212            mapping.compiled_logic = Some(self.compile(&mapping.logic, &label)?);
213        }
214        Ok(())
215    }
216
217    /// Compile validation rule logic
218    fn compile_validation_logic(
219        &self,
220        config: &mut ValidationConfig,
221        task_id: &str,
222        workflow_id: &str,
223    ) -> Result<()> {
224        for (idx, rule) in config.rules.iter_mut().enumerate() {
225            let label = format!(
226                "validation rule {} for task {} in workflow {}",
227                idx, task_id, workflow_id
228            );
229            rule.compiled_logic = Some(self.compile(&rule.logic, &label)?);
230        }
231        Ok(())
232    }
233
234    /// Compile log message and field expressions
235    fn compile_log_logic(
236        &self,
237        config: &mut LogConfig,
238        task_id: &str,
239        workflow_id: &str,
240    ) -> Result<()> {
241        let msg_label = label("log message", task_id, workflow_id);
242        config.compiled_message = Some(self.compile(&config.message, &msg_label)?);
243
244        // Compile each field expression. Collect into a fresh Vec, then
245        // assign — keeps the immutable borrow of `config.fields` from
246        // overlapping with the mutable borrow of `config.compiled_fields`.
247        let mut compiled_fields = Vec::with_capacity(config.fields.len());
248        for (key, logic) in &config.fields {
249            let label = format!(
250                "log field '{}' for task {} in workflow {}",
251                key, task_id, workflow_id
252            );
253            compiled_fields.push((key.clone(), Some(self.compile(logic, &label)?)));
254        }
255        config.compiled_fields = compiled_fields;
256        Ok(())
257    }
258
259    /// Compile filter condition logic
260    fn compile_filter_logic(
261        &self,
262        config: &mut FilterConfig,
263        task_id: &str,
264        workflow_id: &str,
265    ) -> Result<()> {
266        let label = label("filter condition", task_id, workflow_id);
267        config.compiled_condition = Some(self.compile(&config.condition, &label)?);
268        Ok(())
269    }
270
271    /// Compile http_call JSONLogic expressions (path_logic, body_logic)
272    fn compile_http_call_logic(
273        &self,
274        config: &mut HttpCallConfig,
275        task_id: &str,
276        workflow_id: &str,
277    ) -> Result<()> {
278        self.compile_template_field(
279            &mut config.path_logic,
280            "http_call path_logic",
281            task_id,
282            workflow_id,
283        )?;
284        self.compile_template_field(
285            &mut config.body_logic,
286            "http_call body_logic",
287            task_id,
288            workflow_id,
289        )?;
290        Ok(())
291    }
292
293    /// Compile enrich JSONLogic expressions (path_logic)
294    fn compile_enrich_logic(
295        &self,
296        config: &mut EnrichConfig,
297        task_id: &str,
298        workflow_id: &str,
299    ) -> Result<()> {
300        self.compile_template_field(
301            &mut config.path_logic,
302            "enrich path_logic",
303            task_id,
304            workflow_id,
305        )
306    }
307
308    /// Compile publish_kafka JSONLogic expressions (key_logic, value_logic)
309    fn compile_publish_kafka_logic(
310        &self,
311        config: &mut PublishKafkaConfig,
312        task_id: &str,
313        workflow_id: &str,
314    ) -> Result<()> {
315        self.compile_template_field(
316            &mut config.key_logic,
317            "publish_kafka key_logic",
318            task_id,
319            workflow_id,
320        )?;
321        self.compile_template_field(
322            &mut config.value_logic,
323            "publish_kafka value_logic",
324            task_id,
325            workflow_id,
326        )?;
327        Ok(())
328    }
329
330    /// Compile an optional built-in integration `Template` field — `path_logic`,
331    /// `body_logic`, `key_logic`, `value_logic` — against `self.template_compiler`.
332    /// A `None` field is a no-op, matching every one of these fields being
333    /// optional. `what` labels the compile-error context as `"{what} for task
334    /// {task_id} in workflow {workflow_id}"`, e.g. `"http_call body_logic"`.
335    fn compile_template_field(
336        &self,
337        field: &mut Option<Template>,
338        what: &str,
339        task_id: &str,
340        workflow_id: &str,
341    ) -> Result<()> {
342        if let Some(t) = field {
343            t.compile(&self.template_compiler, &label(what, task_id, workflow_id))?;
344        }
345        Ok(())
346    }
347}
348
349/// Format a JSONLogic compile-error label as `"{what} for task {task_id} in
350/// workflow {workflow_id}"` — the shape shared by every built-in whose
351/// context needs no further detail (a few, like map mappings and validation
352/// rules, append per-item detail and format their own label instead).
353fn label(what: &str, task_id: &str, workflow_id: &str) -> String {
354    format!("{what} for task {task_id} in workflow {workflow_id}")
355}
356
357#[cfg(test)]
358mod tests {
359    //! Pins the datalogic operator semantics this crate's own behaviour
360    //! depends on. Not an attempt at a general operator-semantics table — that
361    //! was investigated and refused: `datalogic-rs` keeps `mod opcode;` private
362    //! and `OpCode` `pub(crate)`, so this crate could only hand-maintain the
363    //! same unverified table one layer lower, and it would actively mislead —
364    //! see `an_unrecognised_operator_is_not_an_error_under_templating` below,
365    //! which is exactly the case a static "known operators" table would get
366    //! wrong. Every value here was read from a live `datalogic_rs::Engine`
367    //! built the way `LogicCompiler::new` builds one, not assumed.
368    //!
369    //! If a `datalogic-rs` upgrade changes any of these, that is a real
370    //! behaviour change for every workflow in production — these tests exist
371    //! so it fails CI instead of surfacing as a support ticket.
372    //!
373    //! These values are also *feature*-dependent. This crate exposes the
374    //! `datalogic-rs` operator families as cargo features, all off by default.
375    //! Any test whose answer changes when a family is enabled carries a
376    //! `#[cfg(feature = ...)]` so **both** configurations stay pinned —
377    //! otherwise the `--all-features` CI run would be the only one checking
378    //! anything and the default build, which is what `cargo add dataflow-rs`
379    //! delivers, would go untested.
380
381    use super::*;
382    use serde_json::json;
383
384    /// The exact engine construction `LogicCompiler::new` uses: templating
385    /// enabled, plus whichever `datalogic-rs` operator families this crate's
386    /// cargo features turned on — none, by default. Which families are live is
387    /// fixed at compile time, so a test whose result depends on one must be
388    /// `#[cfg]`-gated rather than assuming the default build.
389    fn engine() -> Engine {
390        Engine::builder().with_templating(true).build()
391    }
392
393    fn eval(engine: &Engine, logic: &Value) -> Value {
394        let compiled = engine.compile_arc(logic).expect("should compile");
395        let ctx = datavalue::OwnedDataValue::from(&json!({}));
396        serde_json::from_str(
397            &engine
398                .session()
399                .eval_str(&compiled, &ctx)
400                .expect("should evaluate"),
401        )
402        .expect("eval_str output should be valid JSON")
403    }
404
405    /// A one-task workflow carrying `extra` as additional top-level JSON keys.
406    fn workflow_json(extra: &str) -> String {
407        format!(
408            r#"{{ "id": "w", "name": "w", {extra}
409                 "tasks": [{{"id": "t", "name": "t",
410                             "function": {{"name": "map", "input": {{"mappings": []}}}}}}] }}"#
411        )
412    }
413
414    #[test]
415    fn compile_workflows_precomputes_the_loop_counter_path() {
416        let workflow =
417            Workflow::from_json(&workflow_json(r#""loop": {"counter": "i", "max": 3},"#))
418                .expect("should parse");
419
420        let compiled = LogicCompiler::new()
421            .compile_workflows(vec![workflow])
422            .expect("should compile");
423
424        let cfg = compiled[0].loop_config.as_ref().expect("loop config");
425        let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
426        assert_eq!(parts, ["temp_data", "i"]);
427    }
428
429    #[test]
430    fn compile_workflows_rejects_an_invalid_loop_config() {
431        // `Workflow::validate` runs inside `compile_workflows`, so a bound that
432        // could never advance fails engine construction rather than the first
433        // message.
434        let workflow =
435            Workflow::from_json(&workflow_json(r#""loop": {"init": 5, "max": 5},"#)).unwrap();
436
437        assert!(
438            LogicCompiler::new()
439                .compile_workflows(vec![workflow])
440                .is_err()
441        );
442    }
443
444    #[test]
445    fn compile_workflows_leaves_a_non_looping_workflow_without_a_loop() {
446        let workflow = Workflow::from_json(&workflow_json("")).expect("should parse");
447
448        let compiled = LogicCompiler::new()
449            .compile_workflows(vec![workflow])
450            .expect("should compile");
451
452        assert!(compiled[0].loop_config.is_none());
453    }
454
455    #[test]
456    fn empty_operand_results_this_crate_would_silently_break_on() {
457        // A workflow author can write any of these — a map mapping folding an
458        // empty list, a filter condition over an empty selector — and the
459        // crate never validates operand count. If a datalogic upgrade changed
460        // any of these defaults, every workflow relying on the vacuous case
461        // would silently start producing a different value.
462        let e = engine();
463        for (logic, expected) in [
464            (json!({"and": []}), json!(null)),
465            (json!({"or": []}), json!(null)),
466            (json!({"+": []}), json!(0)),
467            (json!({"*": []}), json!(1)),
468            (json!({"cat": []}), json!("")),
469            (json!({"merge": []}), json!([])),
470            (json!({"missing": []}), json!([])),
471        ] {
472            assert_eq!(eval(&e, &logic), expected, "for {logic}");
473        }
474    }
475
476    #[test]
477    fn a_missing_var_path_resolves_to_null_not_an_error() {
478        // The exact mechanism behind the pitfall CLAUDE.md documents for
479        // `payload.*` expressions: a `var` over a path that does not resolve
480        // is `Null`, silently, never `Err`. `Template::eval` and the built-in
481        // `*_logic` fields inherit this — there is no engine-level signal that
482        // distinguishes "field absent" from "field is null".
483        let e = engine();
484        assert_eq!(
485            eval(&e, &json!({"var": "data.does_not_exist"})),
486            json!(null)
487        );
488    }
489
490    #[test]
491    fn truthy_falsy_matches_the_documented_semantics() {
492        // Verifies the claim in docs/src/advanced/jsonlogic.md's Truthy/Falsy
493        // section, which is a `json` fence and therefore NOT compiled by
494        // dataflow-docs-tests — this is the only check on that claim.
495        // Notable and easy to get wrong: an empty object `{}` is falsy here,
496        // unlike some JSONLogic implementations that treat any object as truthy.
497        let e = engine();
498        for (v, truthy) in [
499            (json!(0), false),
500            (json!(""), false),
501            (json!(false), false),
502            (json!(null), false),
503            (json!([]), false),
504            (json!({}), false),
505            (json!("x"), true),
506            (json!(1), true),
507        ] {
508            assert_eq!(
509                eval(&e, &json!({"!!": v})),
510                json!(truthy),
511                "truthiness of {v}"
512            );
513        }
514    }
515
516    #[test]
517    fn an_unrecognised_operator_is_not_an_error_under_templating() {
518        // The load-bearing fact behind #26's refusal of a static "known
519        // operators" table, and the reason `Template` documents itself as
520        // opt-in per field rather than a blanket JSON wrapper: under
521        // templating (which LogicCompiler and TemplateCompiler both enable),
522        // an outright typo neither fails to compile nor fails to evaluate. It
523        // echoes back as a literal structured object instead — a workflow
524        // author who mistypes an operator name gets silent pass-through, not a
525        // validation error. True under every feature combination, so this half
526        // of the tripwire is unconditional.
527        let e = engine();
528        let logic = json!({"totally_made_up_op_xyz": ["a", "b"]});
529        assert_eq!(
530            eval(&e, &logic),
531            logic,
532            "an unrecognised operator must echo back verbatim, not error"
533        );
534    }
535
536    /// With `ext-string` off, `starts_with` is not a name the engine knows, so
537    /// it is indistinguishable from the typo above: silent pass-through. This
538    /// is the failure mode a workflow author hits when they reach for an
539    /// operator whose family this build did not enable — no error, just a
540    /// wrong value.
541    #[cfg(not(feature = "ext-string"))]
542    #[test]
543    fn a_gated_operator_echoes_back_while_its_family_is_off() {
544        let e = engine();
545        let logic = json!({"starts_with": ["hello", "he"]});
546        assert_eq!(
547            eval(&e, &logic),
548            logic,
549            "an operator behind an unenabled family must echo back, not error"
550        );
551    }
552
553    /// The other side of the same coin, and the reason enabling a family is
554    /// not a no-op for existing workflows: `ext-string` converts a previously
555    /// inert `{"starts_with": [...]}` *literal* into a live operator call.
556    /// Anyone carrying such an object as data through a `map` mapping sees
557    /// their value silently replaced by the operator's result.
558    #[cfg(feature = "ext-string")]
559    #[test]
560    fn a_gated_operator_evaluates_once_its_family_is_on() {
561        let e = engine();
562        assert_eq!(
563            eval(&e, &json!({"starts_with": ["hello", "he"]})),
564            json!(true),
565            "with ext-string on, starts_with must evaluate, not echo"
566        );
567    }
568
569    /// `datetime` is the one family that is not confined to new operator
570    /// names. `datalogic-rs`'s comparison path probes *plain strings* for a
571    /// datetime/duration shape before falling back to byte comparison, so
572    /// `==` and the ordering operators change answers on date-shaped
573    /// operands. These two strings are different byte sequences naming the
574    /// same instant.
575    #[test]
576    fn datetime_feature_changes_plain_string_comparison() {
577        let e = engine();
578        let logic = json!({"==": ["2024-01-15T00:00:00Z", "2024-01-15T01:00:00+01:00"]});
579        #[cfg(feature = "datetime")]
580        assert_eq!(eval(&e, &logic), json!(true));
581        #[cfg(not(feature = "datetime"))]
582        assert_eq!(eval(&e, &logic), json!(false));
583    }
584
585    /// Each family's cargo feature actually reaches `datalogic-rs`. One
586    /// representative operator per family is enough — the feature either
587    /// forwards or it does not.
588    #[cfg(feature = "ext-string")]
589    #[test]
590    fn ext_string_feature_reaches_datalogic() {
591        let e = engine();
592        assert_eq!(eval(&e, &json!({"upper": "ab"})), json!("AB"));
593    }
594
595    #[cfg(feature = "ext-array")]
596    #[test]
597    fn ext_array_feature_reaches_datalogic() {
598        let e = engine();
599        assert_eq!(eval(&e, &json!({"sort": [[3, 1, 2]]})), json!([1, 2, 3]));
600    }
601
602    #[cfg(feature = "ext-math")]
603    #[test]
604    fn ext_math_feature_reaches_datalogic() {
605        let e = engine();
606        assert_eq!(eval(&e, &json!({"abs": -5})), json!(5));
607    }
608
609    #[cfg(feature = "ext-control")]
610    #[test]
611    fn ext_control_feature_reaches_datalogic() {
612        let e = engine();
613        assert_eq!(
614            eval(&e, &json!({"??": [null, "fallback"]})),
615            json!("fallback")
616        );
617    }
618
619    #[cfg(feature = "error-handling")]
620    #[test]
621    fn error_handling_feature_reaches_datalogic() {
622        // `error-handling` is the JSONLogic `try`/`throw` pair — unrelated to
623        // this crate's own always-on error handling.
624        let e = engine();
625        assert_eq!(
626            eval(&e, &json!({"try": [{"throw": "boom"}, "recovered"]})),
627            json!("recovered")
628        );
629    }
630
631    /// The `datetime` family's own operators. Their exact output depends on
632    /// the ambient clock and on format details this crate does not pin, so
633    /// assert only the property the feature actually buys: the operator is
634    /// recognised and evaluates, rather than echoing back as a literal.
635    #[cfg(feature = "datetime")]
636    #[test]
637    fn datetime_feature_reaches_datalogic() {
638        let e = engine();
639        let logic = json!({"now": []});
640        let result = eval(&e, &logic);
641        assert_ne!(result, logic, "with datetime on, `now` must not echo back");
642        assert!(!result.is_null(), "`now` should produce a value, got null");
643    }
644}