Skip to main content

dataflow_rs/engine/
workflow.rs

1use crate::engine::error::{DataflowError, Result};
2use crate::engine::functions::{ConnectorName, FunctionConfig};
3use crate::engine::task::Task;
4use chrono::{DateTime, Utc};
5use datalogic_rs::Logic;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::fs;
9use std::path::Path;
10use std::sync::Arc;
11
12pub use crate::engine::rollout::{Rollout, RolloutError};
13
14/// Engine-managed `for` loop over a workflow's task list.
15///
16/// A workflow carrying a `loop` runs its task list repeatedly — one *sweep*
17/// per iteration — rather than once. Per sweep the engine writes the counter
18/// into `temp_data` (when [`counter`](Self::counter) names it), checks
19/// `counter < max`, then re-evaluates the workflow `condition`; the sweep runs
20/// only if both hold. Afterwards the counter advances by `increment`.
21///
22/// The bound is half-open, matching [`Rollout`]: `init: 0, max: n` yields
23/// counter values `0..n-1` — exactly array indices.
24///
25/// Reaching `max` is normal completion, never an error: `max` is always
26/// author-supplied, so hitting it is the stated bound rather than a runaway.
27/// To stop mid-body, use a `filter` task with `on_reject: halt` — that breaks
28/// the whole loop, not just the current sweep.
29///
30/// # Iterating an array
31///
32/// [`setup`](Self::setup), [`over`](Self::over), [`item`](Self::item) (`as`)
33/// and [`scratch`](Self::scratch) turn the counter into a batch loop: setup
34/// runs once, `over` is evaluated once after it, and each iteration sees
35/// `over[counter]` in `temp_data.<as>` and a fresh `{}` in
36/// `temp_data.<scratch>`. A loop carrying none of them behaves exactly as a
37/// plain counter loop always has.
38///
39/// # Memory
40///
41/// Every sweep adds one audit entry per task, and while the message has
42/// `capture_changes` on — the default — each entry holds a deep copy of the
43/// old and new value of every write. Nothing is released until
44/// `process_message` returns, so a loop's memory grows with
45/// sweeps × writes × value size: about 65 bytes per number written, so a body
46/// writing one 10,000-number array per sweep holds roughly 6.5 GB by sweep
47/// 10,000. A host that does not read
48/// [`AuditTrail::changes`](crate::AuditTrail::changes) should build the
49/// message with
50/// [`MessageBuilder::capture_changes(false)`](crate::MessageBuilder::capture_changes);
51/// the entries are still recorded, without values. To bound or drop the
52/// entries themselves, set
53/// [`MessageBuilder::audit_mode`](crate::MessageBuilder::audit_mode). See the
54/// Loops guide, "Memory in long loops".
55///
56/// # Example
57///
58/// ```json
59/// {
60///     "id": "per_item",
61///     "condition": {"<": [{"var": "temp_data.i"}, {"var": "temp_data.n"}]},
62///     "loop": { "counter": "i", "max": 10000 },
63///     "tasks": [ ... ]
64/// }
65/// ```
66///
67/// `#[non_exhaustive]`: construct through [`LoopConfig::bounded`] and assign
68/// the public fields you need. The compiled fields are engine internals a
69/// struct literal would otherwise have to name. Not `PartialEq`: `setup` holds
70/// [`Task`]s, which are not comparable, and neither is the compiled `over`.
71#[derive(Clone, Debug, Deserialize)]
72#[non_exhaustive]
73pub struct LoopConfig {
74    /// `temp_data` field the engine maintains as the induction variable —
75    /// `"i"` means `temp_data.i`, and dot-paths nest (`"cursor.index"` →
76    /// `temp_data.cursor.index`).
77    ///
78    /// `None` still bounds the loop by `max`; the count is simply not exposed
79    /// to conditions or tasks. The engine tracks it either way, so the audit
80    /// trail carries it regardless.
81    ///
82    /// The engine owns this field: it is rewritten before every sweep, so a
83    /// body task writing the same path is overwritten at the next increment.
84    #[serde(default)]
85    pub counter: Option<String>,
86
87    /// First counter value. Defaults to `0`.
88    #[serde(default)]
89    pub init: i64,
90
91    /// Added to the counter after each sweep. Defaults to `1`; must be `>= 1`,
92    /// so the counter strictly increases and the loop cannot stall.
93    #[serde(default = "default_increment")]
94    pub increment: i64,
95
96    /// Required upper bound — sweeps run while `counter < max`. There is no
97    /// default: an unbounded loop is never what the author meant, and the
98    /// bound is what makes termination structural rather than a property of
99    /// the condition being written correctly.
100    pub max: i64,
101
102    /// Steps run **once**, before the first iteration, in the normal step
103    /// grammar: groups are allowed, and `terminal` ends the workflow before
104    /// any iteration. Their audit entries carry no `loop_counter`, because
105    /// setup is not a sweep. Shares the step id namespace with the workflow's
106    /// `tasks`.
107    ///
108    /// The workflow `condition` gates setup: a false result skips setup and
109    /// the loop alike.
110    #[serde(default, deserialize_with = "crate::engine::steps::flatten")]
111    pub setup: Vec<Task>,
112
113    /// JSONLogic evaluated **once, after setup**. It must yield an array;
114    /// anything else, `null` included, is a workflow error naming
115    /// `loop.over`. An empty array runs zero iterations.
116    ///
117    /// The counter indexes the array: iteration `k` holds `over[k]` in
118    /// `temp_data.<as>`, and the loop stops at `max` or at the array's end,
119    /// whichever comes first. `init` is therefore a starting offset and
120    /// `increment` a stride, and `init` must be `>= 0`.
121    ///
122    /// An explicit `"over": null` is kept as `Some(Value::Null)` rather than
123    /// read as absence, so it is refused like any other literal that can never
124    /// be an array instead of silently turning the loop into a counter loop.
125    #[serde(default, deserialize_with = "present_value")]
126    pub over: Option<Value>,
127
128    /// `temp_data` field holding the current element of [`Self::over`]. The
129    /// JSON key is `as`. Optional: without it the element is not exposed, but
130    /// the loop is still bounded by the array's length. Requires `over`.
131    #[serde(default, rename = "as")]
132    pub item: Option<String>,
133
134    /// `temp_data` field reset to `{}` at the start of **every** iteration,
135    /// before the condition, so per-item state cannot leak from one element
136    /// to the next. Left holding the last iteration's state after the loop,
137    /// like the counter. Independent of `over`.
138    #[serde(default)]
139    pub scratch: Option<String>,
140
141    /// Engine-internal: `["temp_data", ..counter segments]`, populated by
142    /// `LogicCompiler`. Empty when `counter` is `None`. Not part of the stable
143    /// API.
144    #[doc(hidden)]
145    #[serde(skip)]
146    pub counter_parts: Arc<[Arc<str>]>,
147
148    /// Engine-internal: pre-split `temp_data.{as}`. Not part of the stable API.
149    #[doc(hidden)]
150    #[serde(skip)]
151    pub item_parts: Arc<[Arc<str>]>,
152
153    /// Engine-internal: pre-split `temp_data.{scratch}`. Not part of the
154    /// stable API.
155    #[doc(hidden)]
156    #[serde(skip)]
157    pub scratch_parts: Arc<[Arc<str>]>,
158
159    /// Engine-internal: compiled `over`, populated by `LogicCompiler`. Not
160    /// part of the stable API.
161    #[doc(hidden)]
162    #[serde(skip)]
163    pub compiled_over: Option<Arc<Logic>>,
164}
165
166fn default_increment() -> i64 {
167    1
168}
169
170/// `deserialize_with` target that keeps an explicit JSON `null` as
171/// `Some(Value::Null)`. Absence still gives `None`, through `#[serde(default)]`.
172fn present_value<'de, D>(deserializer: D) -> std::result::Result<Option<Value>, D::Error>
173where
174    D: serde::Deserializer<'de>,
175{
176    Value::deserialize(deserializer).map(Some)
177}
178
179/// The pre-split form of an unnamed slot: `set_nested_value_parts` treats an
180/// empty slice as a no-op write.
181fn no_parts() -> Arc<[Arc<str>]> {
182    Arc::from([] as [Arc<str>; 0])
183}
184
185/// Whether `path` is a well-formed `temp_data` field path: non-empty, with no
186/// empty segment. The one rule `counter`, `as` and `scratch` share.
187pub(crate) fn is_slot_path(path: &str) -> bool {
188    !path.is_empty() && !path.split('.').any(str::is_empty)
189}
190
191/// Whether two slot paths name the same key or one lies inside the other:
192/// `it` and `it.item` overlap, `it` and `item` do not. A reset of the outer
193/// slot would wipe the inner one, so the engine refuses to own both.
194pub(crate) fn slots_overlap(a: &str, b: &str) -> bool {
195    fn within(outer: &str, inner: &str) -> bool {
196        inner
197            .strip_prefix(outer)
198            .is_some_and(|rest| rest.is_empty() || rest.starts_with('.'))
199    }
200    within(a, b) || within(b, a)
201}
202
203/// Why a literal `over` could never yield an array, if it is one.
204///
205/// An object is an expression and an array is itself; any other JSON literal
206/// is a constant that is not an array, so the loop would fail on every
207/// message. Shared by [`LoopConfig`]'s validation and the authoring check.
208pub(crate) fn over_literal_problem(over: &Value) -> Option<String> {
209    let kind = match over {
210        Value::Object(_) | Value::Array(_) => return None,
211        Value::Null => "null",
212        Value::Bool(_) => "a boolean",
213        Value::Number(_) => "a number",
214        Value::String(_) => "a string",
215    };
216    Some(format!(
217        "loop over must be a JSONLogic expression or an array literal — \
218         {kind} can never evaluate to an array"
219    ))
220}
221
222impl LoopConfig {
223    /// A loop bounded at `max`, every other field at its default: counter
224    /// unnamed, `init: 0`, `increment: 1`, no setup, no `over`.
225    ///
226    /// ```
227    /// use dataflow_rs::engine::workflow::LoopConfig;
228    ///
229    /// let mut config = LoopConfig::bounded(10);
230    /// config.counter = Some("i".to_string());
231    /// assert_eq!((config.init, config.increment, config.max), (0, 1, 10));
232    /// ```
233    pub fn bounded(max: i64) -> Self {
234        Self {
235            counter: None,
236            init: 0,
237            increment: default_increment(),
238            max,
239            setup: Vec::new(),
240            over: None,
241            item: None,
242            scratch: None,
243            counter_parts: no_parts(),
244            item_parts: no_parts(),
245            scratch_parts: no_parts(),
246            compiled_over: None,
247        }
248    }
249
250    /// Structural validation, run from [`Workflow::validate`] at
251    /// `Engine::build()` time. Every rule here rejects a config that could
252    /// only fail — or spin — at runtime.
253    fn validate(&self, workflow_id: &str) -> Result<()> {
254        if self.increment < 1 {
255            return Err(DataflowError::Workflow(format!(
256                "Workflow {workflow_id}: loop increment must be >= 1, got {} \
257                 (a non-advancing counter would never reach max)",
258                self.increment
259            )));
260        }
261        if self.max <= self.init {
262            return Err(DataflowError::Workflow(format!(
263                "Workflow {workflow_id}: loop max ({}) must be greater than init ({}) — \
264                 the bound is half-open, so this could never run a sweep",
265                self.max, self.init
266            )));
267        }
268        for (field, slot) in [
269            ("counter", &self.counter),
270            ("as", &self.item),
271            ("scratch", &self.scratch),
272        ] {
273            if let Some(path) = slot
274                && !is_slot_path(path)
275            {
276                return Err(DataflowError::Workflow(format!(
277                    "Workflow {workflow_id}: loop {field} must be a non-empty \
278                     temp_data field path, got {path:?}"
279                )));
280            }
281        }
282        if self.item.is_some() && self.over.is_none() {
283            return Err(DataflowError::Workflow(format!(
284                "Workflow {workflow_id}: loop `as` names a slot for the current element, \
285                 but there is no `over` to take elements from"
286            )));
287        }
288        if let Some(over) = &self.over {
289            if let Some(problem) = over_literal_problem(over) {
290                return Err(DataflowError::Workflow(format!(
291                    "Workflow {workflow_id}: {problem}"
292                )));
293            }
294            if self.init < 0 {
295                return Err(DataflowError::Workflow(format!(
296                    "Workflow {workflow_id}: loop init must be >= 0 when over is set — \
297                     the counter indexes the array, got {}",
298                    self.init
299                )));
300            }
301        }
302        // Each engine-owned slot is its own path: a per-iteration reset of
303        // `scratch` that also wiped `as`, or a counter written over the
304        // element, would be exactly the silent leak this exists to end.
305        let slots = [
306            ("counter", self.counter.as_deref()),
307            ("as", self.item.as_deref()),
308            ("scratch", self.scratch.as_deref()),
309        ];
310        for (i, (name_a, a)) in slots.iter().enumerate() {
311            for (name_b, b) in &slots[i + 1..] {
312                if let (Some(a), Some(b)) = (a, b)
313                    && slots_overlap(a, b)
314                {
315                    return Err(DataflowError::Workflow(format!(
316                        "Workflow {workflow_id}: loop {name_a} ({a:?}) and {name_b} ({b:?}) \
317                         overlap — each engine-owned slot must be its own temp_data path"
318                    )));
319                }
320            }
321        }
322        Ok(())
323    }
324
325    /// Pre-split the three `temp_data.{…}` slots into the path parts the
326    /// executor writes through, so an iteration never re-splits a path.
327    /// Populated by `LogicCompiler`; the executor falls back to splitting on
328    /// the fly for workflows constructed directly rather than through
329    /// `Engine::builder`. An unnamed slot yields an empty slice, which
330    /// `set_nested_value_parts` treats as a no-op.
331    #[doc(hidden)]
332    pub fn precompute_paths(&mut self) {
333        fn parts(name: Option<&str>) -> Arc<[Arc<str>]> {
334            match name {
335                Some(name) => crate::engine::utils::compute_path_parts("temp_data", name),
336                None => no_parts(),
337            }
338        }
339        self.counter_parts = parts(self.counter.as_deref());
340        self.item_parts = parts(self.item.as_deref());
341        self.scratch_parts = parts(self.scratch.as_deref());
342    }
343}
344
345/// Workflow lifecycle status
346#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
347#[serde(rename_all = "lowercase")]
348pub enum WorkflowStatus {
349    #[default]
350    Active,
351    Paused,
352    Archived,
353}
354
355/// Workflow represents a collection of tasks that execute sequentially (also known as a Rule in rules-engine terminology).
356///
357/// Conditions are evaluated against the full message context, including `data`, `metadata`, and `temp_data` fields.
358/// A collection of tasks with a condition, executed against a message.
359///
360/// `#[non_exhaustive]`: construct through [`Workflow::new`], [`Workflow::rule`]
361/// or [`Workflow::from_json`] and assign the public fields you need. Field
362/// reads and writes are unaffected.
363///
364/// Same reason as [`Task`]: several fields are engine internals
365/// marked *not part of the stable API*, and struct-literal construction forced
366/// callers to name them.
367#[derive(Clone, Debug, Deserialize)]
368#[non_exhaustive]
369pub struct Workflow {
370    pub id: String,
371    /// Engine-internal: `Arc<str>` mirror of `id`, populated by
372    /// `LogicCompiler::compile_workflows`. Cloning is a refcount bump; per-message
373    /// `AuditTrail` entries reuse it instead of allocating from `&id` each time.
374    /// Not part of the stable API.
375    #[doc(hidden)]
376    #[serde(skip)]
377    pub id_arc: Arc<str>,
378    pub name: String,
379    #[serde(default)]
380    pub priority: u32,
381    pub description: Option<String>,
382    #[serde(default = "crate::engine::utils::default_condition")]
383    pub condition: Value,
384    /// Engine-internal: pre-compiled JSONLogic for `condition`, populated by
385    /// `LogicCompiler`. `None` is treated as "no condition / always run" by
386    /// the executor. Not part of the stable API.
387    #[doc(hidden)]
388    #[serde(skip)]
389    pub compiled_condition: Option<Arc<Logic>>,
390    /// Engine-internal: `true` when every task is a synchronous built-in
391    /// (`is_sync_builtin`), so the whole workflow can run inside a shared
392    /// `with_arena` scope with no `.await`. Populated by `LogicCompiler`; the
393    /// `false` default means an uncompiled workflow conservatively takes the
394    /// async path. Not part of the stable API.
395    #[doc(hidden)]
396    #[serde(skip, default)]
397    pub fully_sync: bool,
398    /// The workflow's steps, flattened.
399    ///
400    /// The JSON `tasks` array holds *steps*: an element carrying a `tasks` key
401    /// is a [`TaskGroup`](crate::TaskGroup), anything else is a [`Task`]. The
402    /// parser flattens the
403    /// tree in document order and records each group's span on the task that
404    /// opens it ([`Task::group_starts`]), so this stays a flat list and the
405    /// executor keeps walking `&[Task]` slices.
406    #[serde(deserialize_with = "crate::engine::steps::flatten")]
407    pub tasks: Vec<Task>,
408    #[serde(default)]
409    pub continue_on_error: bool,
410    /// Channel for routing (default: "default")
411    #[serde(default = "default_channel")]
412    pub channel: String,
413    /// Version number for rule versioning (default: 1)
414    #[serde(default = "default_version")]
415    pub version: u32,
416    /// Workflow status — Active, Paused, or Archived (default: Active)
417    #[serde(default)]
418    pub status: WorkflowStatus,
419    /// Traffic split for this workflow. `None` (the default) means the workflow
420    /// is not part of a split and runs for every message.
421    ///
422    /// A workflow with a rollout is skipped when the message's
423    /// [`crate::Message::routing_bucket`] falls outside the range. A message with
424    /// **no** bucket is admitted — see [`Rollout`].
425    #[serde(default)]
426    pub rollout: Option<Rollout>,
427    /// Engine-managed loop over this workflow's task list. `None` (the
428    /// default) runs the task list exactly once — the historical behaviour, on
429    /// a code path that carries no loop overhead.
430    ///
431    /// See [`LoopConfig`] for the per-sweep contract.
432    #[serde(default, rename = "loop")]
433    pub loop_config: Option<LoopConfig>,
434    /// Tags for categorization and filtering
435    #[serde(default)]
436    pub tags: Vec<String>,
437    /// Creation timestamp
438    #[serde(default)]
439    pub created_at: Option<DateTime<Utc>>,
440    /// Last update timestamp
441    #[serde(default)]
442    pub updated_at: Option<DateTime<Utc>>,
443}
444
445fn default_channel() -> String {
446    "default".to_string()
447}
448
449fn default_version() -> u32 {
450    1
451}
452
453impl Default for Workflow {
454    fn default() -> Self {
455        Self::new()
456    }
457}
458
459impl Workflow {
460    pub fn new() -> Self {
461        Self {
462            id: String::new(),
463            id_arc: Arc::from(""),
464            name: String::new(),
465            priority: 0,
466            description: None,
467            condition: Value::Bool(true),
468            compiled_condition: None,
469            fully_sync: false,
470            tasks: Vec::new(),
471            continue_on_error: false,
472            channel: default_channel(),
473            version: 1,
474            status: WorkflowStatus::Active,
475            rollout: None,
476            loop_config: None,
477            tags: Vec::new(),
478            created_at: None,
479            updated_at: None,
480        }
481    }
482
483    /// Create a workflow (rule) with a condition and tasks.
484    ///
485    /// This is a convenience constructor for the IFTTT-style rules engine pattern:
486    /// **IF** `condition` **THEN** execute `tasks`.
487    ///
488    /// # Arguments
489    /// * `id` - Unique identifier for the rule
490    /// * `name` - Human-readable name
491    /// * `condition` - JSONLogic condition evaluated against the full context (data, metadata, temp_data)
492    /// * `tasks` - Actions to execute when the condition is met
493    pub fn rule(id: &str, name: &str, condition: Value, tasks: Vec<Task>) -> Self {
494        Self {
495            id: id.to_string(),
496            id_arc: Arc::from(id),
497            name: name.to_string(),
498            priority: 0,
499            description: None,
500            condition,
501            compiled_condition: None,
502            fully_sync: false,
503            tasks,
504            continue_on_error: false,
505            channel: default_channel(),
506            version: 1,
507            status: WorkflowStatus::Active,
508            rollout: None,
509            loop_config: None,
510            tags: Vec::new(),
511            created_at: None,
512            updated_at: None,
513        }
514    }
515
516    /// Load workflow from JSON string
517    pub fn from_json(json_str: &str) -> Result<Self> {
518        serde_json::from_str(json_str).map_err(DataflowError::from_serde)
519    }
520
521    /// Load workflow from JSON file
522    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
523        let json_str = fs::read_to_string(path).map_err(DataflowError::from_io)?;
524
525        Self::from_json(&json_str)
526    }
527
528    /// Validate the workflow structure
529    pub fn validate(&self) -> Result<()> {
530        // Check required fields
531        if self.id.is_empty() {
532            return Err(DataflowError::Workflow(
533                "Workflow id cannot be empty".to_string(),
534            ));
535        }
536
537        if self.name.is_empty() {
538            return Err(DataflowError::Workflow(
539                "Workflow name cannot be empty".to_string(),
540            ));
541        }
542
543        // Check tasks
544        if self.tasks.is_empty() {
545            return Err(DataflowError::Workflow(
546                "Workflow must have at least one task".to_string(),
547            ));
548        }
549
550        // Validate that task and group IDs are unique. Groups share the task
551        // id namespace: both name a step, both surface in traces and error
552        // messages, and a collision would make either ambiguous.
553        let mut step_ids = std::collections::HashSet::new();
554        for task in self.all_tasks() {
555            for group in &task.group_starts {
556                if !step_ids.insert(group.id.as_str()) {
557                    return Err(DataflowError::Workflow(format!(
558                        "Duplicate step ID '{}' in workflow — task group IDs share the task ID namespace",
559                        group.id
560                    )));
561                }
562            }
563            if !step_ids.insert(task.id.as_str()) {
564                return Err(DataflowError::Workflow(format!(
565                    "Duplicate task ID '{}' in workflow",
566                    task.id
567                )));
568            }
569        }
570
571        // Setup steps included: a `for_each` that breaks its rules is refused
572        // wherever it sits. The rules live once, in `ForEach::problem`.
573        for task in self.all_tasks() {
574            if let Some(for_each) = &task.for_each
575                && let Some(problem) = for_each.problem(&task.function)
576            {
577                return Err(DataflowError::Workflow(format!(
578                    "Task '{}': {}",
579                    task.id, problem.message
580                )));
581            }
582        }
583
584        // A loop whose bounds could never advance is rejected at build time
585        // rather than spinning — or silently doing nothing — on the first
586        // message.
587        if let Some(loop_config) = &self.loop_config {
588            loop_config.validate(&self.id)?;
589        }
590
591        Ok(())
592    }
593}
594
595/// One task's connector reference, located within a workflow.
596///
597/// `Copy`: every field is a shared borrow. `config` is carried so callers can
598/// apply cross-field rules — "a task on this kind of connector also needs
599/// `input.database`" — without re-parsing the task.
600///
601/// Not `Serialize`: [`FunctionConfig`] is deserialize-only, so callers that emit
602/// JSON diagnostics build their own shape from these fields.
603#[derive(Debug, Clone, Copy)]
604pub struct ConnectorRef<'a> {
605    /// `id` of the owning workflow.
606    pub workflow_id: &'a str,
607    /// `id` of the referencing task.
608    pub task_id: &'a str,
609    /// Canonical function name, as [`FunctionConfig::function_name`].
610    pub function: &'a str,
611    /// The connector name when authored as a literal, or the expression when
612    /// computed. See [`ConnectorName`].
613    pub connector: ConnectorName<'a>,
614    /// The whole function config, for cross-field rules.
615    pub config: &'a FunctionConfig,
616}
617
618impl Workflow {
619    /// Every task this workflow can run, in execution order: the loop's
620    /// `setup` list first, then the body.
621    ///
622    /// The one iteration a pass over "all the tasks" should use. `tasks`
623    /// alone misses setup, and a check that reads only `tasks` (a handler
624    /// lookup, a secret scan, a lint) would silently pass a setup step it
625    /// should have refused.
626    pub fn all_tasks(&self) -> impl Iterator<Item = &Task> {
627        self.loop_config
628            .iter()
629            .flat_map(|l| l.setup.iter())
630            .chain(self.tasks.iter())
631    }
632
633    /// Mutable [`Self::all_tasks`], for the compiler's per-task passes.
634    pub(crate) fn all_tasks_mut(&mut self) -> impl Iterator<Item = &mut Task> {
635        self.loop_config
636            .iter_mut()
637            .flat_map(|l| l.setup.iter_mut())
638            .chain(self.tasks.iter_mut())
639    }
640
641    /// Every connector reference in this workflow, in task order.
642    ///
643    /// Setup steps of a `loop` come first, as they run first.
644    ///
645    /// Tasks whose function names no connector are skipped. One item is yielded
646    /// per *task*, not per distinct connector: two tasks on the same connector
647    /// yield two items. Callers wanting a distinct set collect one themselves.
648    ///
649    /// Does not require a compiled workflow — this reads only deserialized
650    /// fields, so it works on the output of [`Workflow::from_json`] before the
651    /// engine has compiled it.
652    ///
653    /// Which configs carry a connector is this crate's fact; deriving it here
654    /// rather than reimplementing the set downstream is the point.
655    pub fn connector_refs(&self) -> impl Iterator<Item = ConnectorRef<'_>> {
656        // `move` is load-bearing: it copies the `&Workflow` into the closure so
657        // the returned iterator does not borrow a local.
658        self.all_tasks().filter_map(move |task| {
659            task.function.connector().map(|connector| ConnectorRef {
660                workflow_id: &self.id,
661                task_id: &task.id,
662                function: task.function.function_name(),
663                connector,
664                config: &task.function,
665            })
666        })
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    fn wf(tasks_json: &str) -> Workflow {
675        Workflow::from_json(&format!(
676            r#"{{ "id": "w", "name": "w", "priority": 0, "condition": true,
677                  "tasks": [{tasks_json}] }}"#
678        ))
679        .expect("workflow should parse")
680    }
681
682    const HTTP: &str = r#"{ "id": "call", "name": "call", "function": {
683        "name": "http_call", "input": { "connector": "user_service" } } }"#;
684    const KAFKA: &str = r#"{ "id": "pub", "name": "pub", "function": {
685        "name": "publish_kafka",
686        "input": { "connector": "events", "topic": "t" } } }"#;
687    const MAP: &str = r#"{ "id": "m", "name": "m", "function": {
688        "name": "map", "input": { "mappings": [] } } }"#;
689    const LOG: &str = r#"{ "id": "l", "name": "l", "function": {
690        "name": "log", "input": { "message": "hi" } } }"#;
691
692    #[test]
693    fn connector_refs_yields_only_connector_tasks_in_task_order() {
694        let workflow = wf(&format!("{MAP},{HTTP},{LOG},{KAFKA}"));
695        let refs: Vec<_> = workflow.connector_refs().collect();
696
697        assert_eq!(refs.len(), 2);
698        assert_eq!(refs[0].task_id, "call");
699        assert_eq!(refs[0].function, "http_call");
700        assert_eq!(refs[0].connector.as_static(), Some("user_service"));
701        assert_eq!(refs[1].task_id, "pub");
702        assert_eq!(refs[1].function, "publish_kafka");
703        assert_eq!(refs[1].connector.as_static(), Some("events"));
704    }
705
706    #[test]
707    fn connector_refs_carries_the_owning_workflow_id() {
708        let workflow = wf(HTTP);
709        assert!(workflow.connector_refs().all(|r| r.workflow_id == "w"));
710
711        // Including the empty-id case from `Workflow::new()`.
712        let empty = Workflow::new();
713        assert_eq!(empty.id, "");
714        assert_eq!(empty.connector_refs().count(), 0);
715    }
716
717    #[test]
718    fn connector_refs_is_empty_for_no_tasks() {
719        // `validate` rejects an empty task list, but `connector_refs` must not
720        // assume `validate` ran — `Workflow::new()` has empty tasks.
721        assert_eq!(Workflow::new().connector_refs().count(), 0);
722    }
723
724    #[test]
725    fn connector_refs_does_not_deduplicate() {
726        let a = r#"{ "id": "a", "name": "a", "function": {
727            "name": "http_call", "input": { "connector": "same" } } }"#;
728        let b = r#"{ "id": "b", "name": "b", "function": {
729            "name": "enrich",
730            "input": { "connector": "same", "merge_path": "data.out" } } }"#;
731        let workflow = wf(&format!("{a},{b}"));
732
733        let refs: Vec<_> = workflow.connector_refs().collect();
734        assert_eq!(refs.len(), 2, "one item per task, not a distinct set");
735        assert!(refs.iter().all(|r| r.connector.as_static() == Some("same")));
736    }
737
738    #[test]
739    fn connector_refs_works_on_an_uncompiled_workflow() {
740        // Straight from `from_json`, before any engine construction: `id_arc` and
741        // `compiled_condition` are still unset.
742        let workflow = wf(HTTP);
743        assert!(workflow.compiled_condition.is_none());
744        assert_eq!(workflow.connector_refs().count(), 1);
745    }
746
747    #[test]
748    fn connector_ref_is_copy() {
749        let workflow = wf(HTTP);
750        let r = workflow.connector_refs().next().unwrap();
751        let copied = r;
752        // Reading both without cloning only compiles if `ConnectorRef` is `Copy`.
753        assert_eq!(r.connector, copied.connector);
754        assert_eq!(r.task_id, copied.task_id);
755    }
756
757    #[test]
758    fn connector_ref_config_supports_a_cross_field_rule() {
759        // Proves `config` is load-bearing rather than decorative: read another
760        // key out of the same task's input.
761        let custom = r#"{ "id": "db", "name": "db", "function": {
762            "name": "pg_query",
763            "input": { "connector": "pg_main", "database": "orders" } } }"#;
764        let workflow = wf(custom);
765
766        let r = workflow.connector_refs().next().expect("custom connector");
767        assert_eq!(r.connector.as_static(), Some("pg_main"));
768        match r.config {
769            FunctionConfig::Custom { input, .. } => {
770                assert_eq!(
771                    input.get("database").and_then(|v| v.as_str()),
772                    Some("orders")
773                );
774            }
775            other => panic!("expected Custom, got {other:?}"),
776        }
777    }
778
779    #[test]
780    fn rollout_defaults_to_none_on_every_construction_path() {
781        assert_eq!(Workflow::new().rollout, None);
782        assert_eq!(Workflow::default().rollout, None);
783        assert_eq!(
784            Workflow::rule("r", "r", Value::Bool(true), Vec::new()).rollout,
785            None
786        );
787        assert_eq!(wf(MAP).rollout, None, "absent JSON key gives None");
788    }
789
790    // -----------------------------------------------------------------
791    // LoopConfig
792    // -----------------------------------------------------------------
793
794    /// Build a one-task workflow carrying `loop_json`, then validate it.
795    fn loop_wf(loop_json: &str) -> Result<Workflow> {
796        let workflow = Workflow::from_json(&format!(
797            r#"{{ "id": "w", "name": "w", "loop": {loop_json}, "tasks": [{MAP}] }}"#
798        ))?;
799        workflow.validate()?;
800        Ok(workflow)
801    }
802
803    #[test]
804    fn loop_config_defaults_init_zero_increment_one() {
805        let cfg = loop_wf(r#"{"max": 5}"#)
806            .expect("valid loop")
807            .loop_config
808            .expect("loop config present");
809        assert_eq!(cfg.init, 0);
810        assert_eq!(cfg.increment, 1);
811        assert_eq!(cfg.max, 5);
812        assert_eq!(cfg.counter, None);
813    }
814
815    #[test]
816    fn loop_config_is_absent_on_every_construction_path() {
817        assert!(wf(MAP).loop_config.is_none(), "absent JSON key gives None");
818        assert!(Workflow::new().loop_config.is_none());
819        assert!(Workflow::default().loop_config.is_none());
820        assert!(
821            Workflow::rule("r", "r", Value::Bool(true), Vec::new())
822                .loop_config
823                .is_none()
824        );
825    }
826
827    #[test]
828    fn loop_config_rejects_a_bound_that_could_never_run_a_sweep() {
829        // Half-open: sweeps run while `counter < max`, so max == init is zero
830        // sweeps and max < init is worse.
831        assert!(loop_wf(r#"{"max": 0}"#).is_err());
832        assert!(loop_wf(r#"{"init": 5, "max": 5}"#).is_err());
833        assert!(loop_wf(r#"{"init": 5, "max": 2}"#).is_err());
834    }
835
836    #[test]
837    fn loop_config_rejects_a_non_advancing_increment() {
838        assert!(loop_wf(r#"{"max": 5, "increment": 0}"#).is_err());
839        assert!(loop_wf(r#"{"max": 5, "increment": -1}"#).is_err());
840    }
841
842    #[test]
843    fn loop_config_rejects_an_empty_counter_path() {
844        assert!(loop_wf(r#"{"max": 5, "counter": ""}"#).is_err());
845        assert!(loop_wf(r#"{"max": 5, "counter": "a..b"}"#).is_err());
846        assert!(loop_wf(r#"{"max": 5, "counter": "a."}"#).is_err());
847    }
848
849    #[test]
850    fn loop_config_requires_max() {
851        // No default: an unbounded loop is never what the author meant, so it
852        // fails to deserialize rather than picking a bound on their behalf.
853        assert!(
854            Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {}, "tasks": [] }"#).is_err()
855        );
856    }
857
858    #[test]
859    fn loop_config_deserializes_every_combination_of_optional_fields() {
860        // `max` is the only required field; the other three are independently
861        // optional, so all eight presence combinations must land on the
862        // documented defaults for whatever is absent.
863        for (json, counter, init, increment) in [
864            (r#"{"max": 9}"#, None, 0, 1),
865            (r#"{"max": 9, "counter": "i"}"#, Some("i"), 0, 1),
866            (r#"{"max": 9, "init": 4}"#, None, 4, 1),
867            (r#"{"max": 9, "increment": 3}"#, None, 0, 3),
868            (r#"{"max": 9, "counter": "i", "init": 4}"#, Some("i"), 4, 1),
869            (
870                r#"{"max": 9, "counter": "i", "increment": 3}"#,
871                Some("i"),
872                0,
873                3,
874            ),
875            (r#"{"max": 9, "init": 4, "increment": 3}"#, None, 4, 3),
876            (
877                r#"{"max": 9, "counter": "i", "init": 4, "increment": 3}"#,
878                Some("i"),
879                4,
880                3,
881            ),
882        ] {
883            let cfg = loop_wf(json)
884                .unwrap_or_else(|e| panic!("{json} should be valid: {e}"))
885                .loop_config
886                .expect("loop config present");
887            assert_eq!(cfg.counter.as_deref(), counter, "counter for {json}");
888            assert_eq!(cfg.init, init, "init for {json}");
889            assert_eq!(cfg.increment, increment, "increment for {json}");
890            assert_eq!(cfg.max, 9, "max for {json}");
891        }
892    }
893
894    #[test]
895    fn loop_config_validation_matrix_over_init_increment_and_max() {
896        // The full accept/reject table for the three numeric fields. `max` must
897        // be strictly above `init` (half-open bound) and `increment` at least
898        // 1 (the counter must advance).
899        for (init, increment, max, valid) in [
900            // Ordinary forward ranges.
901            (0_i64, 1_i64, 1_i64, true),
902            (0, 1, 100, true),
903            (0, 7, 3, true), // one sweep, then the increment overshoots
904            (5, 1, 6, true),
905            // Negative and mixed-sign ranges are fine as long as max > init.
906            (-5, 1, 0, true),
907            (-5, 2, -4, true),
908            (-1, 1, 1, true),
909            // Empty or inverted bounds.
910            (0, 1, 0, false),
911            (5, 1, 5, false),
912            (5, 1, 4, false),
913            (0, 1, -1, false),
914            (-5, 1, -5, false),
915            // Non-advancing increments, independent of the bound.
916            (0, 0, 10, false),
917            (0, -1, 10, false),
918            (0, -100, 10, false),
919        ] {
920            let json = format!(r#"{{"init": {init}, "increment": {increment}, "max": {max}}}"#);
921            assert_eq!(
922                loop_wf(&json).is_ok(),
923                valid,
924                "init={init} increment={increment} max={max} should be {}",
925                if valid { "accepted" } else { "rejected" }
926            );
927        }
928    }
929
930    #[test]
931    fn loop_config_counter_path_matrix() {
932        // Accepted and rejected counter spellings, including the `#` escape the
933        // rest of the path vocabulary uses for numerically-named keys.
934        for (counter, valid) in [
935            ("i", true),
936            ("index", true),
937            ("cursor.index", true),
938            ("a.b.c.d", true),
939            ("#7", true), // escaped numeric object key, same as elsewhere
940            ("", false),
941            (".", false),
942            ("a.", false),
943            (".a", false),
944            ("a..b", false),
945        ] {
946            let json = format!(r#"{{"max": 5, "counter": "{counter}"}}"#);
947            assert_eq!(
948                loop_wf(&json).is_ok(),
949                valid,
950                "counter {counter:?} should be {}",
951                if valid { "accepted" } else { "rejected" }
952            );
953        }
954    }
955
956    #[test]
957    fn precompute_counter_path_matrix() {
958        for (counter, expected) in [
959            ("i", vec!["temp_data", "i"]),
960            ("cursor.index", vec!["temp_data", "cursor", "index"]),
961            ("a.b.c", vec!["temp_data", "a", "b", "c"]),
962            // The `#` prefix is preserved here and stripped at write time,
963            // exactly as `MapMapping::path_parts` treats it.
964            ("#7", vec!["temp_data", "#7"]),
965        ] {
966            let mut cfg = loop_wf(&format!(r#"{{"max": 5, "counter": "{counter}"}}"#))
967                .expect("valid loop")
968                .loop_config
969                .expect("loop config present");
970            cfg.precompute_paths();
971            let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
972            assert_eq!(parts, expected, "for counter {counter:?}");
973        }
974    }
975
976    #[test]
977    fn precompute_counter_path_is_idempotent() {
978        // The compiler runs once, but a hot reload recompiles the same config;
979        // calling twice must not accumulate segments.
980        let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
981            .expect("valid loop")
982            .loop_config
983            .expect("loop config present");
984        cfg.precompute_paths();
985        let first: Vec<Arc<str>> = cfg.counter_parts.to_vec();
986        cfg.precompute_paths();
987        assert_eq!(cfg.counter_parts.to_vec(), first);
988    }
989
990    #[test]
991    fn loop_config_rejects_a_non_object_and_a_non_numeric_max() {
992        for json in [r#""five""#, "5", "[]", r#"{"max": "5"}"#, "true"] {
993            assert!(loop_wf(json).is_err(), "{json} is not a valid loop config");
994        }
995    }
996
997    #[test]
998    fn an_explicit_null_loop_means_no_loop() {
999        // `Option<LoopConfig>` takes an explicit JSON null as absence, so a
1000        // caller emitting `"loop": null` for "no loop" gets the single-pass
1001        // workflow they meant rather than a deserialization error.
1002        let workflow = loop_wf("null").expect("explicit null should be accepted");
1003        assert!(workflow.loop_config.is_none());
1004    }
1005
1006    #[test]
1007    fn a_workflow_with_a_loop_still_validates_its_other_rules() {
1008        // Loop validation is additive: the pre-existing rules still fire, and
1009        // an otherwise-invalid workflow is not rescued by a valid loop.
1010        let duplicate_tasks = Workflow::from_json(
1011            r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [
1012                 {"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}},
1013                 {"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}}] }"#,
1014        )
1015        .expect("should parse");
1016        assert!(duplicate_tasks.validate().is_err(), "duplicate task ids");
1017
1018        let no_tasks =
1019            Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [] }"#)
1020                .expect("should parse");
1021        assert!(no_tasks.validate().is_err(), "empty task list");
1022    }
1023
1024    #[test]
1025    fn loop_config_coexists_with_every_other_workflow_field() {
1026        // `loop` is orthogonal to the rest of the schema — nothing it adds
1027        // shadows or disturbs a neighbouring field.
1028        let workflow = Workflow::from_json(&format!(
1029            r#"{{ "id": "w", "name": "w", "priority": 7, "description": "d",
1030                  "condition": {{"==": [1, 1]}},
1031                  "loop": {{"counter": "i", "max": 5}},
1032                  "continue_on_error": true, "channel": "c", "version": 3,
1033                  "status": "paused",
1034                  "rollout": {{"bucket_start": 0, "bucket_end": 50}},
1035                  "tags": ["x"], "tasks": [{MAP}] }}"#
1036        ))
1037        .expect("should parse");
1038        workflow.validate().expect("should validate");
1039
1040        assert_eq!(workflow.priority, 7);
1041        assert_eq!(workflow.channel, "c");
1042        assert_eq!(workflow.version, 3);
1043        assert_eq!(workflow.status, WorkflowStatus::Paused);
1044        assert!(workflow.continue_on_error);
1045        assert_eq!(
1046            workflow.rollout,
1047            Some(Rollout {
1048                bucket_start: 0,
1049                bucket_end: 50
1050            })
1051        );
1052        assert_eq!(workflow.tags, ["x"]);
1053        assert_eq!(
1054            workflow
1055                .loop_config
1056                .expect("loop present")
1057                .counter
1058                .as_deref(),
1059            Some("i")
1060        );
1061    }
1062
1063    #[test]
1064    fn loop_config_accepts_a_valid_counter() {
1065        let cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
1066            .expect("valid loop")
1067            .loop_config
1068            .expect("loop config present");
1069        assert_eq!(cfg.counter.as_deref(), Some("cursor.index"));
1070    }
1071
1072    #[test]
1073    fn precompute_counter_path_prefixes_temp_data() {
1074        let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
1075            .expect("valid loop")
1076            .loop_config
1077            .expect("loop config present");
1078        assert!(
1079            cfg.counter_parts.is_empty(),
1080            "uncompiled workflows start with no pre-split path"
1081        );
1082
1083        cfg.precompute_paths();
1084
1085        let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
1086        assert_eq!(parts, ["temp_data", "cursor", "index"]);
1087    }
1088
1089    #[test]
1090    fn precompute_counter_path_is_empty_without_a_counter_name() {
1091        let mut cfg = loop_wf(r#"{"max": 5}"#)
1092            .expect("valid loop")
1093            .loop_config
1094            .expect("loop config present");
1095        cfg.precompute_paths();
1096        assert!(cfg.counter_parts.is_empty());
1097    }
1098
1099    #[test]
1100    fn loop_config_parses_setup_over_as_and_scratch() {
1101        let cfg = loop_wf(
1102            r#"{"counter": "i", "max": 64, "as": "item", "scratch": "it",
1103                "over": {"var": "temp_data.batch.items"},
1104                "setup": [
1105                  {"id": "claim", "name": "claim", "function": {"name": "map", "input": {"mappings": []}}},
1106                  {"id": "read", "condition": true, "tasks": [
1107                    {"id": "batch", "name": "batch", "function": {"name": "map", "input": {"mappings": []}}}
1108                  ]}
1109                ]}"#,
1110        )
1111        .expect("valid loop")
1112        .loop_config
1113        .expect("loop config present");
1114        let setup_ids: Vec<&str> = cfg.setup.iter().map(|t| t.id.as_str()).collect();
1115        assert_eq!(
1116            setup_ids,
1117            ["claim", "batch"],
1118            "setup is flattened like tasks"
1119        );
1120        assert_eq!(
1121            cfg.setup[1].group_starts.len(),
1122            1,
1123            "the group span is recorded"
1124        );
1125        assert_eq!(cfg.item.as_deref(), Some("item"));
1126        assert_eq!(cfg.scratch.as_deref(), Some("it"));
1127        assert!(cfg.over.is_some());
1128        assert!(
1129            cfg.compiled_over.is_none(),
1130            "uncompiled until the engine builds"
1131        );
1132    }
1133
1134    #[test]
1135    fn a_counter_only_loop_has_no_setup_over_as_or_scratch() {
1136        let cfg = loop_wf(r#"{"max": 5}"#).unwrap().loop_config.unwrap();
1137        assert!(cfg.setup.is_empty());
1138        assert!(cfg.over.is_none());
1139        assert!(cfg.item.is_none());
1140        assert!(cfg.scratch.is_none());
1141
1142        let bounded = LoopConfig::bounded(5);
1143        assert_eq!(
1144            (bounded.init, bounded.increment, bounded.max),
1145            (0, 1, 5),
1146            "bounded() lands on the serde defaults"
1147        );
1148        assert!(bounded.setup.is_empty() && bounded.over.is_none());
1149        assert!(bounded.counter.is_none() && bounded.item.is_none() && bounded.scratch.is_none());
1150    }
1151
1152    #[test]
1153    fn loop_config_rejects_as_without_over() {
1154        let err = loop_wf(r#"{"max": 5, "as": "item"}"#).expect_err("as needs over");
1155        assert!(err.to_string().contains("no `over`"), "got: {err}");
1156        assert!(loop_wf(r#"{"max": 5, "over": {"var": "data.items"}, "as": "item"}"#).is_ok());
1157        assert!(
1158            loop_wf(r#"{"max": 5, "over": {"var": "data.items"}}"#).is_ok(),
1159            "as is optional"
1160        );
1161    }
1162
1163    #[test]
1164    fn loop_config_rejects_a_scalar_over_literal() {
1165        for over in [r#""data.items""#, "5", "true", "null"] {
1166            let json = format!(r#"{{"max": 5, "over": {over}}}"#);
1167            assert!(loop_wf(&json).is_err(), "{json} must be rejected");
1168        }
1169        for over in [r#"{"var": "data.items"}"#, "[1, 2, 3]", "[]"] {
1170            let json = format!(r#"{{"max": 5, "over": {over}}}"#);
1171            assert!(
1172                loop_wf(&json).is_ok(),
1173                "{json} is an expression or an array literal"
1174            );
1175        }
1176    }
1177
1178    #[test]
1179    fn loop_config_rejects_a_negative_init_with_over() {
1180        assert!(loop_wf(r#"{"init": -1, "max": 5, "over": []}"#).is_err());
1181        assert!(
1182            loop_wf(r#"{"init": -1, "max": 5}"#).is_ok(),
1183            "fine without over, as today"
1184        );
1185        assert!(
1186            loop_wf(r#"{"init": 2, "max": 5, "over": []}"#).is_ok(),
1187            "an offset is fine"
1188        );
1189    }
1190
1191    #[test]
1192    fn loop_config_slot_path_and_collision_matrix() {
1193        for (counter, item, scratch, valid) in [
1194            ("i", "item", "it", true),
1195            ("i", "it.item", "scratch", true),
1196            ("a.b", "a.c", "a.d", true),
1197            ("i", "i", "it", false),        // as == counter
1198            ("i", "item", "item", false),   // scratch == as
1199            ("it", "it.item", "x", false),  // as inside the counter
1200            ("i", "item", "item.x", false), // scratch inside as
1201            ("it.x", "item", "it", false),  // counter inside scratch
1202        ] {
1203            let json = format!(
1204                r#"{{"max": 5, "over": [], "counter": "{counter}", "as": "{item}", "scratch": "{scratch}"}}"#
1205            );
1206            assert_eq!(loop_wf(&json).is_ok(), valid, "{json}");
1207        }
1208        for (field, path) in [
1209            ("as", ""),
1210            ("as", "a..b"),
1211            ("scratch", "."),
1212            ("scratch", "a."),
1213        ] {
1214            let json = format!(r#"{{"max": 5, "over": [], "{field}": "{path}"}}"#);
1215            assert!(loop_wf(&json).is_err(), "{json} is a malformed slot path");
1216        }
1217    }
1218
1219    #[test]
1220    fn slots_overlap_is_segment_aware() {
1221        assert!(slots_overlap("it", "it"));
1222        assert!(slots_overlap("it", "it.item"));
1223        assert!(slots_overlap("it.item", "it"));
1224        assert!(
1225            !slots_overlap("it", "item"),
1226            "a shared prefix is not nesting"
1227        );
1228        assert!(!slots_overlap("a.b", "a.c"));
1229    }
1230
1231    #[test]
1232    fn a_setup_step_sharing_an_id_with_a_body_task_is_rejected() {
1233        let workflow = Workflow::from_json(&format!(
1234            r#"{{ "id": "w", "name": "w",
1235                  "loop": {{"max": 5, "setup": [{MAP}]}},
1236                  "tasks": [{MAP}] }}"#
1237        ))
1238        .expect("parses");
1239        let err = workflow
1240            .validate()
1241            .expect_err("setup and body share one id namespace");
1242        assert!(err.to_string().contains("Duplicate"), "got: {err}");
1243    }
1244
1245    #[test]
1246    fn a_setup_group_carrying_halt_on_is_refused_at_parse() {
1247        let err = Workflow::from_json(&format!(
1248            r#"{{ "id": "w", "name": "w",
1249                  "loop": {{"max": 5, "setup": [
1250                    {{"id": "g", "halt_on": "failure", "tasks": [{MAP}]}}]}},
1251                  "tasks": [{LOG}] }}"#
1252        ))
1253        .expect_err("the step grammar applies to setup");
1254        assert!(err.to_string().contains("halt_on"), "got: {err}");
1255    }
1256
1257    #[test]
1258    fn all_tasks_yields_setup_then_body_and_connector_refs_covers_setup() {
1259        let workflow = Workflow::from_json(&format!(
1260            r#"{{ "id": "w", "name": "w",
1261                  "loop": {{"max": 5, "setup": [{HTTP}]}},
1262                  "tasks": [{MAP}, {KAFKA}] }}"#
1263        ))
1264        .expect("parses");
1265        let ids: Vec<&str> = workflow.all_tasks().map(|t| t.id.as_str()).collect();
1266        assert_eq!(ids, ["call", "m", "pub"]);
1267        let refs: Vec<&str> = workflow.connector_refs().map(|r| r.task_id).collect();
1268        assert_eq!(
1269            refs,
1270            ["call", "pub"],
1271            "the setup connector is reported first"
1272        );
1273        assert_eq!(wf(MAP).all_tasks().count(), 1, "no loop: just the body");
1274    }
1275
1276    #[test]
1277    fn precompute_paths_fills_all_three_slots() {
1278        let mut cfg =
1279            loop_wf(r#"{"max": 5, "counter": "i", "over": [], "as": "it.item", "scratch": "scr"}"#)
1280                .unwrap()
1281                .loop_config
1282                .unwrap();
1283        assert!(cfg.item_parts.is_empty() && cfg.scratch_parts.is_empty());
1284        cfg.precompute_paths();
1285        let parts = |p: &Arc<[Arc<str>]>| p.iter().map(|s| s.to_string()).collect::<Vec<_>>();
1286        assert_eq!(parts(&cfg.counter_parts), ["temp_data", "i"]);
1287        assert_eq!(parts(&cfg.item_parts), ["temp_data", "it", "item"]);
1288        assert_eq!(parts(&cfg.scratch_parts), ["temp_data", "scr"]);
1289    }
1290
1291    #[test]
1292    fn rollout_deserializes_from_json() {
1293        let workflow = Workflow::from_json(
1294            r#"{ "id": "w", "name": "w", "condition": true,
1295                 "rollout": { "bucket_start": 0, "bucket_end": 50 },
1296                 "tasks": [] }"#,
1297        )
1298        .unwrap();
1299        assert_eq!(
1300            workflow.rollout,
1301            Some(Rollout {
1302                bucket_start: 0,
1303                bucket_end: 50
1304            })
1305        );
1306    }
1307}