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