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