use crate::config::WorkflowConfig;
use crate::dag::WorkflowDag;
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationIssue {
pub severity: ValidationSeverity,
pub code: ValidationCode,
pub description: String,
pub step_ids: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationSeverity {
Error,
Warning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationCode {
CycleDetected,
UndefinedDependency,
DuplicateStepId,
EmptyStep,
OrphanNode,
SelfDependency,
MissingApprovalGate,
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub is_valid: bool,
pub issues: Vec<ValidationIssue>,
}
impl ValidationResult {
pub fn valid() -> Self {
Self {
is_valid: true,
issues: Vec::new(),
}
}
pub fn with_issues(issues: Vec<ValidationIssue>) -> Self {
let has_errors = issues
.iter()
.any(|i| i.severity == ValidationSeverity::Error);
Self {
is_valid: !has_errors,
issues,
}
}
pub fn errors(&self) -> Vec<&ValidationIssue> {
self.issues
.iter()
.filter(|i| i.severity == ValidationSeverity::Error)
.collect()
}
pub fn warnings(&self) -> Vec<&ValidationIssue> {
self.issues
.iter()
.filter(|i| i.severity == ValidationSeverity::Warning)
.collect()
}
}
pub fn validate_config(config: &WorkflowConfig) -> ValidationResult {
let mut issues = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
for step in &config.steps {
if seen_ids.contains(&step.id) {
issues.push(ValidationIssue {
severity: ValidationSeverity::Error,
code: ValidationCode::DuplicateStepId,
description: format!("Duplicate step ID: '{}'", step.id),
step_ids: vec![step.id.clone()],
});
}
seen_ids.insert(step.id.clone());
}
let step_ids: std::collections::HashSet<String> =
config.steps.iter().map(|s| s.id.clone()).collect();
for step in &config.steps {
for dep_id in &step.depends_on {
if !step_ids.contains(dep_id) {
issues.push(ValidationIssue {
severity: ValidationSeverity::Error,
code: ValidationCode::UndefinedDependency,
description: format!(
"Step '{}' depends on undefined step '{}'",
step.id, dep_id
),
step_ids: vec![step.id.clone(), dep_id.clone()],
});
}
}
}
for step in &config.steps {
if step.depends_on.contains(&step.id) {
issues.push(ValidationIssue {
severity: ValidationSeverity::Error,
code: ValidationCode::SelfDependency,
description: format!("Step '{}' depends on itself", step.id),
step_ids: vec![step.id.clone()],
});
}
}
for step in &config.steps {
if step.tool.is_none() && step.prompt.is_none() && !step.requires_approval {
issues.push(ValidationIssue {
severity: ValidationSeverity::Warning,
code: ValidationCode::EmptyStep,
description: format!(
"Step '{}' has no tool, prompt, or approval checkpoint",
step.id
),
step_ids: vec![step.id.clone()],
});
}
}
ValidationResult::with_issues(issues)
}
pub fn validate_dag(dag: &WorkflowDag) -> ValidationResult {
let mut issues = Vec::new();
if dag.has_cycle() {
issues.push(ValidationIssue {
severity: ValidationSeverity::Error,
code: ValidationCode::CycleDetected,
description: "Workflow contains a circular dependency cycle".to_string(),
step_ids: vec![],
});
}
if !dag.roots.is_empty() {
let reachable: std::collections::HashSet<String> = dag
.roots
.iter()
.flat_map(|root_id| {
let mut visited = std::collections::HashSet::new();
let mut queue = std::collections::VecDeque::new();
queue.push_back(root_id.clone());
while let Some(id) = queue.pop_front() {
if visited.contains(&id) {
continue;
}
visited.insert(id.clone());
if let Some(node) = dag.get_node(&id) {
for dep_id in &node.depends_on {
if !visited.contains(dep_id) {
queue.push_back(dep_id.clone());
}
}
for child_id in &node.children {
if !visited.contains(child_id) {
queue.push_back(child_id.clone());
}
}
}
}
visited
})
.collect();
for id in dag.nodes.keys() {
if !reachable.contains(id) {
issues.push(ValidationIssue {
severity: ValidationSeverity::Warning,
code: ValidationCode::OrphanNode,
description: format!("Node '{}' is unreachable from any root", id),
step_ids: vec![id.clone()],
});
}
}
} else if !dag.nodes.is_empty() && dag.roots.is_empty() {
issues.push(ValidationIssue {
severity: ValidationSeverity::Error,
code: ValidationCode::CycleDetected,
description: "No root nodes found — all nodes may be in a cycle".to_string(),
step_ids: vec![],
});
}
for level in &dag.levels {
for node_id in level {
if let Some(node) = dag.get_node(node_id) {
for dep_id in &node.depends_on {
if level.contains(dep_id) {
issues.push(ValidationIssue {
severity: ValidationSeverity::Error,
code: ValidationCode::CycleDetected,
description: format!(
"Node '{}' at same level as its dependency '{}'",
node_id, dep_id
),
step_ids: vec![node_id.clone(), dep_id.clone()],
});
}
}
}
}
}
ValidationResult::with_issues(issues)
}
pub fn validate(config: &WorkflowConfig, dag: &WorkflowDag) -> ValidationResult {
let config_result = validate_config(config);
let dag_result = validate_dag(dag);
let all_issues: Vec<ValidationIssue> = config_result
.issues
.iter()
.chain(dag_result.issues.iter())
.cloned()
.collect();
ValidationResult::with_issues(all_issues)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compiler::compile;
use crate::config::{StepConfig, WorkflowConfig};
use crate::dag::{DagNode, WorkflowDag};
use std::collections::HashMap;
fn make_step(id: &str, depends_on: Vec<&str>) -> StepConfig {
StepConfig {
id: id.to_string(),
description: format!("Step {}", id),
depends_on: depends_on.iter().map(|s| s.to_string()).collect(),
tool: Some("test_tool".to_string()),
tool_args: None,
prompt: None,
requires_approval: false,
timeout_secs: None,
retry_policy: None,
metadata: HashMap::new(),
}
}
#[test]
fn test_validate_valid_config() {
let config = WorkflowConfig::new(
"valid",
vec![
make_step("step1", vec![]),
make_step("step2", vec!["step1"]),
],
);
let result = validate_config(&config);
assert!(result.is_valid);
assert!(result.issues.is_empty());
}
#[test]
fn test_validate_duplicate_ids() {
let config = WorkflowConfig::new(
"invalid",
vec![make_step("step1", vec![]), make_step("step1", vec![])],
);
let result = validate_config(&config);
assert!(!result.is_valid);
assert!(result
.issues
.iter()
.any(|i| i.code == ValidationCode::DuplicateStepId));
}
#[test]
fn test_validate_undefined_dependency() {
let config = WorkflowConfig::new("invalid", vec![make_step("step1", vec!["nonexistent"])]);
let result = validate_config(&config);
assert!(!result.is_valid);
assert!(result
.issues
.iter()
.any(|i| i.code == ValidationCode::UndefinedDependency));
}
#[test]
fn test_validate_self_dependency() {
let config = WorkflowConfig::new(
"invalid",
vec![StepConfig {
id: "loop".to_string(),
description: "Self-loop".to_string(),
depends_on: vec!["loop".to_string()],
tool: Some("test".to_string()),
tool_args: None,
prompt: None,
requires_approval: false,
timeout_secs: None,
retry_policy: None,
metadata: HashMap::new(),
}],
);
let result = validate_config(&config);
assert!(!result.is_valid);
assert!(result
.issues
.iter()
.any(|i| i.code == ValidationCode::SelfDependency));
}
#[test]
fn test_validate_empty_step_warning() {
let config = WorkflowConfig::new(
"warn",
vec![StepConfig {
id: "empty".to_string(),
description: "Empty step".to_string(),
depends_on: vec![],
tool: None,
tool_args: None,
prompt: None,
requires_approval: false,
timeout_secs: None,
retry_policy: None,
metadata: HashMap::new(),
}],
);
let result = validate_config(&config);
assert!(result.is_valid); assert!(result
.issues
.iter()
.any(|i| i.code == ValidationCode::EmptyStep));
}
#[test]
fn test_validate_dag_cycle() {
let mut dag = WorkflowDag::new("cycle");
dag.add_node(DagNode {
id: "a".to_string(),
step: make_step("a", vec!["b"]),
depends_on: vec!["b".to_string()],
children: Vec::new(),
level: 0,
});
dag.add_node(DagNode {
id: "b".to_string(),
step: make_step("b", vec!["a"]),
depends_on: vec!["a".to_string()],
children: Vec::new(),
level: 0,
});
dag.build();
let result = validate_dag(&dag);
assert!(!result.is_valid);
assert!(result
.issues
.iter()
.any(|i| i.code == ValidationCode::CycleDetected));
}
#[test]
fn test_validate_dag_no_cycle() {
let config = WorkflowConfig::new(
"valid",
vec![
make_step("step1", vec![]),
make_step("step2", vec!["step1"]),
],
);
let dag = compile(&config);
let result = validate_dag(&dag);
assert!(result.is_valid);
}
#[test]
fn test_full_validation_valid() {
let config = WorkflowConfig::new(
"full_valid",
vec![
make_step("step1", vec![]),
make_step("step2", vec!["step1"]),
make_step("step3", vec!["step2"]),
],
);
let dag = compile(&config);
let result = validate(&config, &dag);
assert!(result.is_valid);
}
#[test]
fn test_full_validation_invalid() {
let config = WorkflowConfig::new(
"full_invalid",
vec![make_step("step1", vec!["nonexistent"])],
);
let dag = compile(&config);
let result = validate(&config, &dag);
assert!(!result.is_valid);
}
}