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}