use super::task::{Task, TaskGroup};
use serde::Deserialize;
use serde::de::{Deserializer, Error as DeError};
use serde_json::Value;
pub const MAX_GROUP_DEPTH: usize = 8;
#[inline]
pub fn is_group(step: &Value) -> bool {
step.get("tasks").is_some()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepKind {
Leaf,
Group,
TooDeep,
}
#[derive(Debug, Clone)]
pub struct AuthoredStep<'a> {
pub path: String,
pub node: &'a Value,
pub kind: StepKind,
pub depth: usize,
}
pub fn walk_authored_steps(tasks: &Value) -> AuthoredSteps<'_> {
AuthoredSteps {
stack: match tasks.as_array() {
Some(items) => vec![Frame {
items,
idx: 0,
prefix: "tasks".to_string(),
depth: 0,
}],
None => Vec::new(),
},
}
}
struct Frame<'a> {
items: &'a [Value],
idx: usize,
prefix: String,
depth: usize,
}
pub struct AuthoredSteps<'a> {
stack: Vec<Frame<'a>>,
}
impl<'a> Iterator for AuthoredSteps<'a> {
type Item = AuthoredStep<'a>;
fn next(&mut self) -> Option<Self::Item> {
loop {
let frame = self.stack.last_mut()?;
let Some(node) = frame.items.get(frame.idx) else {
self.stack.pop();
continue;
};
let path = format!("{}[{}]", frame.prefix, frame.idx);
let depth = frame.depth;
frame.idx += 1;
if !is_group(node) {
return Some(AuthoredStep {
path,
node,
kind: StepKind::Leaf,
depth,
});
}
if depth >= MAX_GROUP_DEPTH {
return Some(AuthoredStep {
path,
node,
kind: StepKind::TooDeep,
depth,
});
}
if let Some(children) = node.get("tasks").and_then(Value::as_array) {
self.stack.push(Frame {
items: children,
idx: 0,
prefix: format!("{path}.tasks"),
depth: depth + 1,
});
}
return Some(AuthoredStep {
path,
node,
kind: StepKind::Group,
depth,
});
}
}
}
#[derive(Deserialize)]
struct GroupHeader {
id: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default = "crate::engine::utils::default_condition")]
condition: Value,
#[serde(default)]
terminal: bool,
#[serde(default)]
halt_on: Option<Value>,
#[serde(default)]
continue_on_error: Option<Value>,
tasks: Vec<Value>,
}
pub(crate) fn flatten<'de, D>(deserializer: D) -> Result<Vec<Task>, D::Error>
where
D: Deserializer<'de>,
{
let steps = Vec::<Value>::deserialize(deserializer)?;
let mut tasks = Vec::with_capacity(steps.len());
walk(&steps, 0, &mut tasks).map_err(D::Error::custom)?;
Ok(tasks)
}
fn walk(steps: &[Value], depth: usize, out: &mut Vec<Task>) -> Result<(), String> {
for step in steps {
if !is_group(step) {
let task: Task = serde_json::from_value(step.clone())
.map_err(|e| format!("invalid task in workflow tasks: {e}"))?;
out.push(task);
continue;
}
if depth >= MAX_GROUP_DEPTH {
return Err(format!(
"task groups nested deeper than {MAX_GROUP_DEPTH} levels"
));
}
let header: GroupHeader = serde_json::from_value(step.clone())
.map_err(|e| format!("invalid task group in workflow tasks: {e}"))?;
if header.halt_on.is_some() {
return Err(format!(
"task group '{}' cannot carry halt_on — halt_on is a per-task \
outcome rule; put it on the task that can fail",
header.id
));
}
let start = out.len();
walk(&header.tasks, depth + 1, out)?;
let end = out.len();
if end == start {
return Err(format!(
"task group '{}' contains no tasks — an empty group can only be a mistake",
header.id
));
}
out[start].group_starts.insert(
0,
TaskGroup {
id: header.id,
name: header.name,
description: header.description,
condition: header.condition,
compiled_condition: None,
terminal: header.terminal,
continue_on_error: matches!(header.continue_on_error, Some(Value::Bool(true))),
end,
},
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::workflow::Workflow;
use serde_json::json;
fn leaf(id: &str) -> Value {
json!({"id": id, "name": id, "function": {"name": "map", "input": {"mappings": []}}})
}
fn nested_groups(n: usize) -> Value {
let mut node = leaf("innermost");
for level in (0..n).rev() {
node = json!({"id": format!("g{level}"), "condition": true, "tasks": [node]});
}
json!([node])
}
fn workflow_with(tasks: &Value) -> Result<Workflow, String> {
Workflow::from_json(
&json!({"id": "w", "name": "w", "priority": 0, "tasks": tasks}).to_string(),
)
.map_err(|e| e.to_string())
}
fn kinds(tasks: &Value) -> Vec<(String, StepKind, usize)> {
walk_authored_steps(tasks)
.map(|s| (s.path, s.kind, s.depth))
.collect()
}
#[test]
fn walker_leaves_match_the_parsers_flattened_tasks() {
let fixtures = vec![
json!([leaf("a"), leaf("b")]),
json!([{"id": "g", "condition": true, "tasks": [leaf("a"), leaf("b")]}]),
json!([
leaf("before"),
{"id": "g1", "condition": true, "tasks": [
leaf("in1"),
{"id": "g2", "condition": true, "tasks": [leaf("deep")]},
leaf("in2"),
]},
leaf("after"),
]),
nested_groups(MAX_GROUP_DEPTH),
];
for tasks in fixtures {
let parsed = workflow_with(&tasks).expect("fixture parses");
let from_parser: Vec<&str> = parsed.tasks.iter().map(|t| t.id.as_str()).collect();
let from_walker: Vec<&str> = walk_authored_steps(&tasks)
.filter(|s| s.kind == StepKind::Leaf)
.map(|s| s.node["id"].as_str().unwrap())
.collect();
assert_eq!(
from_walker, from_parser,
"walker leaves must equal the flattened tasks, in order, for {tasks}"
);
}
}
#[test]
fn paths_are_the_coordinates_the_author_typed() {
let tasks = json!([
leaf("first"),
{"id": "g", "condition": true, "tasks": [leaf("inner"), leaf("second")]},
]);
let paths: Vec<String> = walk_authored_steps(&tasks).map(|s| s.path).collect();
assert_eq!(
paths,
vec![
"tasks[0]",
"tasks[1]",
"tasks[1].tasks[0]",
"tasks[1].tasks[1]"
]
);
}
#[test]
fn groups_are_yielded_before_their_members() {
let tasks = json!([{"id": "g", "condition": true, "tasks": [leaf("inner")]}]);
assert_eq!(
kinds(&tasks),
vec![
("tasks[0]".to_string(), StepKind::Group, 0),
("tasks[0].tasks[0]".to_string(), StepKind::Leaf, 1),
],
"pre-order, so filtering to Leaf reproduces parse order"
);
}
#[test]
fn max_group_depth_is_the_value_the_parser_enforces() {
let ok = nested_groups(MAX_GROUP_DEPTH);
assert!(
workflow_with(&ok).is_ok(),
"{MAX_GROUP_DEPTH} levels of nesting is accepted"
);
assert!(
walk_authored_steps(&ok).all(|s| s.kind != StepKind::TooDeep),
"and the walker agrees nothing is too deep"
);
let too_deep = nested_groups(MAX_GROUP_DEPTH + 1);
let err = workflow_with(&too_deep).expect_err("one level past the cap is rejected");
assert!(
err.contains("nested deeper than"),
"parser reports the depth cap, got: {err}"
);
let flagged: Vec<_> = walk_authored_steps(&too_deep)
.filter(|s| s.kind == StepKind::TooDeep)
.collect();
assert_eq!(flagged.len(), 1, "exactly the one offending group");
assert_eq!(flagged[0].depth, MAX_GROUP_DEPTH);
assert_eq!(flagged[0].node["id"], json!(format!("g{MAX_GROUP_DEPTH}")));
}
#[test]
fn a_too_deep_group_is_not_descended_into() {
let tasks = nested_groups(MAX_GROUP_DEPTH + 1);
let deepest = walk_authored_steps(&tasks).map(|s| s.depth).max().unwrap();
assert_eq!(
deepest, MAX_GROUP_DEPTH,
"the walk stops at the offending group; its members are never yielded"
);
assert!(
!walk_authored_steps(&tasks).any(|s| s.node["id"] == json!("innermost")),
"the leaf below the cap is unreachable, and reported as such by its absent parent"
);
}
#[test]
fn a_leaf_is_never_too_deep() {
let tasks = nested_groups(MAX_GROUP_DEPTH);
let innermost = walk_authored_steps(&tasks)
.find(|s| s.node["id"] == json!("innermost"))
.expect("the deepest leaf is yielded");
assert_eq!(innermost.kind, StepKind::Leaf);
assert_eq!(innermost.depth, MAX_GROUP_DEPTH);
}
#[test]
fn an_element_with_neither_tasks_nor_function_is_a_leaf() {
let tasks = json!([{"id": "orphan"}]);
assert_eq!(
kinds(&tasks),
vec![("tasks[0]".to_string(), StepKind::Leaf, 0)]
);
let err = workflow_with(&tasks).expect_err("the parser rejects it");
assert!(
err.contains("invalid task in workflow tasks"),
"and calls it a task, got: {err}"
);
}
#[test]
fn a_tasks_key_that_is_not_an_array_is_still_a_group() {
let tasks = json!([{"id": "g", "tasks": "oops"}]);
assert!(is_group(&tasks[0]));
assert_eq!(
kinds(&tasks),
vec![("tasks[0]".to_string(), StepKind::Group, 0)],
"a malformed group with no members, not a task"
);
let err = workflow_with(&tasks).expect_err("the parser rejects it");
assert!(
err.contains("invalid task group"),
"and calls it a group, got: {err}"
);
}
#[test]
fn an_empty_group_is_yielded_not_an_error() {
let tasks = json!([{"id": "empty", "condition": true, "tasks": []}]);
assert_eq!(
kinds(&tasks),
vec![("tasks[0]".to_string(), StepKind::Group, 0)]
);
let err = workflow_with(&tasks).expect_err("the parser rejects an empty group");
assert!(err.contains("contains no tasks"), "got: {err}");
}
#[test]
fn a_non_array_input_yields_nothing() {
for input in [
Value::Null,
json!({}),
json!("tasks"),
json!(7),
json!({"tasks": []}),
] {
assert_eq!(
walk_authored_steps(&input).count(),
0,
"not an array, so nothing to walk: {input}"
);
}
}
#[test]
fn an_empty_array_yields_nothing_and_leaves_no_frame_behind() {
assert_eq!(walk_authored_steps(&json!([])).count(), 0);
let tasks = json!([{"id": "g", "tasks": [{"id": "inner", "tasks": []}]}]);
assert_eq!(
kinds(&tasks),
vec![
("tasks[0]".to_string(), StepKind::Group, 0),
("tasks[0].tasks[0]".to_string(), StepKind::Group, 1),
]
);
}
}