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