use crate::engine::error::{DataflowError, Result};
use crate::engine::functions::{ConnectorName, FunctionConfig};
use crate::engine::task::Task;
use chrono::{DateTime, Utc};
use datalogic_rs::Logic;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use std::path::Path;
use std::sync::Arc;
pub use crate::engine::rollout::{Rollout, RolloutError};
#[derive(Clone, Debug, Deserialize)]
#[non_exhaustive]
pub struct LoopConfig {
#[serde(default)]
pub counter: Option<String>,
#[serde(default)]
pub init: i64,
#[serde(default = "default_increment")]
pub increment: i64,
pub max: i64,
#[serde(default, deserialize_with = "crate::engine::steps::flatten")]
pub setup: Vec<Task>,
#[serde(default, deserialize_with = "present_value")]
pub over: Option<Value>,
#[serde(default, rename = "as")]
pub item: Option<String>,
#[serde(default)]
pub scratch: Option<String>,
#[doc(hidden)]
#[serde(skip)]
pub counter_parts: Arc<[Arc<str>]>,
#[doc(hidden)]
#[serde(skip)]
pub item_parts: Arc<[Arc<str>]>,
#[doc(hidden)]
#[serde(skip)]
pub scratch_parts: Arc<[Arc<str>]>,
#[doc(hidden)]
#[serde(skip)]
pub compiled_over: Option<Arc<Logic>>,
}
fn default_increment() -> i64 {
1
}
fn present_value<'de, D>(deserializer: D) -> std::result::Result<Option<Value>, D::Error>
where
D: serde::Deserializer<'de>,
{
Value::deserialize(deserializer).map(Some)
}
fn no_parts() -> Arc<[Arc<str>]> {
Arc::from([] as [Arc<str>; 0])
}
pub(crate) fn is_slot_path(path: &str) -> bool {
!path.is_empty() && !path.split('.').any(str::is_empty)
}
pub(crate) fn slots_overlap(a: &str, b: &str) -> bool {
fn within(outer: &str, inner: &str) -> bool {
inner
.strip_prefix(outer)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('.'))
}
within(a, b) || within(b, a)
}
pub(crate) fn over_literal_problem(over: &Value) -> Option<String> {
let kind = match over {
Value::Object(_) | Value::Array(_) => return None,
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
};
Some(format!(
"loop over must be a JSONLogic expression or an array literal — \
{kind} can never evaluate to an array"
))
}
impl LoopConfig {
pub fn bounded(max: i64) -> Self {
Self {
counter: None,
init: 0,
increment: default_increment(),
max,
setup: Vec::new(),
over: None,
item: None,
scratch: None,
counter_parts: no_parts(),
item_parts: no_parts(),
scratch_parts: no_parts(),
compiled_over: None,
}
}
fn validate(&self, workflow_id: &str) -> Result<()> {
if self.increment < 1 {
return Err(DataflowError::Workflow(format!(
"Workflow {workflow_id}: loop increment must be >= 1, got {} \
(a non-advancing counter would never reach max)",
self.increment
)));
}
if self.max <= self.init {
return Err(DataflowError::Workflow(format!(
"Workflow {workflow_id}: loop max ({}) must be greater than init ({}) — \
the bound is half-open, so this could never run a sweep",
self.max, self.init
)));
}
for (field, slot) in [
("counter", &self.counter),
("as", &self.item),
("scratch", &self.scratch),
] {
if let Some(path) = slot
&& !is_slot_path(path)
{
return Err(DataflowError::Workflow(format!(
"Workflow {workflow_id}: loop {field} must be a non-empty \
temp_data field path, got {path:?}"
)));
}
}
if self.item.is_some() && self.over.is_none() {
return Err(DataflowError::Workflow(format!(
"Workflow {workflow_id}: loop `as` names a slot for the current element, \
but there is no `over` to take elements from"
)));
}
if let Some(over) = &self.over {
if let Some(problem) = over_literal_problem(over) {
return Err(DataflowError::Workflow(format!(
"Workflow {workflow_id}: {problem}"
)));
}
if self.init < 0 {
return Err(DataflowError::Workflow(format!(
"Workflow {workflow_id}: loop init must be >= 0 when over is set — \
the counter indexes the array, got {}",
self.init
)));
}
}
let slots = [
("counter", self.counter.as_deref()),
("as", self.item.as_deref()),
("scratch", self.scratch.as_deref()),
];
for (i, (name_a, a)) in slots.iter().enumerate() {
for (name_b, b) in &slots[i + 1..] {
if let (Some(a), Some(b)) = (a, b)
&& slots_overlap(a, b)
{
return Err(DataflowError::Workflow(format!(
"Workflow {workflow_id}: loop {name_a} ({a:?}) and {name_b} ({b:?}) \
overlap — each engine-owned slot must be its own temp_data path"
)));
}
}
}
Ok(())
}
#[doc(hidden)]
pub fn precompute_paths(&mut self) {
fn parts(name: Option<&str>) -> Arc<[Arc<str>]> {
match name {
Some(name) => crate::engine::utils::compute_path_parts("temp_data", name),
None => no_parts(),
}
}
self.counter_parts = parts(self.counter.as_deref());
self.item_parts = parts(self.item.as_deref());
self.scratch_parts = parts(self.scratch.as_deref());
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WorkflowStatus {
#[default]
Active,
Paused,
Archived,
}
#[derive(Clone, Debug, Deserialize)]
#[non_exhaustive]
pub struct Workflow {
pub id: String,
#[doc(hidden)]
#[serde(skip)]
pub id_arc: Arc<str>,
pub name: String,
#[serde(default)]
pub priority: u32,
pub description: Option<String>,
#[serde(default = "crate::engine::utils::default_condition")]
pub condition: Value,
#[doc(hidden)]
#[serde(skip)]
pub compiled_condition: Option<Arc<Logic>>,
#[doc(hidden)]
#[serde(skip, default)]
pub fully_sync: bool,
#[serde(deserialize_with = "crate::engine::steps::flatten")]
pub tasks: Vec<Task>,
#[serde(default)]
pub continue_on_error: bool,
#[serde(default = "default_channel")]
pub channel: String,
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub status: WorkflowStatus,
#[serde(default)]
pub rollout: Option<Rollout>,
#[serde(default, rename = "loop")]
pub loop_config: Option<LoopConfig>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub created_at: Option<DateTime<Utc>>,
#[serde(default)]
pub updated_at: Option<DateTime<Utc>>,
}
fn default_channel() -> String {
"default".to_string()
}
fn default_version() -> u32 {
1
}
impl Default for Workflow {
fn default() -> Self {
Self::new()
}
}
impl Workflow {
pub fn new() -> Self {
Self {
id: String::new(),
id_arc: Arc::from(""),
name: String::new(),
priority: 0,
description: None,
condition: Value::Bool(true),
compiled_condition: None,
fully_sync: false,
tasks: Vec::new(),
continue_on_error: false,
channel: default_channel(),
version: 1,
status: WorkflowStatus::Active,
rollout: None,
loop_config: None,
tags: Vec::new(),
created_at: None,
updated_at: None,
}
}
pub fn rule(id: &str, name: &str, condition: Value, tasks: Vec<Task>) -> Self {
Self {
id: id.to_string(),
id_arc: Arc::from(id),
name: name.to_string(),
priority: 0,
description: None,
condition,
compiled_condition: None,
fully_sync: false,
tasks,
continue_on_error: false,
channel: default_channel(),
version: 1,
status: WorkflowStatus::Active,
rollout: None,
loop_config: None,
tags: Vec::new(),
created_at: None,
updated_at: None,
}
}
pub fn from_json(json_str: &str) -> Result<Self> {
serde_json::from_str(json_str).map_err(DataflowError::from_serde)
}
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let json_str = fs::read_to_string(path).map_err(DataflowError::from_io)?;
Self::from_json(&json_str)
}
pub fn validate(&self) -> Result<()> {
if self.id.is_empty() {
return Err(DataflowError::Workflow(
"Workflow id cannot be empty".to_string(),
));
}
if self.name.is_empty() {
return Err(DataflowError::Workflow(
"Workflow name cannot be empty".to_string(),
));
}
if self.tasks.is_empty() {
return Err(DataflowError::Workflow(
"Workflow must have at least one task".to_string(),
));
}
let mut step_ids = std::collections::HashSet::new();
for task in self.all_tasks() {
for group in &task.group_starts {
if !step_ids.insert(group.id.as_str()) {
return Err(DataflowError::Workflow(format!(
"Duplicate step ID '{}' in workflow — task group IDs share the task ID namespace",
group.id
)));
}
}
if !step_ids.insert(task.id.as_str()) {
return Err(DataflowError::Workflow(format!(
"Duplicate task ID '{}' in workflow",
task.id
)));
}
}
for task in self.all_tasks() {
if let Some(for_each) = &task.for_each
&& let Some(problem) = for_each.problem(&task.function)
{
return Err(DataflowError::Workflow(format!(
"Task '{}': {}",
task.id, problem.message
)));
}
}
if let Some(loop_config) = &self.loop_config {
loop_config.validate(&self.id)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
pub struct ConnectorRef<'a> {
pub workflow_id: &'a str,
pub task_id: &'a str,
pub function: &'a str,
pub connector: ConnectorName<'a>,
pub config: &'a FunctionConfig,
}
impl Workflow {
pub fn all_tasks(&self) -> impl Iterator<Item = &Task> {
self.loop_config
.iter()
.flat_map(|l| l.setup.iter())
.chain(self.tasks.iter())
}
pub(crate) fn all_tasks_mut(&mut self) -> impl Iterator<Item = &mut Task> {
self.loop_config
.iter_mut()
.flat_map(|l| l.setup.iter_mut())
.chain(self.tasks.iter_mut())
}
pub fn connector_refs(&self) -> impl Iterator<Item = ConnectorRef<'_>> {
self.all_tasks().filter_map(move |task| {
task.function.connector().map(|connector| ConnectorRef {
workflow_id: &self.id,
task_id: &task.id,
function: task.function.function_name(),
connector,
config: &task.function,
})
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn wf(tasks_json: &str) -> Workflow {
Workflow::from_json(&format!(
r#"{{ "id": "w", "name": "w", "priority": 0, "condition": true,
"tasks": [{tasks_json}] }}"#
))
.expect("workflow should parse")
}
const HTTP: &str = r#"{ "id": "call", "name": "call", "function": {
"name": "http_call", "input": { "connector": "user_service" } } }"#;
const KAFKA: &str = r#"{ "id": "pub", "name": "pub", "function": {
"name": "publish_kafka",
"input": { "connector": "events", "topic": "t" } } }"#;
const MAP: &str = r#"{ "id": "m", "name": "m", "function": {
"name": "map", "input": { "mappings": [] } } }"#;
const LOG: &str = r#"{ "id": "l", "name": "l", "function": {
"name": "log", "input": { "message": "hi" } } }"#;
#[test]
fn connector_refs_yields_only_connector_tasks_in_task_order() {
let workflow = wf(&format!("{MAP},{HTTP},{LOG},{KAFKA}"));
let refs: Vec<_> = workflow.connector_refs().collect();
assert_eq!(refs.len(), 2);
assert_eq!(refs[0].task_id, "call");
assert_eq!(refs[0].function, "http_call");
assert_eq!(refs[0].connector.as_static(), Some("user_service"));
assert_eq!(refs[1].task_id, "pub");
assert_eq!(refs[1].function, "publish_kafka");
assert_eq!(refs[1].connector.as_static(), Some("events"));
}
#[test]
fn connector_refs_carries_the_owning_workflow_id() {
let workflow = wf(HTTP);
assert!(workflow.connector_refs().all(|r| r.workflow_id == "w"));
let empty = Workflow::new();
assert_eq!(empty.id, "");
assert_eq!(empty.connector_refs().count(), 0);
}
#[test]
fn connector_refs_is_empty_for_no_tasks() {
assert_eq!(Workflow::new().connector_refs().count(), 0);
}
#[test]
fn connector_refs_does_not_deduplicate() {
let a = r#"{ "id": "a", "name": "a", "function": {
"name": "http_call", "input": { "connector": "same" } } }"#;
let b = r#"{ "id": "b", "name": "b", "function": {
"name": "enrich",
"input": { "connector": "same", "merge_path": "data.out" } } }"#;
let workflow = wf(&format!("{a},{b}"));
let refs: Vec<_> = workflow.connector_refs().collect();
assert_eq!(refs.len(), 2, "one item per task, not a distinct set");
assert!(refs.iter().all(|r| r.connector.as_static() == Some("same")));
}
#[test]
fn connector_refs_works_on_an_uncompiled_workflow() {
let workflow = wf(HTTP);
assert!(workflow.compiled_condition.is_none());
assert_eq!(workflow.connector_refs().count(), 1);
}
#[test]
fn connector_ref_is_copy() {
let workflow = wf(HTTP);
let r = workflow.connector_refs().next().unwrap();
let copied = r;
assert_eq!(r.connector, copied.connector);
assert_eq!(r.task_id, copied.task_id);
}
#[test]
fn connector_ref_config_supports_a_cross_field_rule() {
let custom = r#"{ "id": "db", "name": "db", "function": {
"name": "pg_query",
"input": { "connector": "pg_main", "database": "orders" } } }"#;
let workflow = wf(custom);
let r = workflow.connector_refs().next().expect("custom connector");
assert_eq!(r.connector.as_static(), Some("pg_main"));
match r.config {
FunctionConfig::Custom { input, .. } => {
assert_eq!(
input.get("database").and_then(|v| v.as_str()),
Some("orders")
);
}
other => panic!("expected Custom, got {other:?}"),
}
}
#[test]
fn rollout_defaults_to_none_on_every_construction_path() {
assert_eq!(Workflow::new().rollout, None);
assert_eq!(Workflow::default().rollout, None);
assert_eq!(
Workflow::rule("r", "r", Value::Bool(true), Vec::new()).rollout,
None
);
assert_eq!(wf(MAP).rollout, None, "absent JSON key gives None");
}
fn loop_wf(loop_json: &str) -> Result<Workflow> {
let workflow = Workflow::from_json(&format!(
r#"{{ "id": "w", "name": "w", "loop": {loop_json}, "tasks": [{MAP}] }}"#
))?;
workflow.validate()?;
Ok(workflow)
}
#[test]
fn loop_config_defaults_init_zero_increment_one() {
let cfg = loop_wf(r#"{"max": 5}"#)
.expect("valid loop")
.loop_config
.expect("loop config present");
assert_eq!(cfg.init, 0);
assert_eq!(cfg.increment, 1);
assert_eq!(cfg.max, 5);
assert_eq!(cfg.counter, None);
}
#[test]
fn loop_config_is_absent_on_every_construction_path() {
assert!(wf(MAP).loop_config.is_none(), "absent JSON key gives None");
assert!(Workflow::new().loop_config.is_none());
assert!(Workflow::default().loop_config.is_none());
assert!(
Workflow::rule("r", "r", Value::Bool(true), Vec::new())
.loop_config
.is_none()
);
}
#[test]
fn loop_config_rejects_a_bound_that_could_never_run_a_sweep() {
assert!(loop_wf(r#"{"max": 0}"#).is_err());
assert!(loop_wf(r#"{"init": 5, "max": 5}"#).is_err());
assert!(loop_wf(r#"{"init": 5, "max": 2}"#).is_err());
}
#[test]
fn loop_config_rejects_a_non_advancing_increment() {
assert!(loop_wf(r#"{"max": 5, "increment": 0}"#).is_err());
assert!(loop_wf(r#"{"max": 5, "increment": -1}"#).is_err());
}
#[test]
fn loop_config_rejects_an_empty_counter_path() {
assert!(loop_wf(r#"{"max": 5, "counter": ""}"#).is_err());
assert!(loop_wf(r#"{"max": 5, "counter": "a..b"}"#).is_err());
assert!(loop_wf(r#"{"max": 5, "counter": "a."}"#).is_err());
}
#[test]
fn loop_config_requires_max() {
assert!(
Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {}, "tasks": [] }"#).is_err()
);
}
#[test]
fn loop_config_deserializes_every_combination_of_optional_fields() {
for (json, counter, init, increment) in [
(r#"{"max": 9}"#, None, 0, 1),
(r#"{"max": 9, "counter": "i"}"#, Some("i"), 0, 1),
(r#"{"max": 9, "init": 4}"#, None, 4, 1),
(r#"{"max": 9, "increment": 3}"#, None, 0, 3),
(r#"{"max": 9, "counter": "i", "init": 4}"#, Some("i"), 4, 1),
(
r#"{"max": 9, "counter": "i", "increment": 3}"#,
Some("i"),
0,
3,
),
(r#"{"max": 9, "init": 4, "increment": 3}"#, None, 4, 3),
(
r#"{"max": 9, "counter": "i", "init": 4, "increment": 3}"#,
Some("i"),
4,
3,
),
] {
let cfg = loop_wf(json)
.unwrap_or_else(|e| panic!("{json} should be valid: {e}"))
.loop_config
.expect("loop config present");
assert_eq!(cfg.counter.as_deref(), counter, "counter for {json}");
assert_eq!(cfg.init, init, "init for {json}");
assert_eq!(cfg.increment, increment, "increment for {json}");
assert_eq!(cfg.max, 9, "max for {json}");
}
}
#[test]
fn loop_config_validation_matrix_over_init_increment_and_max() {
for (init, increment, max, valid) in [
(0_i64, 1_i64, 1_i64, true),
(0, 1, 100, true),
(0, 7, 3, true), (5, 1, 6, true),
(-5, 1, 0, true),
(-5, 2, -4, true),
(-1, 1, 1, true),
(0, 1, 0, false),
(5, 1, 5, false),
(5, 1, 4, false),
(0, 1, -1, false),
(-5, 1, -5, false),
(0, 0, 10, false),
(0, -1, 10, false),
(0, -100, 10, false),
] {
let json = format!(r#"{{"init": {init}, "increment": {increment}, "max": {max}}}"#);
assert_eq!(
loop_wf(&json).is_ok(),
valid,
"init={init} increment={increment} max={max} should be {}",
if valid { "accepted" } else { "rejected" }
);
}
}
#[test]
fn loop_config_counter_path_matrix() {
for (counter, valid) in [
("i", true),
("index", true),
("cursor.index", true),
("a.b.c.d", true),
("#7", true), ("", false),
(".", false),
("a.", false),
(".a", false),
("a..b", false),
] {
let json = format!(r#"{{"max": 5, "counter": "{counter}"}}"#);
assert_eq!(
loop_wf(&json).is_ok(),
valid,
"counter {counter:?} should be {}",
if valid { "accepted" } else { "rejected" }
);
}
}
#[test]
fn precompute_counter_path_matrix() {
for (counter, expected) in [
("i", vec!["temp_data", "i"]),
("cursor.index", vec!["temp_data", "cursor", "index"]),
("a.b.c", vec!["temp_data", "a", "b", "c"]),
("#7", vec!["temp_data", "#7"]),
] {
let mut cfg = loop_wf(&format!(r#"{{"max": 5, "counter": "{counter}"}}"#))
.expect("valid loop")
.loop_config
.expect("loop config present");
cfg.precompute_paths();
let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
assert_eq!(parts, expected, "for counter {counter:?}");
}
}
#[test]
fn precompute_counter_path_is_idempotent() {
let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
.expect("valid loop")
.loop_config
.expect("loop config present");
cfg.precompute_paths();
let first: Vec<Arc<str>> = cfg.counter_parts.to_vec();
cfg.precompute_paths();
assert_eq!(cfg.counter_parts.to_vec(), first);
}
#[test]
fn loop_config_rejects_a_non_object_and_a_non_numeric_max() {
for json in [r#""five""#, "5", "[]", r#"{"max": "5"}"#, "true"] {
assert!(loop_wf(json).is_err(), "{json} is not a valid loop config");
}
}
#[test]
fn an_explicit_null_loop_means_no_loop() {
let workflow = loop_wf("null").expect("explicit null should be accepted");
assert!(workflow.loop_config.is_none());
}
#[test]
fn a_workflow_with_a_loop_still_validates_its_other_rules() {
let duplicate_tasks = Workflow::from_json(
r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [
{"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}},
{"id": "t", "name": "t", "function": {"name": "map", "input": {"mappings": []}}}] }"#,
)
.expect("should parse");
assert!(duplicate_tasks.validate().is_err(), "duplicate task ids");
let no_tasks =
Workflow::from_json(r#"{ "id": "w", "name": "w", "loop": {"max": 5}, "tasks": [] }"#)
.expect("should parse");
assert!(no_tasks.validate().is_err(), "empty task list");
}
#[test]
fn loop_config_coexists_with_every_other_workflow_field() {
let workflow = Workflow::from_json(&format!(
r#"{{ "id": "w", "name": "w", "priority": 7, "description": "d",
"condition": {{"==": [1, 1]}},
"loop": {{"counter": "i", "max": 5}},
"continue_on_error": true, "channel": "c", "version": 3,
"status": "paused",
"rollout": {{"bucket_start": 0, "bucket_end": 50}},
"tags": ["x"], "tasks": [{MAP}] }}"#
))
.expect("should parse");
workflow.validate().expect("should validate");
assert_eq!(workflow.priority, 7);
assert_eq!(workflow.channel, "c");
assert_eq!(workflow.version, 3);
assert_eq!(workflow.status, WorkflowStatus::Paused);
assert!(workflow.continue_on_error);
assert_eq!(
workflow.rollout,
Some(Rollout {
bucket_start: 0,
bucket_end: 50
})
);
assert_eq!(workflow.tags, ["x"]);
assert_eq!(
workflow
.loop_config
.expect("loop present")
.counter
.as_deref(),
Some("i")
);
}
#[test]
fn loop_config_accepts_a_valid_counter() {
let cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
.expect("valid loop")
.loop_config
.expect("loop config present");
assert_eq!(cfg.counter.as_deref(), Some("cursor.index"));
}
#[test]
fn precompute_counter_path_prefixes_temp_data() {
let mut cfg = loop_wf(r#"{"max": 5, "counter": "cursor.index"}"#)
.expect("valid loop")
.loop_config
.expect("loop config present");
assert!(
cfg.counter_parts.is_empty(),
"uncompiled workflows start with no pre-split path"
);
cfg.precompute_paths();
let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
assert_eq!(parts, ["temp_data", "cursor", "index"]);
}
#[test]
fn precompute_counter_path_is_empty_without_a_counter_name() {
let mut cfg = loop_wf(r#"{"max": 5}"#)
.expect("valid loop")
.loop_config
.expect("loop config present");
cfg.precompute_paths();
assert!(cfg.counter_parts.is_empty());
}
#[test]
fn loop_config_parses_setup_over_as_and_scratch() {
let cfg = loop_wf(
r#"{"counter": "i", "max": 64, "as": "item", "scratch": "it",
"over": {"var": "temp_data.batch.items"},
"setup": [
{"id": "claim", "name": "claim", "function": {"name": "map", "input": {"mappings": []}}},
{"id": "read", "condition": true, "tasks": [
{"id": "batch", "name": "batch", "function": {"name": "map", "input": {"mappings": []}}}
]}
]}"#,
)
.expect("valid loop")
.loop_config
.expect("loop config present");
let setup_ids: Vec<&str> = cfg.setup.iter().map(|t| t.id.as_str()).collect();
assert_eq!(
setup_ids,
["claim", "batch"],
"setup is flattened like tasks"
);
assert_eq!(
cfg.setup[1].group_starts.len(),
1,
"the group span is recorded"
);
assert_eq!(cfg.item.as_deref(), Some("item"));
assert_eq!(cfg.scratch.as_deref(), Some("it"));
assert!(cfg.over.is_some());
assert!(
cfg.compiled_over.is_none(),
"uncompiled until the engine builds"
);
}
#[test]
fn a_counter_only_loop_has_no_setup_over_as_or_scratch() {
let cfg = loop_wf(r#"{"max": 5}"#).unwrap().loop_config.unwrap();
assert!(cfg.setup.is_empty());
assert!(cfg.over.is_none());
assert!(cfg.item.is_none());
assert!(cfg.scratch.is_none());
let bounded = LoopConfig::bounded(5);
assert_eq!(
(bounded.init, bounded.increment, bounded.max),
(0, 1, 5),
"bounded() lands on the serde defaults"
);
assert!(bounded.setup.is_empty() && bounded.over.is_none());
assert!(bounded.counter.is_none() && bounded.item.is_none() && bounded.scratch.is_none());
}
#[test]
fn loop_config_rejects_as_without_over() {
let err = loop_wf(r#"{"max": 5, "as": "item"}"#).expect_err("as needs over");
assert!(err.to_string().contains("no `over`"), "got: {err}");
assert!(loop_wf(r#"{"max": 5, "over": {"var": "data.items"}, "as": "item"}"#).is_ok());
assert!(
loop_wf(r#"{"max": 5, "over": {"var": "data.items"}}"#).is_ok(),
"as is optional"
);
}
#[test]
fn loop_config_rejects_a_scalar_over_literal() {
for over in [r#""data.items""#, "5", "true", "null"] {
let json = format!(r#"{{"max": 5, "over": {over}}}"#);
assert!(loop_wf(&json).is_err(), "{json} must be rejected");
}
for over in [r#"{"var": "data.items"}"#, "[1, 2, 3]", "[]"] {
let json = format!(r#"{{"max": 5, "over": {over}}}"#);
assert!(
loop_wf(&json).is_ok(),
"{json} is an expression or an array literal"
);
}
}
#[test]
fn loop_config_rejects_a_negative_init_with_over() {
assert!(loop_wf(r#"{"init": -1, "max": 5, "over": []}"#).is_err());
assert!(
loop_wf(r#"{"init": -1, "max": 5}"#).is_ok(),
"fine without over, as today"
);
assert!(
loop_wf(r#"{"init": 2, "max": 5, "over": []}"#).is_ok(),
"an offset is fine"
);
}
#[test]
fn loop_config_slot_path_and_collision_matrix() {
for (counter, item, scratch, valid) in [
("i", "item", "it", true),
("i", "it.item", "scratch", true),
("a.b", "a.c", "a.d", true),
("i", "i", "it", false), ("i", "item", "item", false), ("it", "it.item", "x", false), ("i", "item", "item.x", false), ("it.x", "item", "it", false), ] {
let json = format!(
r#"{{"max": 5, "over": [], "counter": "{counter}", "as": "{item}", "scratch": "{scratch}"}}"#
);
assert_eq!(loop_wf(&json).is_ok(), valid, "{json}");
}
for (field, path) in [
("as", ""),
("as", "a..b"),
("scratch", "."),
("scratch", "a."),
] {
let json = format!(r#"{{"max": 5, "over": [], "{field}": "{path}"}}"#);
assert!(loop_wf(&json).is_err(), "{json} is a malformed slot path");
}
}
#[test]
fn slots_overlap_is_segment_aware() {
assert!(slots_overlap("it", "it"));
assert!(slots_overlap("it", "it.item"));
assert!(slots_overlap("it.item", "it"));
assert!(
!slots_overlap("it", "item"),
"a shared prefix is not nesting"
);
assert!(!slots_overlap("a.b", "a.c"));
}
#[test]
fn a_setup_step_sharing_an_id_with_a_body_task_is_rejected() {
let workflow = Workflow::from_json(&format!(
r#"{{ "id": "w", "name": "w",
"loop": {{"max": 5, "setup": [{MAP}]}},
"tasks": [{MAP}] }}"#
))
.expect("parses");
let err = workflow
.validate()
.expect_err("setup and body share one id namespace");
assert!(err.to_string().contains("Duplicate"), "got: {err}");
}
#[test]
fn a_setup_group_carrying_halt_on_is_refused_at_parse() {
let err = Workflow::from_json(&format!(
r#"{{ "id": "w", "name": "w",
"loop": {{"max": 5, "setup": [
{{"id": "g", "halt_on": "failure", "tasks": [{MAP}]}}]}},
"tasks": [{LOG}] }}"#
))
.expect_err("the step grammar applies to setup");
assert!(err.to_string().contains("halt_on"), "got: {err}");
}
#[test]
fn all_tasks_yields_setup_then_body_and_connector_refs_covers_setup() {
let workflow = Workflow::from_json(&format!(
r#"{{ "id": "w", "name": "w",
"loop": {{"max": 5, "setup": [{HTTP}]}},
"tasks": [{MAP}, {KAFKA}] }}"#
))
.expect("parses");
let ids: Vec<&str> = workflow.all_tasks().map(|t| t.id.as_str()).collect();
assert_eq!(ids, ["call", "m", "pub"]);
let refs: Vec<&str> = workflow.connector_refs().map(|r| r.task_id).collect();
assert_eq!(
refs,
["call", "pub"],
"the setup connector is reported first"
);
assert_eq!(wf(MAP).all_tasks().count(), 1, "no loop: just the body");
}
#[test]
fn precompute_paths_fills_all_three_slots() {
let mut cfg =
loop_wf(r#"{"max": 5, "counter": "i", "over": [], "as": "it.item", "scratch": "scr"}"#)
.unwrap()
.loop_config
.unwrap();
assert!(cfg.item_parts.is_empty() && cfg.scratch_parts.is_empty());
cfg.precompute_paths();
let parts = |p: &Arc<[Arc<str>]>| p.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(parts(&cfg.counter_parts), ["temp_data", "i"]);
assert_eq!(parts(&cfg.item_parts), ["temp_data", "it", "item"]);
assert_eq!(parts(&cfg.scratch_parts), ["temp_data", "scr"]);
}
#[test]
fn rollout_deserializes_from_json() {
let workflow = Workflow::from_json(
r#"{ "id": "w", "name": "w", "condition": true,
"rollout": { "bucket_start": 0, "bucket_end": 50 },
"tasks": [] }"#,
)
.unwrap();
assert_eq!(
workflow.rollout,
Some(Rollout {
bucket_start: 0,
bucket_end: 50
})
);
}
}