Skip to main content

dataflow_rs/engine/
workflow.rs

1use crate::engine::error::{DataflowError, Result};
2use crate::engine::functions::{ConnectorName, FunctionConfig};
3use crate::engine::task::Task;
4use chrono::{DateTime, Utc};
5use datalogic_rs::Logic;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::fs;
9use std::path::Path;
10use std::sync::Arc;
11
12pub use crate::engine::rollout::{Rollout, RolloutError};
13
14/// Engine-managed `for` loop over a workflow's task list.
15///
16/// A workflow carrying a `loop` runs its task list repeatedly — one *sweep*
17/// per iteration — rather than once. Per sweep the engine writes the counter
18/// into `temp_data` (when [`counter`](Self::counter) names it), checks
19/// `counter < max`, then re-evaluates the workflow `condition`; the sweep runs
20/// only if both hold. Afterwards the counter advances by `increment`.
21///
22/// The bound is half-open, matching [`Rollout`]: `init: 0, max: n` yields
23/// counter values `0..n-1` — exactly array indices.
24///
25/// Reaching `max` is normal completion, never an error: `max` is always
26/// author-supplied, so hitting it is the stated bound rather than a runaway.
27/// To stop mid-body, use a `filter` task with `on_reject: halt` — that breaks
28/// the whole loop, not just the current sweep.
29///
30/// # Example
31///
32/// ```json
33/// {
34///     "id": "per_item",
35///     "condition": {"<": [{"var": "temp_data.i"}, {"var": "temp_data.n"}]},
36///     "loop": { "counter": "i", "max": 10000 },
37///     "tasks": [ ... ]
38/// }
39/// ```
40#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
41pub struct LoopConfig {
42    /// `temp_data` field the engine maintains as the induction variable —
43    /// `"i"` means `temp_data.i`, and dot-paths nest (`"cursor.index"` →
44    /// `temp_data.cursor.index`).
45    ///
46    /// `None` still bounds the loop by `max`; the count is simply not exposed
47    /// to conditions or tasks. The engine tracks it either way, so the audit
48    /// trail carries it regardless.
49    ///
50    /// The engine owns this field: it is rewritten before every sweep, so a
51    /// body task writing the same path is overwritten at the next increment.
52    #[serde(default)]
53    pub counter: Option<String>,
54
55    /// First counter value. Defaults to `0`.
56    #[serde(default)]
57    pub init: i64,
58
59    /// Added to the counter after each sweep. Defaults to `1`; must be `>= 1`,
60    /// so the counter strictly increases and the loop cannot stall.
61    #[serde(default = "default_increment")]
62    pub increment: i64,
63
64    /// Required upper bound — sweeps run while `counter < max`. There is no
65    /// default: an unbounded loop is never what the author meant, and the
66    /// bound is what makes termination structural rather than a property of
67    /// the condition being written correctly.
68    pub max: i64,
69
70    /// Engine-internal: `["temp_data", ..counter segments]`, populated by
71    /// `LogicCompiler`. Empty when `counter` is `None`. Not part of the stable
72    /// API.
73    #[doc(hidden)]
74    #[serde(skip)]
75    pub counter_parts: Arc<[Arc<str>]>,
76}
77
78fn default_increment() -> i64 {
79    1
80}
81
82impl LoopConfig {
83    /// Structural validation, run from [`Workflow::validate`] at
84    /// `Engine::build()` time. Every rule here rejects a config that could
85    /// only fail — or spin — at runtime.
86    fn validate(&self, workflow_id: &str) -> Result<()> {
87        if self.increment < 1 {
88            return Err(DataflowError::Workflow(format!(
89                "Workflow {workflow_id}: loop increment must be >= 1, got {} \
90                 (a non-advancing counter would never reach max)",
91                self.increment
92            )));
93        }
94        if self.max <= self.init {
95            return Err(DataflowError::Workflow(format!(
96                "Workflow {workflow_id}: loop max ({}) must be greater than init ({}) — \
97                 the bound is half-open, so this could never run a sweep",
98                self.max, self.init
99            )));
100        }
101        if let Some(counter) = &self.counter {
102            if counter.is_empty() || counter.split('.').any(str::is_empty) {
103                return Err(DataflowError::Workflow(format!(
104                    "Workflow {workflow_id}: loop counter must be a non-empty \
105                     temp_data field path, got {counter:?}"
106                )));
107            }
108        }
109        Ok(())
110    }
111
112    /// Pre-split `temp_data.{counter}` into the path parts the executor writes
113    /// through, so a sweep never re-splits the path. Populated by
114    /// `LogicCompiler`; the executor falls back to splitting on the fly for
115    /// workflows constructed directly rather than through `Engine::builder`.
116    #[doc(hidden)]
117    pub fn precompute_counter_path(&mut self) {
118        self.counter_parts = match &self.counter {
119            Some(counter) => crate::engine::utils::compute_path_parts("temp_data", counter),
120            None => Arc::from([] as [Arc<str>; 0]),
121        };
122    }
123}
124
125/// Workflow lifecycle status
126#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "lowercase")]
128pub enum WorkflowStatus {
129    #[default]
130    Active,
131    Paused,
132    Archived,
133}
134
135/// Workflow represents a collection of tasks that execute sequentially (also known as a Rule in rules-engine terminology).
136///
137/// Conditions are evaluated against the full message context, including `data`, `metadata`, and `temp_data` fields.
138/// A collection of tasks with a condition, executed against a message.
139///
140/// `#[non_exhaustive]`: construct through [`Workflow::new`], [`Workflow::rule`]
141/// or [`Workflow::from_json`] and assign the public fields you need. Field
142/// reads and writes are unaffected.
143///
144/// Same reason as [`Task`]: several fields are engine internals
145/// marked *not part of the stable API*, and struct-literal construction forced
146/// callers to name them.
147#[derive(Clone, Debug, Deserialize)]
148#[non_exhaustive]
149pub struct Workflow {
150    pub id: String,
151    /// Engine-internal: `Arc<str>` mirror of `id`, populated by
152    /// `LogicCompiler::compile_workflows`. Cloning is a refcount bump; per-message
153    /// `AuditTrail` entries reuse it instead of allocating from `&id` each time.
154    /// Not part of the stable API.
155    #[doc(hidden)]
156    #[serde(skip)]
157    pub id_arc: Arc<str>,
158    pub name: String,
159    #[serde(default)]
160    pub priority: u32,
161    pub description: Option<String>,
162    #[serde(default = "crate::engine::utils::default_condition")]
163    pub condition: Value,
164    /// Engine-internal: pre-compiled JSONLogic for `condition`, populated by
165    /// `LogicCompiler`. `None` is treated as "no condition / always run" by
166    /// the executor. Not part of the stable API.
167    #[doc(hidden)]
168    #[serde(skip)]
169    pub compiled_condition: Option<Arc<Logic>>,
170    /// Engine-internal: `true` when every task is a synchronous built-in
171    /// (`is_sync_builtin`), so the whole workflow can run inside a shared
172    /// `with_arena` scope with no `.await`. Populated by `LogicCompiler`; the
173    /// `false` default means an uncompiled workflow conservatively takes the
174    /// async path. Not part of the stable API.
175    #[doc(hidden)]
176    #[serde(skip, default)]
177    pub fully_sync: bool,
178    /// The workflow's steps, flattened.
179    ///
180    /// The JSON `tasks` array holds *steps*: an element carrying a `tasks` key
181    /// is a [`TaskGroup`](crate::TaskGroup), anything else is a [`Task`]. The
182    /// parser flattens the
183    /// tree in document order and records each group's span on the task that
184    /// opens it ([`Task::group_starts`]), so this stays a flat list and the
185    /// executor keeps walking `&[Task]` slices.
186    #[serde(deserialize_with = "crate::engine::steps::flatten")]
187    pub tasks: Vec<Task>,
188    #[serde(default)]
189    pub continue_on_error: bool,
190    /// Channel for routing (default: "default")
191    #[serde(default = "default_channel")]
192    pub channel: String,
193    /// Version number for rule versioning (default: 1)
194    #[serde(default = "default_version")]
195    pub version: u32,
196    /// Workflow status — Active, Paused, or Archived (default: Active)
197    #[serde(default)]
198    pub status: WorkflowStatus,
199    /// Traffic split for this workflow. `None` (the default) means the workflow
200    /// is not part of a split and runs for every message.
201    ///
202    /// A workflow with a rollout is skipped when the message's
203    /// [`crate::Message::routing_bucket`] falls outside the range. A message with
204    /// **no** bucket is admitted — see [`Rollout`].
205    #[serde(default)]
206    pub rollout: Option<Rollout>,
207    /// Engine-managed loop over this workflow's task list. `None` (the
208    /// default) runs the task list exactly once — the historical behaviour, on
209    /// a code path that carries no loop overhead.
210    ///
211    /// See [`LoopConfig`] for the per-sweep contract.
212    #[serde(default, rename = "loop")]
213    pub loop_config: Option<LoopConfig>,
214    /// Tags for categorization and filtering
215    #[serde(default)]
216    pub tags: Vec<String>,
217    /// Creation timestamp
218    #[serde(default)]
219    pub created_at: Option<DateTime<Utc>>,
220    /// Last update timestamp
221    #[serde(default)]
222    pub updated_at: Option<DateTime<Utc>>,
223}
224
225fn default_channel() -> String {
226    "default".to_string()
227}
228
229fn default_version() -> u32 {
230    1
231}
232
233impl Default for Workflow {
234    fn default() -> Self {
235        Self::new()
236    }
237}
238
239impl Workflow {
240    pub fn new() -> Self {
241        Self {
242            id: String::new(),
243            id_arc: Arc::from(""),
244            name: String::new(),
245            priority: 0,
246            description: None,
247            condition: Value::Bool(true),
248            compiled_condition: None,
249            fully_sync: false,
250            tasks: Vec::new(),
251            continue_on_error: false,
252            channel: default_channel(),
253            version: 1,
254            status: WorkflowStatus::Active,
255            rollout: None,
256            loop_config: None,
257            tags: Vec::new(),
258            created_at: None,
259            updated_at: None,
260        }
261    }
262
263    /// Create a workflow (rule) with a condition and tasks.
264    ///
265    /// This is a convenience constructor for the IFTTT-style rules engine pattern:
266    /// **IF** `condition` **THEN** execute `tasks`.
267    ///
268    /// # Arguments
269    /// * `id` - Unique identifier for the rule
270    /// * `name` - Human-readable name
271    /// * `condition` - JSONLogic condition evaluated against the full context (data, metadata, temp_data)
272    /// * `tasks` - Actions to execute when the condition is met
273    pub fn rule(id: &str, name: &str, condition: Value, tasks: Vec<Task>) -> Self {
274        Self {
275            id: id.to_string(),
276            id_arc: Arc::from(id),
277            name: name.to_string(),
278            priority: 0,
279            description: None,
280            condition,
281            compiled_condition: None,
282            fully_sync: false,
283            tasks,
284            continue_on_error: false,
285            channel: default_channel(),
286            version: 1,
287            status: WorkflowStatus::Active,
288            rollout: None,
289            loop_config: None,
290            tags: Vec::new(),
291            created_at: None,
292            updated_at: None,
293        }
294    }
295
296    /// Load workflow from JSON string
297    pub fn from_json(json_str: &str) -> Result<Self> {
298        serde_json::from_str(json_str).map_err(DataflowError::from_serde)
299    }
300
301    /// Load workflow from JSON file
302    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
303        let json_str = fs::read_to_string(path).map_err(DataflowError::from_io)?;
304
305        Self::from_json(&json_str)
306    }
307
308    /// Validate the workflow structure
309    pub fn validate(&self) -> Result<()> {
310        // Check required fields
311        if self.id.is_empty() {
312            return Err(DataflowError::Workflow(
313                "Workflow id cannot be empty".to_string(),
314            ));
315        }
316
317        if self.name.is_empty() {
318            return Err(DataflowError::Workflow(
319                "Workflow name cannot be empty".to_string(),
320            ));
321        }
322
323        // Check tasks
324        if self.tasks.is_empty() {
325            return Err(DataflowError::Workflow(
326                "Workflow must have at least one task".to_string(),
327            ));
328        }
329
330        // Validate that task and group IDs are unique. Groups share the task
331        // id namespace: both name a step, both surface in traces and error
332        // messages, and a collision would make either ambiguous.
333        let mut step_ids = std::collections::HashSet::new();
334        for task in &self.tasks {
335            for group in &task.group_starts {
336                if !step_ids.insert(group.id.as_str()) {
337                    return Err(DataflowError::Workflow(format!(
338                        "Duplicate step ID '{}' in workflow — task group IDs share the task ID namespace",
339                        group.id
340                    )));
341                }
342            }
343            if !step_ids.insert(task.id.as_str()) {
344                return Err(DataflowError::Workflow(format!(
345                    "Duplicate task ID '{}' in workflow",
346                    task.id
347                )));
348            }
349        }
350
351        // A loop whose bounds could never advance is rejected at build time
352        // rather than spinning — or silently doing nothing — on the first
353        // message.
354        if let Some(loop_config) = &self.loop_config {
355            loop_config.validate(&self.id)?;
356        }
357
358        Ok(())
359    }
360}
361
362/// One task's connector reference, located within a workflow.
363///
364/// `Copy`: every field is a shared borrow. `config` is carried so callers can
365/// apply cross-field rules — "a task on this kind of connector also needs
366/// `input.database`" — without re-parsing the task.
367///
368/// Not `Serialize`: [`FunctionConfig`] is deserialize-only, so callers that emit
369/// JSON diagnostics build their own shape from these fields.
370#[derive(Debug, Clone, Copy)]
371pub struct ConnectorRef<'a> {
372    /// `id` of the owning workflow.
373    pub workflow_id: &'a str,
374    /// `id` of the referencing task.
375    pub task_id: &'a str,
376    /// Canonical function name, as [`FunctionConfig::function_name`].
377    pub function: &'a str,
378    /// The connector name when authored as a literal, or the expression when
379    /// computed. See [`ConnectorName`].
380    pub connector: ConnectorName<'a>,
381    /// The whole function config, for cross-field rules.
382    pub config: &'a FunctionConfig,
383}
384
385impl Workflow {
386    /// Every connector reference in this workflow, in task order.
387    ///
388    /// Tasks whose function names no connector are skipped. One item is yielded
389    /// per *task*, not per distinct connector: two tasks on the same connector
390    /// yield two items. Callers wanting a distinct set collect one themselves.
391    ///
392    /// Does not require a compiled workflow — this reads only deserialized
393    /// fields, so it works on the output of [`Workflow::from_json`] before the
394    /// engine has compiled it.
395    ///
396    /// Which configs carry a connector is this crate's fact; deriving it here
397    /// rather than reimplementing the set downstream is the point.
398    pub fn connector_refs(&self) -> impl Iterator<Item = ConnectorRef<'_>> {
399        // `move` is load-bearing: it copies the `&Workflow` into the closure so
400        // the returned iterator does not borrow a local.
401        self.tasks.iter().filter_map(move |task| {
402            task.function.connector().map(|connector| ConnectorRef {
403                workflow_id: &self.id,
404                task_id: &task.id,
405                function: task.function.function_name(),
406                connector,
407                config: &task.function,
408            })
409        })
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    fn wf(tasks_json: &str) -> Workflow {
418        Workflow::from_json(&format!(
419            r#"{{ "id": "w", "name": "w", "priority": 0, "condition": true,
420                  "tasks": [{tasks_json}] }}"#
421        ))
422        .expect("workflow should parse")
423    }
424
425    const HTTP: &str = r#"{ "id": "call", "name": "call", "function": {
426        "name": "http_call", "input": { "connector": "user_service" } } }"#;
427    const KAFKA: &str = r#"{ "id": "pub", "name": "pub", "function": {
428        "name": "publish_kafka",
429        "input": { "connector": "events", "topic": "t" } } }"#;
430    const MAP: &str = r#"{ "id": "m", "name": "m", "function": {
431        "name": "map", "input": { "mappings": [] } } }"#;
432    const LOG: &str = r#"{ "id": "l", "name": "l", "function": {
433        "name": "log", "input": { "message": "hi" } } }"#;
434
435    #[test]
436    fn connector_refs_yields_only_connector_tasks_in_task_order() {
437        let workflow = wf(&format!("{MAP},{HTTP},{LOG},{KAFKA}"));
438        let refs: Vec<_> = workflow.connector_refs().collect();
439
440        assert_eq!(refs.len(), 2);
441        assert_eq!(refs[0].task_id, "call");
442        assert_eq!(refs[0].function, "http_call");
443        assert_eq!(refs[0].connector.as_static(), Some("user_service"));
444        assert_eq!(refs[1].task_id, "pub");
445        assert_eq!(refs[1].function, "publish_kafka");
446        assert_eq!(refs[1].connector.as_static(), Some("events"));
447    }
448
449    #[test]
450    fn connector_refs_carries_the_owning_workflow_id() {
451        let workflow = wf(HTTP);
452        assert!(workflow.connector_refs().all(|r| r.workflow_id == "w"));
453
454        // Including the empty-id case from `Workflow::new()`.
455        let empty = Workflow::new();
456        assert_eq!(empty.id, "");
457        assert_eq!(empty.connector_refs().count(), 0);
458    }
459
460    #[test]
461    fn connector_refs_is_empty_for_no_tasks() {
462        // `validate` rejects an empty task list, but `connector_refs` must not
463        // assume `validate` ran — `Workflow::new()` has empty tasks.
464        assert_eq!(Workflow::new().connector_refs().count(), 0);
465    }
466
467    #[test]
468    fn connector_refs_does_not_deduplicate() {
469        let a = r#"{ "id": "a", "name": "a", "function": {
470            "name": "http_call", "input": { "connector": "same" } } }"#;
471        let b = r#"{ "id": "b", "name": "b", "function": {
472            "name": "enrich",
473            "input": { "connector": "same", "merge_path": "data.out" } } }"#;
474        let workflow = wf(&format!("{a},{b}"));
475
476        let refs: Vec<_> = workflow.connector_refs().collect();
477        assert_eq!(refs.len(), 2, "one item per task, not a distinct set");
478        assert!(refs.iter().all(|r| r.connector.as_static() == Some("same")));
479    }
480
481    #[test]
482    fn connector_refs_works_on_an_uncompiled_workflow() {
483        // Straight from `from_json`, before any engine construction: `id_arc` and
484        // `compiled_condition` are still unset.
485        let workflow = wf(HTTP);
486        assert!(workflow.compiled_condition.is_none());
487        assert_eq!(workflow.connector_refs().count(), 1);
488    }
489
490    #[test]
491    fn connector_ref_is_copy() {
492        let workflow = wf(HTTP);
493        let r = workflow.connector_refs().next().unwrap();
494        let copied = r;
495        // Reading both without cloning only compiles if `ConnectorRef` is `Copy`.
496        assert_eq!(r.connector, copied.connector);
497        assert_eq!(r.task_id, copied.task_id);
498    }
499
500    #[test]
501    fn connector_ref_config_supports_a_cross_field_rule() {
502        // Proves `config` is load-bearing rather than decorative: read another
503        // key out of the same task's input.
504        let custom = r#"{ "id": "db", "name": "db", "function": {
505            "name": "pg_query",
506            "input": { "connector": "pg_main", "database": "orders" } } }"#;
507        let workflow = wf(custom);
508
509        let r = workflow.connector_refs().next().expect("custom connector");
510        assert_eq!(r.connector.as_static(), Some("pg_main"));
511        match r.config {
512            FunctionConfig::Custom { input, .. } => {
513                assert_eq!(
514                    input.get("database").and_then(|v| v.as_str()),
515                    Some("orders")
516                );
517            }
518            other => panic!("expected Custom, got {other:?}"),
519        }
520    }
521
522    #[test]
523    fn rollout_defaults_to_none_on_every_construction_path() {
524        assert_eq!(Workflow::new().rollout, None);
525        assert_eq!(Workflow::default().rollout, None);
526        assert_eq!(
527            Workflow::rule("r", "r", Value::Bool(true), Vec::new()).rollout,
528            None
529        );
530        assert_eq!(wf(MAP).rollout, None, "absent JSON key gives None");
531    }
532
533    // -----------------------------------------------------------------
534    // LoopConfig
535    // -----------------------------------------------------------------
536
537    /// Build a one-task workflow carrying `loop_json`, then validate it.
538    fn loop_wf(loop_json: &str) -> Result<Workflow> {
539        let workflow = Workflow::from_json(&format!(
540            r#"{{ "id": "w", "name": "w", "loop": {loop_json}, "tasks": [{MAP}] }}"#
541        ))?;
542        workflow.validate()?;
543        Ok(workflow)
544    }
545
546    #[test]
547    fn loop_config_defaults_init_zero_increment_one() {
548        let cfg = loop_wf(r#"{"max": 5}"#)
549            .expect("valid loop")
550            .loop_config
551            .expect("loop config present");
552        assert_eq!(cfg.init, 0);
553        assert_eq!(cfg.increment, 1);
554        assert_eq!(cfg.max, 5);
555        assert_eq!(cfg.counter, None);
556    }
557
558    #[test]
559    fn loop_config_is_absent_on_every_construction_path() {
560        assert!(wf(MAP).loop_config.is_none(), "absent JSON key gives None");
561        assert!(Workflow::new().loop_config.is_none());
562        assert!(Workflow::default().loop_config.is_none());
563        assert!(
564            Workflow::rule("r", "r", Value::Bool(true), Vec::new())
565                .loop_config
566                .is_none()
567        );
568    }
569
570    #[test]
571    fn loop_config_rejects_a_bound_that_could_never_run_a_sweep() {
572        // Half-open: sweeps run while `counter < max`, so max == init is zero
573        // sweeps and max < init is worse.
574        assert!(loop_wf(r#"{"max": 0}"#).is_err());
575        assert!(loop_wf(r#"{"init": 5, "max": 5}"#).is_err());
576        assert!(loop_wf(r#"{"init": 5, "max": 2}"#).is_err());
577    }
578
579    #[test]
580    fn loop_config_rejects_a_non_advancing_increment() {
581        assert!(loop_wf(r#"{"max": 5, "increment": 0}"#).is_err());
582        assert!(loop_wf(r#"{"max": 5, "increment": -1}"#).is_err());
583    }
584
585    #[test]
586    fn loop_config_rejects_an_empty_counter_path() {
587        assert!(loop_wf(r#"{"max": 5, "counter": ""}"#).is_err());
588        assert!(loop_wf(r#"{"max": 5, "counter": "a..b"}"#).is_err());
589        assert!(loop_wf(r#"{"max": 5, "counter": "a."}"#).is_err());
590    }
591
592    #[test]
593    fn loop_config_requires_max() {
594        // No default: an unbounded loop is never what the author meant, so it
595        // fails to deserialize rather than picking a bound on their behalf.
596        assert!(
597            Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {}, "tasks": [] }"#).is_err()
598        );
599    }
600
601    #[test]
602    fn loop_config_deserializes_every_combination_of_optional_fields() {
603        // `max` is the only required field; the other three are independently
604        // optional, so all eight presence combinations must land on the
605        // documented defaults for whatever is absent.
606        for (json, counter, init, increment) in [
607            (r#"{"max": 9}"#, None, 0, 1),
608            (r#"{"max": 9, "counter": "i"}"#, Some("i"), 0, 1),
609            (r#"{"max": 9, "init": 4}"#, None, 4, 1),
610            (r#"{"max": 9, "increment": 3}"#, None, 0, 3),
611            (r#"{"max": 9, "counter": "i", "init": 4}"#, Some("i"), 4, 1),
612            (
613                r#"{"max": 9, "counter": "i", "increment": 3}"#,
614                Some("i"),
615                0,
616                3,
617            ),
618            (r#"{"max": 9, "init": 4, "increment": 3}"#, None, 4, 3),
619            (
620                r#"{"max": 9, "counter": "i", "init": 4, "increment": 3}"#,
621                Some("i"),
622                4,
623                3,
624            ),
625        ] {
626            let cfg = loop_wf(json)
627                .unwrap_or_else(|e| panic!("{json} should be valid: {e}"))
628                .loop_config
629                .expect("loop config present");
630            assert_eq!(cfg.counter.as_deref(), counter, "counter for {json}");
631            assert_eq!(cfg.init, init, "init for {json}");
632            assert_eq!(cfg.increment, increment, "increment for {json}");
633            assert_eq!(cfg.max, 9, "max for {json}");
634        }
635    }
636
637    #[test]
638    fn loop_config_validation_matrix_over_init_increment_and_max() {
639        // The full accept/reject table for the three numeric fields. `max` must
640        // be strictly above `init` (half-open bound) and `increment` at least
641        // 1 (the counter must advance).
642        for (init, increment, max, valid) in [
643            // Ordinary forward ranges.
644            (0_i64, 1_i64, 1_i64, true),
645            (0, 1, 100, true),
646            (0, 7, 3, true), // one sweep, then the increment overshoots
647            (5, 1, 6, true),
648            // Negative and mixed-sign ranges are fine as long as max > init.
649            (-5, 1, 0, true),
650            (-5, 2, -4, true),
651            (-1, 1, 1, true),
652            // Empty or inverted bounds.
653            (0, 1, 0, false),
654            (5, 1, 5, false),
655            (5, 1, 4, false),
656            (0, 1, -1, false),
657            (-5, 1, -5, false),
658            // Non-advancing increments, independent of the bound.
659            (0, 0, 10, false),
660            (0, -1, 10, false),
661            (0, -100, 10, false),
662        ] {
663            let json = format!(r#"{{"init": {init}, "increment": {increment}, "max": {max}}}"#);
664            assert_eq!(
665                loop_wf(&json).is_ok(),
666                valid,
667                "init={init} increment={increment} max={max} should be {}",
668                if valid { "accepted" } else { "rejected" }
669            );
670        }
671    }
672
673    #[test]
674    fn loop_config_counter_path_matrix() {
675        // Accepted and rejected counter spellings, including the `#` escape the
676        // rest of the path vocabulary uses for numerically-named keys.
677        for (counter, valid) in [
678            ("i", true),
679            ("index", true),
680            ("cursor.index", true),
681            ("a.b.c.d", true),
682            ("#7", true), // escaped numeric object key, same as elsewhere
683            ("", false),
684            (".", false),
685            ("a.", false),
686            (".a", false),
687            ("a..b", false),
688        ] {
689            let json = format!(r#"{{"max": 5, "counter": "{counter}"}}"#);
690            assert_eq!(
691                loop_wf(&json).is_ok(),
692                valid,
693                "counter {counter:?} should be {}",
694                if valid { "accepted" } else { "rejected" }
695            );
696        }
697    }
698
699    #[test]
700    fn precompute_counter_path_matrix() {
701        for (counter, expected) in [
702            ("i", vec!["temp_data", "i"]),
703            ("cursor.index", vec!["temp_data", "cursor", "index"]),
704            ("a.b.c", vec!["temp_data", "a", "b", "c"]),
705            // The `#` prefix is preserved here and stripped at write time,
706            // exactly as `MapMapping::path_parts` treats it.
707            ("#7", vec!["temp_data", "#7"]),
708        ] {
709            let mut cfg = loop_wf(&format!(r#"{{"max": 5, "counter": "{counter}"}}"#))
710                .expect("valid loop")
711                .loop_config
712                .expect("loop config present");
713            cfg.precompute_counter_path();
714            let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
715            assert_eq!(parts, expected, "for counter {counter:?}");
716        }
717    }
718
719    #[test]
720    fn precompute_counter_path_is_idempotent() {
721        // The compiler runs once, but a hot reload recompiles the same config;
722        // calling twice must not accumulate segments.
723        let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
724            .expect("valid loop")
725            .loop_config
726            .expect("loop config present");
727        cfg.precompute_counter_path();
728        let first: Vec<Arc<str>> = cfg.counter_parts.to_vec();
729        cfg.precompute_counter_path();
730        assert_eq!(cfg.counter_parts.to_vec(), first);
731    }
732
733    #[test]
734    fn loop_config_rejects_a_non_object_and_a_non_numeric_max() {
735        for json in [r#""five""#, "5", "[]", r#"{"max": "5"}"#, "true"] {
736            assert!(loop_wf(json).is_err(), "{json} is not a valid loop config");
737        }
738    }
739
740    #[test]
741    fn an_explicit_null_loop_means_no_loop() {
742        // `Option<LoopConfig>` takes an explicit JSON null as absence, so a
743        // caller emitting `"loop": null` for "no loop" gets the single-pass
744        // workflow they meant rather than a deserialization error.
745        let workflow = loop_wf("null").expect("explicit null should be accepted");
746        assert!(workflow.loop_config.is_none());
747    }
748
749    #[test]
750    fn a_workflow_with_a_loop_still_validates_its_other_rules() {
751        // Loop validation is additive: the pre-existing rules still fire, and
752        // an otherwise-invalid workflow is not rescued by a valid loop.
753        let duplicate_tasks = Workflow::from_json(
754            r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [
755                 {"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}},
756                 {"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}}] }"#,
757        )
758        .expect("should parse");
759        assert!(duplicate_tasks.validate().is_err(), "duplicate task ids");
760
761        let no_tasks =
762            Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [] }"#)
763                .expect("should parse");
764        assert!(no_tasks.validate().is_err(), "empty task list");
765    }
766
767    #[test]
768    fn loop_config_coexists_with_every_other_workflow_field() {
769        // `loop` is orthogonal to the rest of the schema — nothing it adds
770        // shadows or disturbs a neighbouring field.
771        let workflow = Workflow::from_json(&format!(
772            r#"{{ "id": "w", "name": "w", "priority": 7, "description": "d",
773                  "condition": {{"==": [1, 1]}},
774                  "loop": {{"counter": "i", "max": 5}},
775                  "continue_on_error": true, "channel": "c", "version": 3,
776                  "status": "paused",
777                  "rollout": {{"bucket_start": 0, "bucket_end": 50}},
778                  "tags": ["x"], "tasks": [{MAP}] }}"#
779        ))
780        .expect("should parse");
781        workflow.validate().expect("should validate");
782
783        assert_eq!(workflow.priority, 7);
784        assert_eq!(workflow.channel, "c");
785        assert_eq!(workflow.version, 3);
786        assert_eq!(workflow.status, WorkflowStatus::Paused);
787        assert!(workflow.continue_on_error);
788        assert_eq!(
789            workflow.rollout,
790            Some(Rollout {
791                bucket_start: 0,
792                bucket_end: 50
793            })
794        );
795        assert_eq!(workflow.tags, ["x"]);
796        assert_eq!(
797            workflow
798                .loop_config
799                .expect("loop present")
800                .counter
801                .as_deref(),
802            Some("i")
803        );
804    }
805
806    #[test]
807    fn loop_config_accepts_a_valid_counter() {
808        let cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
809            .expect("valid loop")
810            .loop_config
811            .expect("loop config present");
812        assert_eq!(cfg.counter.as_deref(), Some("cursor.index"));
813    }
814
815    #[test]
816    fn precompute_counter_path_prefixes_temp_data() {
817        let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
818            .expect("valid loop")
819            .loop_config
820            .expect("loop config present");
821        assert!(
822            cfg.counter_parts.is_empty(),
823            "uncompiled workflows start with no pre-split path"
824        );
825
826        cfg.precompute_counter_path();
827
828        let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
829        assert_eq!(parts, ["temp_data", "cursor", "index"]);
830    }
831
832    #[test]
833    fn precompute_counter_path_is_empty_without_a_counter_name() {
834        let mut cfg = loop_wf(r#"{"max": 5}"#)
835            .expect("valid loop")
836            .loop_config
837            .expect("loop config present");
838        cfg.precompute_counter_path();
839        assert!(cfg.counter_parts.is_empty());
840    }
841
842    #[test]
843    fn rollout_deserializes_from_json() {
844        let workflow = Workflow::from_json(
845            r#"{ "id": "w", "name": "w", "condition": true,
846                 "rollout": { "bucket_start": 0, "bucket_end": 50 },
847                 "tasks": [] }"#,
848        )
849        .unwrap();
850        assert_eq!(
851            workflow.rollout,
852            Some(Rollout {
853                bucket_start: 0,
854                bucket_end: 50
855            })
856        );
857    }
858}