Skip to main content

dataflow_rs/engine/
workflow.rs

1use crate::engine::error::{DataflowError, Result};
2use crate::engine::functions::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`](crate::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        Workflow {
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        Workflow {
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, exactly as authored.
379    pub connector: &'a str,
380    /// The whole function config, for cross-field rules.
381    pub config: &'a FunctionConfig,
382}
383
384impl Workflow {
385    /// Every connector reference in this workflow, in task order.
386    ///
387    /// Tasks whose function names no connector are skipped. One item is yielded
388    /// per *task*, not per distinct connector: two tasks on the same connector
389    /// yield two items. Callers wanting a distinct set collect one themselves.
390    ///
391    /// Does not require a compiled workflow — this reads only deserialized
392    /// fields, so it works on the output of [`Workflow::from_json`] before the
393    /// engine has compiled it.
394    ///
395    /// Which configs carry a connector is this crate's fact; deriving it here
396    /// rather than reimplementing the set downstream is the point.
397    pub fn connector_refs(&self) -> impl Iterator<Item = ConnectorRef<'_>> {
398        // `move` is load-bearing: it copies the `&Workflow` into the closure so
399        // the returned iterator does not borrow a local.
400        self.tasks.iter().filter_map(move |task| {
401            task.function.connector().map(|connector| ConnectorRef {
402                workflow_id: &self.id,
403                task_id: &task.id,
404                function: task.function.function_name(),
405                connector,
406                config: &task.function,
407            })
408        })
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    fn wf(tasks_json: &str) -> Workflow {
417        Workflow::from_json(&format!(
418            r#"{{ "id": "w", "name": "w", "priority": 0, "condition": true,
419                  "tasks": [{tasks_json}] }}"#
420        ))
421        .expect("workflow should parse")
422    }
423
424    const HTTP: &str = r#"{ "id": "call", "name": "call", "function": {
425        "name": "http_call", "input": { "connector": "user_service" } } }"#;
426    const KAFKA: &str = r#"{ "id": "pub", "name": "pub", "function": {
427        "name": "publish_kafka",
428        "input": { "connector": "events", "topic": "t" } } }"#;
429    const MAP: &str = r#"{ "id": "m", "name": "m", "function": {
430        "name": "map", "input": { "mappings": [] } } }"#;
431    const LOG: &str = r#"{ "id": "l", "name": "l", "function": {
432        "name": "log", "input": { "message": "hi" } } }"#;
433
434    #[test]
435    fn connector_refs_yields_only_connector_tasks_in_task_order() {
436        let workflow = wf(&format!("{MAP},{HTTP},{LOG},{KAFKA}"));
437        let refs: Vec<_> = workflow.connector_refs().collect();
438
439        assert_eq!(refs.len(), 2);
440        assert_eq!(refs[0].task_id, "call");
441        assert_eq!(refs[0].function, "http_call");
442        assert_eq!(refs[0].connector, "user_service");
443        assert_eq!(refs[1].task_id, "pub");
444        assert_eq!(refs[1].function, "publish_kafka");
445        assert_eq!(refs[1].connector, "events");
446    }
447
448    #[test]
449    fn connector_refs_carries_the_owning_workflow_id() {
450        let workflow = wf(HTTP);
451        assert!(workflow.connector_refs().all(|r| r.workflow_id == "w"));
452
453        // Including the empty-id case from `Workflow::new()`.
454        let empty = Workflow::new();
455        assert_eq!(empty.id, "");
456        assert_eq!(empty.connector_refs().count(), 0);
457    }
458
459    #[test]
460    fn connector_refs_is_empty_for_no_tasks() {
461        // `validate` rejects an empty task list, but `connector_refs` must not
462        // assume `validate` ran — `Workflow::new()` has empty tasks.
463        assert_eq!(Workflow::new().connector_refs().count(), 0);
464    }
465
466    #[test]
467    fn connector_refs_does_not_deduplicate() {
468        let a = r#"{ "id": "a", "name": "a", "function": {
469            "name": "http_call", "input": { "connector": "same" } } }"#;
470        let b = r#"{ "id": "b", "name": "b", "function": {
471            "name": "enrich",
472            "input": { "connector": "same", "merge_path": "data.out" } } }"#;
473        let workflow = wf(&format!("{a},{b}"));
474
475        let refs: Vec<_> = workflow.connector_refs().collect();
476        assert_eq!(refs.len(), 2, "one item per task, not a distinct set");
477        assert!(refs.iter().all(|r| r.connector == "same"));
478    }
479
480    #[test]
481    fn connector_refs_works_on_an_uncompiled_workflow() {
482        // Straight from `from_json`, before any engine construction: `id_arc` and
483        // `compiled_condition` are still unset.
484        let workflow = wf(HTTP);
485        assert!(workflow.compiled_condition.is_none());
486        assert_eq!(workflow.connector_refs().count(), 1);
487    }
488
489    #[test]
490    fn connector_ref_is_copy() {
491        let workflow = wf(HTTP);
492        let r = workflow.connector_refs().next().unwrap();
493        let copied = r;
494        // Reading both without cloning only compiles if `ConnectorRef` is `Copy`.
495        assert_eq!(r.connector, copied.connector);
496        assert_eq!(r.task_id, copied.task_id);
497    }
498
499    #[test]
500    fn connector_ref_config_supports_a_cross_field_rule() {
501        // Proves `config` is load-bearing rather than decorative: read another
502        // key out of the same task's input.
503        let custom = r#"{ "id": "db", "name": "db", "function": {
504            "name": "pg_query",
505            "input": { "connector": "pg_main", "database": "orders" } } }"#;
506        let workflow = wf(custom);
507
508        let r = workflow.connector_refs().next().expect("custom connector");
509        assert_eq!(r.connector, "pg_main");
510        match r.config {
511            FunctionConfig::Custom { input, .. } => {
512                assert_eq!(
513                    input.get("database").and_then(|v| v.as_str()),
514                    Some("orders")
515                );
516            }
517            other => panic!("expected Custom, got {other:?}"),
518        }
519    }
520
521    #[test]
522    fn rollout_defaults_to_none_on_every_construction_path() {
523        assert_eq!(Workflow::new().rollout, None);
524        assert_eq!(Workflow::default().rollout, None);
525        assert_eq!(
526            Workflow::rule("r", "r", Value::Bool(true), Vec::new()).rollout,
527            None
528        );
529        assert_eq!(wf(MAP).rollout, None, "absent JSON key gives None");
530    }
531
532    // -----------------------------------------------------------------
533    // LoopConfig
534    // -----------------------------------------------------------------
535
536    /// Build a one-task workflow carrying `loop_json`, then validate it.
537    fn loop_wf(loop_json: &str) -> Result<Workflow> {
538        let workflow = Workflow::from_json(&format!(
539            r#"{{ "id": "w", "name": "w", "loop": {loop_json}, "tasks": [{MAP}] }}"#
540        ))?;
541        workflow.validate()?;
542        Ok(workflow)
543    }
544
545    #[test]
546    fn loop_config_defaults_init_zero_increment_one() {
547        let cfg = loop_wf(r#"{"max": 5}"#)
548            .expect("valid loop")
549            .loop_config
550            .expect("loop config present");
551        assert_eq!(cfg.init, 0);
552        assert_eq!(cfg.increment, 1);
553        assert_eq!(cfg.max, 5);
554        assert_eq!(cfg.counter, None);
555    }
556
557    #[test]
558    fn loop_config_is_absent_on_every_construction_path() {
559        assert!(wf(MAP).loop_config.is_none(), "absent JSON key gives None");
560        assert!(Workflow::new().loop_config.is_none());
561        assert!(Workflow::default().loop_config.is_none());
562        assert!(
563            Workflow::rule("r", "r", Value::Bool(true), Vec::new())
564                .loop_config
565                .is_none()
566        );
567    }
568
569    #[test]
570    fn loop_config_rejects_a_bound_that_could_never_run_a_sweep() {
571        // Half-open: sweeps run while `counter < max`, so max == init is zero
572        // sweeps and max < init is worse.
573        assert!(loop_wf(r#"{"max": 0}"#).is_err());
574        assert!(loop_wf(r#"{"init": 5, "max": 5}"#).is_err());
575        assert!(loop_wf(r#"{"init": 5, "max": 2}"#).is_err());
576    }
577
578    #[test]
579    fn loop_config_rejects_a_non_advancing_increment() {
580        assert!(loop_wf(r#"{"max": 5, "increment": 0}"#).is_err());
581        assert!(loop_wf(r#"{"max": 5, "increment": -1}"#).is_err());
582    }
583
584    #[test]
585    fn loop_config_rejects_an_empty_counter_path() {
586        assert!(loop_wf(r#"{"max": 5, "counter": ""}"#).is_err());
587        assert!(loop_wf(r#"{"max": 5, "counter": "a..b"}"#).is_err());
588        assert!(loop_wf(r#"{"max": 5, "counter": "a."}"#).is_err());
589    }
590
591    #[test]
592    fn loop_config_requires_max() {
593        // No default: an unbounded loop is never what the author meant, so it
594        // fails to deserialize rather than picking a bound on their behalf.
595        assert!(
596            Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {}, "tasks": [] }"#).is_err()
597        );
598    }
599
600    #[test]
601    fn loop_config_deserializes_every_combination_of_optional_fields() {
602        // `max` is the only required field; the other three are independently
603        // optional, so all eight presence combinations must land on the
604        // documented defaults for whatever is absent.
605        for (json, counter, init, increment) in [
606            (r#"{"max": 9}"#, None, 0, 1),
607            (r#"{"max": 9, "counter": "i"}"#, Some("i"), 0, 1),
608            (r#"{"max": 9, "init": 4}"#, None, 4, 1),
609            (r#"{"max": 9, "increment": 3}"#, None, 0, 3),
610            (r#"{"max": 9, "counter": "i", "init": 4}"#, Some("i"), 4, 1),
611            (
612                r#"{"max": 9, "counter": "i", "increment": 3}"#,
613                Some("i"),
614                0,
615                3,
616            ),
617            (r#"{"max": 9, "init": 4, "increment": 3}"#, None, 4, 3),
618            (
619                r#"{"max": 9, "counter": "i", "init": 4, "increment": 3}"#,
620                Some("i"),
621                4,
622                3,
623            ),
624        ] {
625            let cfg = loop_wf(json)
626                .unwrap_or_else(|e| panic!("{json} should be valid: {e}"))
627                .loop_config
628                .expect("loop config present");
629            assert_eq!(cfg.counter.as_deref(), counter, "counter for {json}");
630            assert_eq!(cfg.init, init, "init for {json}");
631            assert_eq!(cfg.increment, increment, "increment for {json}");
632            assert_eq!(cfg.max, 9, "max for {json}");
633        }
634    }
635
636    #[test]
637    fn loop_config_validation_matrix_over_init_increment_and_max() {
638        // The full accept/reject table for the three numeric fields. `max` must
639        // be strictly above `init` (half-open bound) and `increment` at least
640        // 1 (the counter must advance).
641        for (init, increment, max, valid) in [
642            // Ordinary forward ranges.
643            (0_i64, 1_i64, 1_i64, true),
644            (0, 1, 100, true),
645            (0, 7, 3, true), // one sweep, then the increment overshoots
646            (5, 1, 6, true),
647            // Negative and mixed-sign ranges are fine as long as max > init.
648            (-5, 1, 0, true),
649            (-5, 2, -4, true),
650            (-1, 1, 1, true),
651            // Empty or inverted bounds.
652            (0, 1, 0, false),
653            (5, 1, 5, false),
654            (5, 1, 4, false),
655            (0, 1, -1, false),
656            (-5, 1, -5, false),
657            // Non-advancing increments, independent of the bound.
658            (0, 0, 10, false),
659            (0, -1, 10, false),
660            (0, -100, 10, false),
661        ] {
662            let json = format!(r#"{{"init": {init}, "increment": {increment}, "max": {max}}}"#);
663            assert_eq!(
664                loop_wf(&json).is_ok(),
665                valid,
666                "init={init} increment={increment} max={max} should be {}",
667                if valid { "accepted" } else { "rejected" }
668            );
669        }
670    }
671
672    #[test]
673    fn loop_config_counter_path_matrix() {
674        // Accepted and rejected counter spellings, including the `#` escape the
675        // rest of the path vocabulary uses for numerically-named keys.
676        for (counter, valid) in [
677            ("i", true),
678            ("index", true),
679            ("cursor.index", true),
680            ("a.b.c.d", true),
681            ("#7", true), // escaped numeric object key, same as elsewhere
682            ("", false),
683            (".", false),
684            ("a.", false),
685            (".a", false),
686            ("a..b", false),
687        ] {
688            let json = format!(r#"{{"max": 5, "counter": "{counter}"}}"#);
689            assert_eq!(
690                loop_wf(&json).is_ok(),
691                valid,
692                "counter {counter:?} should be {}",
693                if valid { "accepted" } else { "rejected" }
694            );
695        }
696    }
697
698    #[test]
699    fn precompute_counter_path_matrix() {
700        for (counter, expected) in [
701            ("i", vec!["temp_data", "i"]),
702            ("cursor.index", vec!["temp_data", "cursor", "index"]),
703            ("a.b.c", vec!["temp_data", "a", "b", "c"]),
704            // The `#` prefix is preserved here and stripped at write time,
705            // exactly as `MapMapping::path_parts` treats it.
706            ("#7", vec!["temp_data", "#7"]),
707        ] {
708            let mut cfg = loop_wf(&format!(r#"{{"max": 5, "counter": "{counter}"}}"#))
709                .expect("valid loop")
710                .loop_config
711                .expect("loop config present");
712            cfg.precompute_counter_path();
713            let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
714            assert_eq!(parts, expected, "for counter {counter:?}");
715        }
716    }
717
718    #[test]
719    fn precompute_counter_path_is_idempotent() {
720        // The compiler runs once, but a hot reload recompiles the same config;
721        // calling twice must not accumulate segments.
722        let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
723            .expect("valid loop")
724            .loop_config
725            .expect("loop config present");
726        cfg.precompute_counter_path();
727        let first: Vec<Arc<str>> = cfg.counter_parts.to_vec();
728        cfg.precompute_counter_path();
729        assert_eq!(cfg.counter_parts.to_vec(), first);
730    }
731
732    #[test]
733    fn loop_config_rejects_a_non_object_and_a_non_numeric_max() {
734        for json in [r#""five""#, "5", "[]", r#"{"max": "5"}"#, "true"] {
735            assert!(loop_wf(json).is_err(), "{json} is not a valid loop config");
736        }
737    }
738
739    #[test]
740    fn an_explicit_null_loop_means_no_loop() {
741        // `Option<LoopConfig>` takes an explicit JSON null as absence, so a
742        // caller emitting `"loop": null` for "no loop" gets the single-pass
743        // workflow they meant rather than a deserialization error.
744        let workflow = loop_wf("null").expect("explicit null should be accepted");
745        assert!(workflow.loop_config.is_none());
746    }
747
748    #[test]
749    fn a_workflow_with_a_loop_still_validates_its_other_rules() {
750        // Loop validation is additive: the pre-existing rules still fire, and
751        // an otherwise-invalid workflow is not rescued by a valid loop.
752        let duplicate_tasks = Workflow::from_json(
753            r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [
754                 {"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}},
755                 {"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}}] }"#,
756        )
757        .expect("should parse");
758        assert!(duplicate_tasks.validate().is_err(), "duplicate task ids");
759
760        let no_tasks =
761            Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [] }"#)
762                .expect("should parse");
763        assert!(no_tasks.validate().is_err(), "empty task list");
764    }
765
766    #[test]
767    fn loop_config_coexists_with_every_other_workflow_field() {
768        // `loop` is orthogonal to the rest of the schema — nothing it adds
769        // shadows or disturbs a neighbouring field.
770        let workflow = Workflow::from_json(&format!(
771            r#"{{ "id": "w", "name": "w", "priority": 7, "description": "d",
772                  "condition": {{"==": [1, 1]}},
773                  "loop": {{"counter": "i", "max": 5}},
774                  "continue_on_error": true, "channel": "c", "version": 3,
775                  "status": "paused",
776                  "rollout": {{"bucket_start": 0, "bucket_end": 50}},
777                  "tags": ["x"], "tasks": [{MAP}] }}"#
778        ))
779        .expect("should parse");
780        workflow.validate().expect("should validate");
781
782        assert_eq!(workflow.priority, 7);
783        assert_eq!(workflow.channel, "c");
784        assert_eq!(workflow.version, 3);
785        assert_eq!(workflow.status, WorkflowStatus::Paused);
786        assert!(workflow.continue_on_error);
787        assert_eq!(
788            workflow.rollout,
789            Some(Rollout {
790                bucket_start: 0,
791                bucket_end: 50
792            })
793        );
794        assert_eq!(workflow.tags, ["x"]);
795        assert_eq!(
796            workflow
797                .loop_config
798                .expect("loop present")
799                .counter
800                .as_deref(),
801            Some("i")
802        );
803    }
804
805    #[test]
806    fn loop_config_accepts_a_valid_counter() {
807        let cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
808            .expect("valid loop")
809            .loop_config
810            .expect("loop config present");
811        assert_eq!(cfg.counter.as_deref(), Some("cursor.index"));
812    }
813
814    #[test]
815    fn precompute_counter_path_prefixes_temp_data() {
816        let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
817            .expect("valid loop")
818            .loop_config
819            .expect("loop config present");
820        assert!(
821            cfg.counter_parts.is_empty(),
822            "uncompiled workflows start with no pre-split path"
823        );
824
825        cfg.precompute_counter_path();
826
827        let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
828        assert_eq!(parts, ["temp_data", "cursor", "index"]);
829    }
830
831    #[test]
832    fn precompute_counter_path_is_empty_without_a_counter_name() {
833        let mut cfg = loop_wf(r#"{"max": 5}"#)
834            .expect("valid loop")
835            .loop_config
836            .expect("loop config present");
837        cfg.precompute_counter_path();
838        assert!(cfg.counter_parts.is_empty());
839    }
840
841    #[test]
842    fn rollout_deserializes_from_json() {
843        let workflow = Workflow::from_json(
844            r#"{ "id": "w", "name": "w", "condition": true,
845                 "rollout": { "bucket_start": 0, "bucket_end": 50 },
846                 "tasks": [] }"#,
847        )
848        .unwrap();
849        assert_eq!(
850            workflow.rollout,
851            Some(Rollout {
852                bucket_start: 0,
853                bucket_end: 50
854            })
855        );
856    }
857}