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