1use crate::dag::WorkflowDag;
11use crate::config::WorkflowConfig;
12
13#[derive(Debug, Clone, PartialEq)]
15pub struct ValidationIssue {
16 pub severity: ValidationSeverity,
18
19 pub code: ValidationCode,
21
22 pub description: String,
24
25 pub step_ids: Vec<String>,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ValidationSeverity {
32 Error,
34 Warning,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum ValidationCode {
41 CycleDetected,
43 UndefinedDependency,
45 DuplicateStepId,
47 EmptyStep,
49 OrphanNode,
51 SelfDependency,
53 MissingApprovalGate,
55}
56
57#[derive(Debug, Clone)]
59pub struct ValidationResult {
60 pub is_valid: bool,
62
63 pub issues: Vec<ValidationIssue>,
65}
66
67impl ValidationResult {
68 pub fn valid() -> Self {
70 Self {
71 is_valid: true,
72 issues: Vec::new(),
73 }
74 }
75
76 pub fn with_issues(issues: Vec<ValidationIssue>) -> Self {
78 let has_errors = issues.iter().any(|i| i.severity == ValidationSeverity::Error);
79 Self {
80 is_valid: !has_errors,
81 issues,
82 }
83 }
84
85 pub fn errors(&self) -> Vec<&ValidationIssue> {
87 self.issues.iter().filter(|i| i.severity == ValidationSeverity::Error).collect()
88 }
89
90 pub fn warnings(&self) -> Vec<&ValidationIssue> {
92 self.issues.iter().filter(|i| i.severity == ValidationSeverity::Warning).collect()
93 }
94}
95
96pub fn validate_config(config: &WorkflowConfig) -> ValidationResult {
104 let mut issues = Vec::new();
105
106 let mut seen_ids = std::collections::HashSet::new();
108 for step in &config.steps {
109 if seen_ids.contains(&step.id) {
110 issues.push(ValidationIssue {
111 severity: ValidationSeverity::Error,
112 code: ValidationCode::DuplicateStepId,
113 description: format!("Duplicate step ID: '{}'", step.id),
114 step_ids: vec![step.id.clone()],
115 });
116 }
117 seen_ids.insert(step.id.clone());
118 }
119
120 let step_ids: std::collections::HashSet<String> = config.steps.iter()
121 .map(|s| s.id.clone())
122 .collect();
123
124 for step in &config.steps {
126 for dep_id in &step.depends_on {
127 if !step_ids.contains(dep_id) {
128 issues.push(ValidationIssue {
129 severity: ValidationSeverity::Error,
130 code: ValidationCode::UndefinedDependency,
131 description: format!(
132 "Step '{}' depends on undefined step '{}'",
133 step.id, dep_id
134 ),
135 step_ids: vec![step.id.clone(), dep_id.clone()],
136 });
137 }
138 }
139 }
140
141 for step in &config.steps {
143 if step.depends_on.contains(&step.id) {
144 issues.push(ValidationIssue {
145 severity: ValidationSeverity::Error,
146 code: ValidationCode::SelfDependency,
147 description: format!("Step '{}' depends on itself", step.id),
148 step_ids: vec![step.id.clone()],
149 });
150 }
151 }
152
153 for step in &config.steps {
155 if step.tool.is_none() && step.prompt.is_none() && !step.requires_approval {
156 issues.push(ValidationIssue {
157 severity: ValidationSeverity::Warning,
158 code: ValidationCode::EmptyStep,
159 description: format!(
160 "Step '{}' has no tool, prompt, or approval checkpoint",
161 step.id
162 ),
163 step_ids: vec![step.id.clone()],
164 });
165 }
166 }
167
168 ValidationResult::with_issues(issues)
169}
170
171pub fn validate_dag(dag: &WorkflowDag) -> ValidationResult {
177 let mut issues = Vec::new();
178
179 if dag.has_cycle() {
181 issues.push(ValidationIssue {
182 severity: ValidationSeverity::Error,
183 code: ValidationCode::CycleDetected,
184 description: "Workflow contains a circular dependency cycle".to_string(),
185 step_ids: vec![],
186 });
187 }
188
189 if !dag.roots.is_empty() {
191 let reachable: std::collections::HashSet<String> = dag.roots.iter()
193 .flat_map(|root_id| {
194 let mut visited = std::collections::HashSet::new();
195 let mut queue = std::collections::VecDeque::new();
196 queue.push_back(root_id.clone());
197 while let Some(id) = queue.pop_front() {
198 if visited.contains(&id) {
199 continue;
200 }
201 visited.insert(id.clone());
202 if let Some(node) = dag.get_node(&id) {
204 for dep_id in &node.depends_on {
205 if !visited.contains(dep_id) {
206 queue.push_back(dep_id.clone());
207 }
208 }
209 for child_id in &node.children {
210 if !visited.contains(child_id) {
211 queue.push_back(child_id.clone());
212 }
213 }
214 }
215 }
216 visited
217 })
218 .collect();
219
220 for (id, _) in &dag.nodes {
221 if !reachable.contains(id) {
222 issues.push(ValidationIssue {
223 severity: ValidationSeverity::Warning,
224 code: ValidationCode::OrphanNode,
225 description: format!("Node '{}' is unreachable from any root", id),
226 step_ids: vec![id.clone()],
227 });
228 }
229 }
230 } else if !dag.nodes.is_empty() && dag.roots.is_empty() {
231 issues.push(ValidationIssue {
233 severity: ValidationSeverity::Error,
234 code: ValidationCode::CycleDetected,
235 description: "No root nodes found — all nodes may be in a cycle".to_string(),
236 step_ids: vec![],
237 });
238 }
239
240 for level in &dag.levels {
242 for node_id in level {
243 if let Some(node) = dag.get_node(node_id) {
244 for dep_id in &node.depends_on {
245 if level.contains(dep_id) {
246 issues.push(ValidationIssue {
247 severity: ValidationSeverity::Error,
248 code: ValidationCode::CycleDetected,
249 description: format!(
250 "Node '{}' at same level as its dependency '{}'",
251 node_id, dep_id
252 ),
253 step_ids: vec![node_id.clone(), dep_id.clone()],
254 });
255 }
256 }
257 }
258 }
259 }
260
261 ValidationResult::with_issues(issues)
262}
263
264pub fn validate(config: &WorkflowConfig, dag: &WorkflowDag) -> ValidationResult {
266 let config_result = validate_config(config);
267 let dag_result = validate_dag(dag);
268
269 let all_issues: Vec<ValidationIssue> = config_result.issues.iter()
270 .chain(dag_result.issues.iter())
271 .cloned()
272 .collect();
273
274 ValidationResult::with_issues(all_issues)
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::config::{StepConfig, WorkflowConfig};
281 use crate::dag::{DagNode, WorkflowDag};
282 use crate::compiler::compile;
283 use std::collections::HashMap;
284
285 fn make_step(id: &str, depends_on: Vec<&str>) -> StepConfig {
286 StepConfig {
287 id: id.to_string(),
288 description: format!("Step {}", id),
289 depends_on: depends_on.iter().map(|s| s.to_string()).collect(),
290 tool: Some("test_tool".to_string()),
291 tool_args: None,
292 prompt: None,
293 requires_approval: false,
294 timeout_secs: None,
295 retry_policy: None,
296 metadata: HashMap::new(),
297 }
298 }
299
300 #[test]
301 fn test_validate_valid_config() {
302 let config = WorkflowConfig::new("valid", vec![
303 make_step("step1", vec![]),
304 make_step("step2", vec!["step1"]),
305 ]);
306
307 let result = validate_config(&config);
308 assert!(result.is_valid);
309 assert!(result.issues.is_empty());
310 }
311
312 #[test]
313 fn test_validate_duplicate_ids() {
314 let config = WorkflowConfig::new("invalid", vec![
315 make_step("step1", vec![]),
316 make_step("step1", vec![]),
317 ]);
318
319 let result = validate_config(&config);
320 assert!(!result.is_valid);
321 assert!(result.issues.iter().any(|i| i.code == ValidationCode::DuplicateStepId));
322 }
323
324 #[test]
325 fn test_validate_undefined_dependency() {
326 let config = WorkflowConfig::new("invalid", vec![
327 make_step("step1", vec!["nonexistent"]),
328 ]);
329
330 let result = validate_config(&config);
331 assert!(!result.is_valid);
332 assert!(result.issues.iter().any(|i| i.code == ValidationCode::UndefinedDependency));
333 }
334
335 #[test]
336 fn test_validate_self_dependency() {
337 let config = WorkflowConfig::new("invalid", vec![
338 StepConfig {
339 id: "loop".to_string(),
340 description: "Self-loop".to_string(),
341 depends_on: vec!["loop".to_string()],
342 tool: Some("test".to_string()),
343 tool_args: None,
344 prompt: None,
345 requires_approval: false,
346 timeout_secs: None,
347 retry_policy: None,
348 metadata: HashMap::new(),
349 },
350 ]);
351
352 let result = validate_config(&config);
353 assert!(!result.is_valid);
354 assert!(result.issues.iter().any(|i| i.code == ValidationCode::SelfDependency));
355 }
356
357 #[test]
358 fn test_validate_empty_step_warning() {
359 let config = WorkflowConfig::new("warn", vec![
360 StepConfig {
361 id: "empty".to_string(),
362 description: "Empty step".to_string(),
363 depends_on: vec![],
364 tool: None,
365 tool_args: None,
366 prompt: None,
367 requires_approval: false,
368 timeout_secs: None,
369 retry_policy: None,
370 metadata: HashMap::new(),
371 },
372 ]);
373
374 let result = validate_config(&config);
375 assert!(result.is_valid); assert!(result.issues.iter().any(|i| i.code == ValidationCode::EmptyStep));
378 }
379
380 #[test]
381 fn test_validate_dag_cycle() {
382 let mut dag = WorkflowDag::new("cycle");
383 dag.add_node(DagNode {
384 id: "a".to_string(),
385 step: make_step("a", vec!["b"]),
386 depends_on: vec!["b".to_string()],
387 children: Vec::new(),
388 level: 0,
389 });
390 dag.add_node(DagNode {
391 id: "b".to_string(),
392 step: make_step("b", vec!["a"]),
393 depends_on: vec!["a".to_string()],
394 children: Vec::new(),
395 level: 0,
396 });
397 dag.build();
398
399 let result = validate_dag(&dag);
400 assert!(!result.is_valid);
401 assert!(result.issues.iter().any(|i| i.code == ValidationCode::CycleDetected));
402 }
403
404 #[test]
405 fn test_validate_dag_no_cycle() {
406 let config = WorkflowConfig::new("valid", vec![
407 make_step("step1", vec![]),
408 make_step("step2", vec!["step1"]),
409 ]);
410 let dag = compile(&config);
411
412 let result = validate_dag(&dag);
413 assert!(result.is_valid);
414 }
415
416 #[test]
417 fn test_full_validation_valid() {
418 let config = WorkflowConfig::new("full_valid", vec![
419 make_step("step1", vec![]),
420 make_step("step2", vec!["step1"]),
421 make_step("step3", vec!["step2"]),
422 ]);
423 let dag = compile(&config);
424
425 let result = validate(&config, &dag);
426 assert!(result.is_valid);
427 }
428
429 #[test]
430 fn test_full_validation_invalid() {
431 let config = WorkflowConfig::new("full_invalid", vec![
432 make_step("step1", vec!["nonexistent"]),
433 ]);
434 let dag = compile(&config);
435
436 let result = validate(&config, &dag);
437 assert!(!result.is_valid);
438 }
439}