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