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    /// The workflow's steps, flattened.
193    ///
194    /// The JSON `tasks` array holds *steps*: an element carrying a `tasks` key
195    /// is a [`TaskGroup`](crate::TaskGroup), anything else is a [`Task`]. The
196    /// parser flattens the
197    /// tree in document order and records each group's span on the task that
198    /// opens it ([`Task::group_starts`]), so this stays a flat list and the
199    /// executor keeps walking `&[Task]` slices.
200    #[serde(deserialize_with = "crate::engine::task::steps::flatten")]
201    pub tasks: Vec<Task>,
202    #[serde(default)]
203    pub continue_on_error: bool,
204    /// Channel for routing (default: "default")
205    #[serde(default = "default_channel")]
206    pub channel: String,
207    /// Version number for rule versioning (default: 1)
208    #[serde(default = "default_version")]
209    pub version: u32,
210    /// Workflow status — Active, Paused, or Archived (default: Active)
211    #[serde(default)]
212    pub status: WorkflowStatus,
213    /// Traffic split for this workflow. `None` (the default) means the workflow
214    /// is not part of a split and runs for every message.
215    ///
216    /// A workflow with a rollout is skipped when the message's
217    /// [`crate::Message::routing_bucket`] falls outside the range. A message with
218    /// **no** bucket is admitted — see [`Rollout`].
219    #[serde(default)]
220    pub rollout: Option<Rollout>,
221    /// Engine-managed loop over this workflow's task list. `None` (the
222    /// default) runs the task list exactly once — the historical behaviour, on
223    /// a code path that carries no loop overhead.
224    ///
225    /// See [`LoopConfig`] for the per-sweep contract.
226    #[serde(default, rename = "loop")]
227    pub loop_config: Option<LoopConfig>,
228    /// Tags for categorization and filtering
229    #[serde(default)]
230    pub tags: Vec<String>,
231    /// Creation timestamp
232    #[serde(default)]
233    pub created_at: Option<DateTime<Utc>>,
234    /// Last update timestamp
235    #[serde(default)]
236    pub updated_at: Option<DateTime<Utc>>,
237}
238
239fn default_channel() -> String {
240    "default".to_string()
241}
242
243fn default_version() -> u32 {
244    1
245}
246
247impl Default for Workflow {
248    fn default() -> Self {
249        Self::new()
250    }
251}
252
253impl Workflow {
254    pub fn new() -> Self {
255        Workflow {
256            id: String::new(),
257            id_arc: Arc::from(""),
258            name: String::new(),
259            priority: 0,
260            description: None,
261            condition: Value::Bool(true),
262            compiled_condition: None,
263            fully_sync: false,
264            tasks: Vec::new(),
265            continue_on_error: false,
266            channel: default_channel(),
267            version: 1,
268            status: WorkflowStatus::Active,
269            rollout: None,
270            loop_config: None,
271            tags: Vec::new(),
272            created_at: None,
273            updated_at: None,
274        }
275    }
276
277    /// Create a workflow (rule) with a condition and tasks.
278    ///
279    /// This is a convenience constructor for the IFTTT-style rules engine pattern:
280    /// **IF** `condition` **THEN** execute `tasks`.
281    ///
282    /// # Arguments
283    /// * `id` - Unique identifier for the rule
284    /// * `name` - Human-readable name
285    /// * `condition` - JSONLogic condition evaluated against the full context (data, metadata, temp_data)
286    /// * `tasks` - Actions to execute when the condition is met
287    pub fn rule(id: &str, name: &str, condition: Value, tasks: Vec<Task>) -> Self {
288        Workflow {
289            id: id.to_string(),
290            id_arc: Arc::from(id),
291            name: name.to_string(),
292            priority: 0,
293            description: None,
294            condition,
295            compiled_condition: None,
296            fully_sync: false,
297            tasks,
298            continue_on_error: false,
299            channel: default_channel(),
300            version: 1,
301            status: WorkflowStatus::Active,
302            rollout: None,
303            loop_config: None,
304            tags: Vec::new(),
305            created_at: None,
306            updated_at: None,
307        }
308    }
309
310    /// Load workflow from JSON string
311    pub fn from_json(json_str: &str) -> Result<Self> {
312        serde_json::from_str(json_str).map_err(DataflowError::from_serde)
313    }
314
315    /// Load workflow from JSON file
316    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
317        let json_str = fs::read_to_string(path).map_err(DataflowError::from_io)?;
318
319        Self::from_json(&json_str)
320    }
321
322    /// Validate the workflow structure
323    pub fn validate(&self) -> Result<()> {
324        // Check required fields
325        if self.id.is_empty() {
326            return Err(DataflowError::Workflow(
327                "Workflow id cannot be empty".to_string(),
328            ));
329        }
330
331        if self.name.is_empty() {
332            return Err(DataflowError::Workflow(
333                "Workflow name cannot be empty".to_string(),
334            ));
335        }
336
337        // Check tasks
338        if self.tasks.is_empty() {
339            return Err(DataflowError::Workflow(
340                "Workflow must have at least one task".to_string(),
341            ));
342        }
343
344        // Validate that task and group IDs are unique. Groups share the task
345        // id namespace: both name a step, both surface in traces and error
346        // messages, and a collision would make either ambiguous.
347        let mut step_ids = std::collections::HashSet::new();
348        for task in &self.tasks {
349            for group in &task.group_starts {
350                if !step_ids.insert(group.id.as_str()) {
351                    return Err(DataflowError::Workflow(format!(
352                        "Duplicate step ID '{}' in workflow — task group IDs share the task ID namespace",
353                        group.id
354                    )));
355                }
356            }
357            if !step_ids.insert(task.id.as_str()) {
358                return Err(DataflowError::Workflow(format!(
359                    "Duplicate task ID '{}' in workflow",
360                    task.id
361                )));
362            }
363        }
364
365        // A loop whose bounds could never advance is rejected at build time
366        // rather than spinning — or silently doing nothing — on the first
367        // message.
368        if let Some(loop_config) = &self.loop_config {
369            loop_config.validate(&self.id)?;
370        }
371
372        Ok(())
373    }
374}
375
376/// One task's connector reference, located within a workflow.
377///
378/// `Copy`: every field is a shared borrow. `config` is carried so callers can
379/// apply cross-field rules — "a task on this kind of connector also needs
380/// `input.database`" — without re-parsing the task.
381///
382/// Not `Serialize`: [`FunctionConfig`] is deserialize-only, so callers that emit
383/// JSON diagnostics build their own shape from these fields.
384#[derive(Debug, Clone, Copy)]
385pub struct ConnectorRef<'a> {
386    /// `id` of the owning workflow.
387    pub workflow_id: &'a str,
388    /// `id` of the referencing task.
389    pub task_id: &'a str,
390    /// Canonical function name, as [`FunctionConfig::function_name`].
391    pub function: &'a str,
392    /// The connector name, exactly as authored.
393    pub connector: &'a str,
394    /// The whole function config, for cross-field rules.
395    pub config: &'a FunctionConfig,
396}
397
398impl Workflow {
399    /// Every connector reference in this workflow, in task order.
400    ///
401    /// Tasks whose function names no connector are skipped. One item is yielded
402    /// per *task*, not per distinct connector: two tasks on the same connector
403    /// yield two items. Callers wanting a distinct set collect one themselves.
404    ///
405    /// Does not require a compiled workflow — this reads only deserialized
406    /// fields, so it works on the output of [`Workflow::from_json`] before the
407    /// engine has compiled it.
408    ///
409    /// Which configs carry a connector is this crate's fact; deriving it here
410    /// rather than reimplementing the set downstream is the point.
411    pub fn connector_refs(&self) -> impl Iterator<Item = ConnectorRef<'_>> {
412        // `move` is load-bearing: it copies the `&Workflow` into the closure so
413        // the returned iterator does not borrow a local.
414        self.tasks.iter().filter_map(move |task| {
415            task.function.connector().map(|connector| ConnectorRef {
416                workflow_id: &self.id,
417                task_id: &task.id,
418                function: task.function.function_name(),
419                connector,
420                config: &task.function,
421            })
422        })
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    fn wf(tasks_json: &str) -> Workflow {
431        Workflow::from_json(&format!(
432            r#"{{ "id": "w", "name": "w", "priority": 0, "condition": true,
433                  "tasks": [{tasks_json}] }}"#
434        ))
435        .expect("workflow should parse")
436    }
437
438    const HTTP: &str = r#"{ "id": "call", "name": "call", "function": {
439        "name": "http_call", "input": { "connector": "user_service" } } }"#;
440    const KAFKA: &str = r#"{ "id": "pub", "name": "pub", "function": {
441        "name": "publish_kafka",
442        "input": { "connector": "events", "topic": "t" } } }"#;
443    const MAP: &str = r#"{ "id": "m", "name": "m", "function": {
444        "name": "map", "input": { "mappings": [] } } }"#;
445    const LOG: &str = r#"{ "id": "l", "name": "l", "function": {
446        "name": "log", "input": { "message": "hi" } } }"#;
447
448    #[test]
449    fn connector_refs_yields_only_connector_tasks_in_task_order() {
450        let workflow = wf(&format!("{MAP},{HTTP},{LOG},{KAFKA}"));
451        let refs: Vec<_> = workflow.connector_refs().collect();
452
453        assert_eq!(refs.len(), 2);
454        assert_eq!(refs[0].task_id, "call");
455        assert_eq!(refs[0].function, "http_call");
456        assert_eq!(refs[0].connector, "user_service");
457        assert_eq!(refs[1].task_id, "pub");
458        assert_eq!(refs[1].function, "publish_kafka");
459        assert_eq!(refs[1].connector, "events");
460    }
461
462    #[test]
463    fn connector_refs_carries_the_owning_workflow_id() {
464        let workflow = wf(HTTP);
465        assert!(workflow.connector_refs().all(|r| r.workflow_id == "w"));
466
467        // Including the empty-id case from `Workflow::new()`.
468        let empty = Workflow::new();
469        assert_eq!(empty.id, "");
470        assert_eq!(empty.connector_refs().count(), 0);
471    }
472
473    #[test]
474    fn connector_refs_is_empty_for_no_tasks() {
475        // `validate` rejects an empty task list, but `connector_refs` must not
476        // assume `validate` ran — `Workflow::new()` has empty tasks.
477        assert_eq!(Workflow::new().connector_refs().count(), 0);
478    }
479
480    #[test]
481    fn connector_refs_does_not_deduplicate() {
482        let a = r#"{ "id": "a", "name": "a", "function": {
483            "name": "http_call", "input": { "connector": "same" } } }"#;
484        let b = r#"{ "id": "b", "name": "b", "function": {
485            "name": "enrich",
486            "input": { "connector": "same", "merge_path": "data.out" } } }"#;
487        let workflow = wf(&format!("{a},{b}"));
488
489        let refs: Vec<_> = workflow.connector_refs().collect();
490        assert_eq!(refs.len(), 2, "one item per task, not a distinct set");
491        assert!(refs.iter().all(|r| r.connector == "same"));
492    }
493
494    #[test]
495    fn connector_refs_works_on_an_uncompiled_workflow() {
496        // Straight from `from_json`, before any engine construction: `id_arc` and
497        // `compiled_condition` are still unset.
498        let workflow = wf(HTTP);
499        assert!(workflow.compiled_condition.is_none());
500        assert_eq!(workflow.connector_refs().count(), 1);
501    }
502
503    #[test]
504    fn connector_ref_is_copy() {
505        let workflow = wf(HTTP);
506        let r = workflow.connector_refs().next().unwrap();
507        let copied = r;
508        // Reading both without cloning only compiles if `ConnectorRef` is `Copy`.
509        assert_eq!(r.connector, copied.connector);
510        assert_eq!(r.task_id, copied.task_id);
511    }
512
513    #[test]
514    fn connector_ref_config_supports_a_cross_field_rule() {
515        // Proves `config` is load-bearing rather than decorative: read another
516        // key out of the same task's input.
517        let custom = r#"{ "id": "db", "name": "db", "function": {
518            "name": "pg_query",
519            "input": { "connector": "pg_main", "database": "orders" } } }"#;
520        let workflow = wf(custom);
521
522        let r = workflow.connector_refs().next().expect("custom connector");
523        assert_eq!(r.connector, "pg_main");
524        match r.config {
525            FunctionConfig::Custom { input, .. } => {
526                assert_eq!(
527                    input.get("database").and_then(|v| v.as_str()),
528                    Some("orders")
529                );
530            }
531            other => panic!("expected Custom, got {other:?}"),
532        }
533    }
534
535    #[test]
536    fn rollout_accepts_is_a_half_open_range() {
537        let all = Rollout {
538            bucket_start: 0,
539            bucket_end: 100,
540        };
541        assert!(all.accepts(0));
542        assert!(all.accepts(99));
543
544        let lower = Rollout {
545            bucket_start: 0,
546            bucket_end: 50,
547        };
548        assert!(lower.accepts(0));
549        assert!(lower.accepts(49));
550        assert!(!lower.accepts(50), "bucket_end is exclusive");
551        assert!(!lower.accepts(99));
552
553        // `start` inclusive, `end` exclusive — boundary exactness.
554        let upper = Rollout {
555            bucket_start: 50,
556            bucket_end: 100,
557        };
558        assert!(upper.accepts(50), "bucket_start is inclusive");
559        assert!(upper.accepts(99));
560        assert!(!upper.accepts(49));
561
562        // The two halves partition 0..=99 exactly.
563        for b in 0u8..=99 {
564            assert_ne!(
565                lower.accepts(b),
566                upper.accepts(b),
567                "bucket {b} must be served by exactly one half"
568            );
569        }
570    }
571
572    #[test]
573    fn rollout_empty_and_inverted_ranges_accept_nothing() {
574        let empty = Rollout {
575            bucket_start: 50,
576            bucket_end: 50,
577        };
578        let inverted = Rollout {
579            bucket_start: 60,
580            bucket_end: 20,
581        };
582        for b in 0u8..=99 {
583            assert!(!empty.accepts(b), "empty range accepted {b}");
584            assert!(!inverted.accepts(b), "inverted range accepted {b}");
585        }
586    }
587
588    #[test]
589    fn rollout_end_of_100_is_representable_without_overflow() {
590        // `bucket_end = 100` fits a u8 and `accepts` does no arithmetic on it.
591        let r = Rollout {
592            bucket_start: 99,
593            bucket_end: 100,
594        };
595        assert!(r.accepts(99));
596        assert!(!r.accepts(98));
597    }
598
599    #[test]
600    fn rollout_defaults_to_none_on_every_construction_path() {
601        assert_eq!(Workflow::new().rollout, None);
602        assert_eq!(Workflow::default().rollout, None);
603        assert_eq!(
604            Workflow::rule("r", "r", Value::Bool(true), Vec::new()).rollout,
605            None
606        );
607        assert_eq!(wf(MAP).rollout, None, "absent JSON key gives None");
608    }
609
610    // -----------------------------------------------------------------
611    // LoopConfig
612    // -----------------------------------------------------------------
613
614    /// Build a one-task workflow carrying `loop_json`, then validate it.
615    fn loop_wf(loop_json: &str) -> Result<Workflow> {
616        let workflow = Workflow::from_json(&format!(
617            r#"{{ "id": "w", "name": "w", "loop": {loop_json}, "tasks": [{MAP}] }}"#
618        ))?;
619        workflow.validate()?;
620        Ok(workflow)
621    }
622
623    #[test]
624    fn loop_config_defaults_init_zero_increment_one() {
625        let cfg = loop_wf(r#"{"max": 5}"#)
626            .expect("valid loop")
627            .loop_config
628            .expect("loop config present");
629        assert_eq!(cfg.init, 0);
630        assert_eq!(cfg.increment, 1);
631        assert_eq!(cfg.max, 5);
632        assert_eq!(cfg.counter, None);
633    }
634
635    #[test]
636    fn loop_config_is_absent_on_every_construction_path() {
637        assert!(wf(MAP).loop_config.is_none(), "absent JSON key gives None");
638        assert!(Workflow::new().loop_config.is_none());
639        assert!(Workflow::default().loop_config.is_none());
640        assert!(
641            Workflow::rule("r", "r", Value::Bool(true), Vec::new())
642                .loop_config
643                .is_none()
644        );
645    }
646
647    #[test]
648    fn loop_config_rejects_a_bound_that_could_never_run_a_sweep() {
649        // Half-open: sweeps run while `counter < max`, so max == init is zero
650        // sweeps and max < init is worse.
651        assert!(loop_wf(r#"{"max": 0}"#).is_err());
652        assert!(loop_wf(r#"{"init": 5, "max": 5}"#).is_err());
653        assert!(loop_wf(r#"{"init": 5, "max": 2}"#).is_err());
654    }
655
656    #[test]
657    fn loop_config_rejects_a_non_advancing_increment() {
658        assert!(loop_wf(r#"{"max": 5, "increment": 0}"#).is_err());
659        assert!(loop_wf(r#"{"max": 5, "increment": -1}"#).is_err());
660    }
661
662    #[test]
663    fn loop_config_rejects_an_empty_counter_path() {
664        assert!(loop_wf(r#"{"max": 5, "counter": ""}"#).is_err());
665        assert!(loop_wf(r#"{"max": 5, "counter": "a..b"}"#).is_err());
666        assert!(loop_wf(r#"{"max": 5, "counter": "a."}"#).is_err());
667    }
668
669    #[test]
670    fn loop_config_requires_max() {
671        // No default: an unbounded loop is never what the author meant, so it
672        // fails to deserialize rather than picking a bound on their behalf.
673        assert!(
674            Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {}, "tasks": [] }"#).is_err()
675        );
676    }
677
678    #[test]
679    fn loop_config_deserializes_every_combination_of_optional_fields() {
680        // `max` is the only required field; the other three are independently
681        // optional, so all eight presence combinations must land on the
682        // documented defaults for whatever is absent.
683        for (json, counter, init, increment) in [
684            (r#"{"max": 9}"#, None, 0, 1),
685            (r#"{"max": 9, "counter": "i"}"#, Some("i"), 0, 1),
686            (r#"{"max": 9, "init": 4}"#, None, 4, 1),
687            (r#"{"max": 9, "increment": 3}"#, None, 0, 3),
688            (r#"{"max": 9, "counter": "i", "init": 4}"#, Some("i"), 4, 1),
689            (
690                r#"{"max": 9, "counter": "i", "increment": 3}"#,
691                Some("i"),
692                0,
693                3,
694            ),
695            (r#"{"max": 9, "init": 4, "increment": 3}"#, None, 4, 3),
696            (
697                r#"{"max": 9, "counter": "i", "init": 4, "increment": 3}"#,
698                Some("i"),
699                4,
700                3,
701            ),
702        ] {
703            let cfg = loop_wf(json)
704                .unwrap_or_else(|e| panic!("{json} should be valid: {e}"))
705                .loop_config
706                .expect("loop config present");
707            assert_eq!(cfg.counter.as_deref(), counter, "counter for {json}");
708            assert_eq!(cfg.init, init, "init for {json}");
709            assert_eq!(cfg.increment, increment, "increment for {json}");
710            assert_eq!(cfg.max, 9, "max for {json}");
711        }
712    }
713
714    #[test]
715    fn loop_config_validation_matrix_over_init_increment_and_max() {
716        // The full accept/reject table for the three numeric fields. `max` must
717        // be strictly above `init` (half-open bound) and `increment` at least
718        // 1 (the counter must advance).
719        for (init, increment, max, valid) in [
720            // Ordinary forward ranges.
721            (0_i64, 1_i64, 1_i64, true),
722            (0, 1, 100, true),
723            (0, 7, 3, true), // one sweep, then the increment overshoots
724            (5, 1, 6, true),
725            // Negative and mixed-sign ranges are fine as long as max > init.
726            (-5, 1, 0, true),
727            (-5, 2, -4, true),
728            (-1, 1, 1, true),
729            // Empty or inverted bounds.
730            (0, 1, 0, false),
731            (5, 1, 5, false),
732            (5, 1, 4, false),
733            (0, 1, -1, false),
734            (-5, 1, -5, false),
735            // Non-advancing increments, independent of the bound.
736            (0, 0, 10, false),
737            (0, -1, 10, false),
738            (0, -100, 10, false),
739        ] {
740            let json = format!(r#"{{"init": {init}, "increment": {increment}, "max": {max}}}"#);
741            assert_eq!(
742                loop_wf(&json).is_ok(),
743                valid,
744                "init={init} increment={increment} max={max} should be {}",
745                if valid { "accepted" } else { "rejected" }
746            );
747        }
748    }
749
750    #[test]
751    fn loop_config_counter_path_matrix() {
752        // Accepted and rejected counter spellings, including the `#` escape the
753        // rest of the path vocabulary uses for numerically-named keys.
754        for (counter, valid) in [
755            ("i", true),
756            ("index", true),
757            ("cursor.index", true),
758            ("a.b.c.d", true),
759            ("#7", true), // escaped numeric object key, same as elsewhere
760            ("", false),
761            (".", false),
762            ("a.", false),
763            (".a", false),
764            ("a..b", false),
765        ] {
766            let json = format!(r#"{{"max": 5, "counter": "{counter}"}}"#);
767            assert_eq!(
768                loop_wf(&json).is_ok(),
769                valid,
770                "counter {counter:?} should be {}",
771                if valid { "accepted" } else { "rejected" }
772            );
773        }
774    }
775
776    #[test]
777    fn precompute_counter_path_matrix() {
778        for (counter, expected) in [
779            ("i", vec!["temp_data", "i"]),
780            ("cursor.index", vec!["temp_data", "cursor", "index"]),
781            ("a.b.c", vec!["temp_data", "a", "b", "c"]),
782            // The `#` prefix is preserved here and stripped at write time,
783            // exactly as `MapMapping::path_parts` treats it.
784            ("#7", vec!["temp_data", "#7"]),
785        ] {
786            let mut cfg = loop_wf(&format!(r#"{{"max": 5, "counter": "{counter}"}}"#))
787                .expect("valid loop")
788                .loop_config
789                .expect("loop config present");
790            cfg.precompute_counter_path();
791            let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
792            assert_eq!(parts, expected, "for counter {counter:?}");
793        }
794    }
795
796    #[test]
797    fn precompute_counter_path_is_idempotent() {
798        // The compiler runs once, but a hot reload recompiles the same config;
799        // calling twice must not accumulate segments.
800        let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
801            .expect("valid loop")
802            .loop_config
803            .expect("loop config present");
804        cfg.precompute_counter_path();
805        let first: Vec<Arc<str>> = cfg.counter_parts.to_vec();
806        cfg.precompute_counter_path();
807        assert_eq!(cfg.counter_parts.to_vec(), first);
808    }
809
810    #[test]
811    fn loop_config_rejects_a_non_object_and_a_non_numeric_max() {
812        for json in [r#""five""#, "5", "[]", r#"{"max": "5"}"#, "true"] {
813            assert!(loop_wf(json).is_err(), "{json} is not a valid loop config");
814        }
815    }
816
817    #[test]
818    fn an_explicit_null_loop_means_no_loop() {
819        // `Option<LoopConfig>` takes an explicit JSON null as absence, so a
820        // caller emitting `"loop": null` for "no loop" gets the single-pass
821        // workflow they meant rather than a deserialization error.
822        let workflow = loop_wf("null").expect("explicit null should be accepted");
823        assert!(workflow.loop_config.is_none());
824    }
825
826    #[test]
827    fn a_workflow_with_a_loop_still_validates_its_other_rules() {
828        // Loop validation is additive: the pre-existing rules still fire, and
829        // an otherwise-invalid workflow is not rescued by a valid loop.
830        let duplicate_tasks = Workflow::from_json(
831            r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [
832                 {"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}},
833                 {"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}}] }"#,
834        )
835        .expect("should parse");
836        assert!(duplicate_tasks.validate().is_err(), "duplicate task ids");
837
838        let no_tasks =
839            Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [] }"#)
840                .expect("should parse");
841        assert!(no_tasks.validate().is_err(), "empty task list");
842    }
843
844    #[test]
845    fn loop_config_coexists_with_every_other_workflow_field() {
846        // `loop` is orthogonal to the rest of the schema — nothing it adds
847        // shadows or disturbs a neighbouring field.
848        let workflow = Workflow::from_json(&format!(
849            r#"{{ "id": "w", "name": "w", "priority": 7, "description": "d",
850                  "condition": {{"==": [1, 1]}},
851                  "loop": {{"counter": "i", "max": 5}},
852                  "continue_on_error": true, "channel": "c", "version": 3,
853                  "status": "paused",
854                  "rollout": {{"bucket_start": 0, "bucket_end": 50}},
855                  "tags": ["x"], "tasks": [{MAP}] }}"#
856        ))
857        .expect("should parse");
858        workflow.validate().expect("should validate");
859
860        assert_eq!(workflow.priority, 7);
861        assert_eq!(workflow.channel, "c");
862        assert_eq!(workflow.version, 3);
863        assert_eq!(workflow.status, WorkflowStatus::Paused);
864        assert!(workflow.continue_on_error);
865        assert_eq!(
866            workflow.rollout,
867            Some(Rollout {
868                bucket_start: 0,
869                bucket_end: 50
870            })
871        );
872        assert_eq!(workflow.tags, ["x"]);
873        assert_eq!(
874            workflow
875                .loop_config
876                .expect("loop present")
877                .counter
878                .as_deref(),
879            Some("i")
880        );
881    }
882
883    #[test]
884    fn loop_config_accepts_a_valid_counter() {
885        let cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
886            .expect("valid loop")
887            .loop_config
888            .expect("loop config present");
889        assert_eq!(cfg.counter.as_deref(), Some("cursor.index"));
890    }
891
892    #[test]
893    fn precompute_counter_path_prefixes_temp_data() {
894        let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
895            .expect("valid loop")
896            .loop_config
897            .expect("loop config present");
898        assert!(
899            cfg.counter_parts.is_empty(),
900            "uncompiled workflows start with no pre-split path"
901        );
902
903        cfg.precompute_counter_path();
904
905        let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
906        assert_eq!(parts, ["temp_data", "cursor", "index"]);
907    }
908
909    #[test]
910    fn precompute_counter_path_is_empty_without_a_counter_name() {
911        let mut cfg = loop_wf(r#"{"max": 5}"#)
912            .expect("valid loop")
913            .loop_config
914            .expect("loop config present");
915        cfg.precompute_counter_path();
916        assert!(cfg.counter_parts.is_empty());
917    }
918
919    #[test]
920    fn rollout_deserializes_from_json() {
921        let workflow = Workflow::from_json(
922            r#"{ "id": "w", "name": "w", "condition": true,
923                 "rollout": { "bucket_start": 0, "bucket_end": 50 },
924                 "tasks": [] }"#,
925        )
926        .unwrap();
927        assert_eq!(
928            workflow.rollout,
929            Some(Rollout {
930                bucket_start: 0,
931                bucket_end: 50
932            })
933        );
934    }
935}