1use crate::config::WorkflowConfig;
11use crate::dag::WorkflowDag;
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
79 .iter()
80 .any(|i| i.severity == ValidationSeverity::Error);
81 Self {
82 is_valid: !has_errors,
83 issues,
84 }
85 }
86
87 pub fn errors(&self) -> Vec<&ValidationIssue> {
89 self.issues
90 .iter()
91 .filter(|i| i.severity == ValidationSeverity::Error)
92 .collect()
93 }
94
95 pub fn warnings(&self) -> Vec<&ValidationIssue> {
97 self.issues
98 .iter()
99 .filter(|i| i.severity == ValidationSeverity::Warning)
100 .collect()
101 }
102}
103
104pub fn validate_config(config: &WorkflowConfig) -> ValidationResult {
112 let mut issues = Vec::new();
113
114 let mut seen_ids = std::collections::HashSet::new();
116 for step in &config.steps {
117 if seen_ids.contains(&step.id) {
118 issues.push(ValidationIssue {
119 severity: ValidationSeverity::Error,
120 code: ValidationCode::DuplicateStepId,
121 description: format!("Duplicate step ID: '{}'", step.id),
122 step_ids: vec![step.id.clone()],
123 });
124 }
125 seen_ids.insert(step.id.clone());
126 }
127
128 let step_ids: std::collections::HashSet<String> =
129 config.steps.iter().map(|s| s.id.clone()).collect();
130
131 for step in &config.steps {
133 for dep_id in &step.depends_on {
134 if !step_ids.contains(dep_id) {
135 issues.push(ValidationIssue {
136 severity: ValidationSeverity::Error,
137 code: ValidationCode::UndefinedDependency,
138 description: format!(
139 "Step '{}' depends on undefined step '{}'",
140 step.id, dep_id
141 ),
142 step_ids: vec![step.id.clone(), dep_id.clone()],
143 });
144 }
145 }
146 }
147
148 for step in &config.steps {
150 if step.depends_on.contains(&step.id) {
151 issues.push(ValidationIssue {
152 severity: ValidationSeverity::Error,
153 code: ValidationCode::SelfDependency,
154 description: format!("Step '{}' depends on itself", step.id),
155 step_ids: vec![step.id.clone()],
156 });
157 }
158 }
159
160 for step in &config.steps {
162 if step.tool.is_none() && step.prompt.is_none() && !step.requires_approval {
163 issues.push(ValidationIssue {
164 severity: ValidationSeverity::Warning,
165 code: ValidationCode::EmptyStep,
166 description: format!(
167 "Step '{}' has no tool, prompt, or approval checkpoint",
168 step.id
169 ),
170 step_ids: vec![step.id.clone()],
171 });
172 }
173 }
174
175 ValidationResult::with_issues(issues)
176}
177
178pub fn validate_dag(dag: &WorkflowDag) -> ValidationResult {
184 let mut issues = Vec::new();
185
186 if dag.has_cycle() {
188 issues.push(ValidationIssue {
189 severity: ValidationSeverity::Error,
190 code: ValidationCode::CycleDetected,
191 description: "Workflow contains a circular dependency cycle".to_string(),
192 step_ids: vec![],
193 });
194 }
195
196 if !dag.roots.is_empty() {
198 let reachable: std::collections::HashSet<String> = dag
200 .roots
201 .iter()
202 .flat_map(|root_id| {
203 let mut visited = std::collections::HashSet::new();
204 let mut queue = std::collections::VecDeque::new();
205 queue.push_back(root_id.clone());
206 while let Some(id) = queue.pop_front() {
207 if visited.contains(&id) {
208 continue;
209 }
210 visited.insert(id.clone());
211 if let Some(node) = dag.get_node(&id) {
213 for dep_id in &node.depends_on {
214 if !visited.contains(dep_id) {
215 queue.push_back(dep_id.clone());
216 }
217 }
218 for child_id in &node.children {
219 if !visited.contains(child_id) {
220 queue.push_back(child_id.clone());
221 }
222 }
223 }
224 }
225 visited
226 })
227 .collect();
228
229 for id in dag.nodes.keys() {
230 if !reachable.contains(id) {
231 issues.push(ValidationIssue {
232 severity: ValidationSeverity::Warning,
233 code: ValidationCode::OrphanNode,
234 description: format!("Node '{}' is unreachable from any root", id),
235 step_ids: vec![id.clone()],
236 });
237 }
238 }
239 } else if !dag.nodes.is_empty() && dag.roots.is_empty() {
240 issues.push(ValidationIssue {
242 severity: ValidationSeverity::Error,
243 code: ValidationCode::CycleDetected,
244 description: "No root nodes found — all nodes may be in a cycle".to_string(),
245 step_ids: vec![],
246 });
247 }
248
249 for level in &dag.levels {
251 for node_id in level {
252 if let Some(node) = dag.get_node(node_id) {
253 for dep_id in &node.depends_on {
254 if level.contains(dep_id) {
255 issues.push(ValidationIssue {
256 severity: ValidationSeverity::Error,
257 code: ValidationCode::CycleDetected,
258 description: format!(
259 "Node '{}' at same level as its dependency '{}'",
260 node_id, dep_id
261 ),
262 step_ids: vec![node_id.clone(), dep_id.clone()],
263 });
264 }
265 }
266 }
267 }
268 }
269
270 ValidationResult::with_issues(issues)
271}
272
273pub fn validate(config: &WorkflowConfig, dag: &WorkflowDag) -> ValidationResult {
275 let config_result = validate_config(config);
276 let dag_result = validate_dag(dag);
277
278 let all_issues: Vec<ValidationIssue> = config_result
279 .issues
280 .iter()
281 .chain(dag_result.issues.iter())
282 .cloned()
283 .collect();
284
285 ValidationResult::with_issues(all_issues)
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::compiler::compile;
292 use crate::config::{StepConfig, WorkflowConfig};
293 use crate::dag::{DagNode, WorkflowDag};
294 use std::collections::HashMap;
295
296 fn make_step(id: &str, depends_on: Vec<&str>) -> StepConfig {
297 StepConfig {
298 id: id.to_string(),
299 description: format!("Step {}", id),
300 depends_on: depends_on.iter().map(|s| s.to_string()).collect(),
301 tool: Some("test_tool".to_string()),
302 tool_args: None,
303 prompt: None,
304 requires_approval: false,
305 timeout_secs: None,
306 retry_policy: None,
307 metadata: HashMap::new(),
308 }
309 }
310
311 #[test]
312 fn test_validate_valid_config() {
313 let config = WorkflowConfig::new(
314 "valid",
315 vec![
316 make_step("step1", vec![]),
317 make_step("step2", vec!["step1"]),
318 ],
319 );
320
321 let result = validate_config(&config);
322 assert!(result.is_valid);
323 assert!(result.issues.is_empty());
324 }
325
326 #[test]
327 fn test_validate_duplicate_ids() {
328 let config = WorkflowConfig::new(
329 "invalid",
330 vec![make_step("step1", vec![]), make_step("step1", vec![])],
331 );
332
333 let result = validate_config(&config);
334 assert!(!result.is_valid);
335 assert!(result
336 .issues
337 .iter()
338 .any(|i| i.code == ValidationCode::DuplicateStepId));
339 }
340
341 #[test]
342 fn test_validate_undefined_dependency() {
343 let config = WorkflowConfig::new("invalid", vec![make_step("step1", vec!["nonexistent"])]);
344
345 let result = validate_config(&config);
346 assert!(!result.is_valid);
347 assert!(result
348 .issues
349 .iter()
350 .any(|i| i.code == ValidationCode::UndefinedDependency));
351 }
352
353 #[test]
354 fn test_validate_self_dependency() {
355 let config = WorkflowConfig::new(
356 "invalid",
357 vec![StepConfig {
358 id: "loop".to_string(),
359 description: "Self-loop".to_string(),
360 depends_on: vec!["loop".to_string()],
361 tool: Some("test".to_string()),
362 tool_args: None,
363 prompt: None,
364 requires_approval: false,
365 timeout_secs: None,
366 retry_policy: None,
367 metadata: HashMap::new(),
368 }],
369 );
370
371 let result = validate_config(&config);
372 assert!(!result.is_valid);
373 assert!(result
374 .issues
375 .iter()
376 .any(|i| i.code == ValidationCode::SelfDependency));
377 }
378
379 #[test]
380 fn test_validate_empty_step_warning() {
381 let config = WorkflowConfig::new(
382 "warn",
383 vec![StepConfig {
384 id: "empty".to_string(),
385 description: "Empty step".to_string(),
386 depends_on: vec![],
387 tool: None,
388 tool_args: None,
389 prompt: None,
390 requires_approval: false,
391 timeout_secs: None,
392 retry_policy: None,
393 metadata: HashMap::new(),
394 }],
395 );
396
397 let result = validate_config(&config);
398 assert!(result.is_valid); assert!(result
401 .issues
402 .iter()
403 .any(|i| i.code == ValidationCode::EmptyStep));
404 }
405
406 #[test]
407 fn test_validate_dag_cycle() {
408 let mut dag = WorkflowDag::new("cycle");
409 dag.add_node(DagNode {
410 id: "a".to_string(),
411 step: make_step("a", vec!["b"]),
412 depends_on: vec!["b".to_string()],
413 children: Vec::new(),
414 level: 0,
415 });
416 dag.add_node(DagNode {
417 id: "b".to_string(),
418 step: make_step("b", vec!["a"]),
419 depends_on: vec!["a".to_string()],
420 children: Vec::new(),
421 level: 0,
422 });
423 dag.build();
424
425 let result = validate_dag(&dag);
426 assert!(!result.is_valid);
427 assert!(result
428 .issues
429 .iter()
430 .any(|i| i.code == ValidationCode::CycleDetected));
431 }
432
433 #[test]
434 fn test_validate_dag_no_cycle() {
435 let config = WorkflowConfig::new(
436 "valid",
437 vec![
438 make_step("step1", vec![]),
439 make_step("step2", vec!["step1"]),
440 ],
441 );
442 let dag = compile(&config);
443
444 let result = validate_dag(&dag);
445 assert!(result.is_valid);
446 }
447
448 #[test]
449 fn test_full_validation_valid() {
450 let config = WorkflowConfig::new(
451 "full_valid",
452 vec![
453 make_step("step1", vec![]),
454 make_step("step2", vec!["step1"]),
455 make_step("step3", vec!["step2"]),
456 ],
457 );
458 let dag = compile(&config);
459
460 let result = validate(&config, &dag);
461 assert!(result.is_valid);
462 }
463
464 #[test]
465 fn test_full_validation_invalid() {
466 let config = WorkflowConfig::new(
467 "full_invalid",
468 vec![make_step("step1", vec!["nonexistent"])],
469 );
470 let dag = compile(&config);
471
472 let result = validate(&config, &dag);
473 assert!(!result.is_valid);
474 }
475}