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