Skip to main content

voirs_cli/workflow/
validation.rs

1//! Workflow Validation
2//!
3//! Validates workflow definitions for correctness and safety.
4
5use super::definition::Workflow;
6use crate::error::CliError;
7
8type Result<T> = std::result::Result<T, CliError>;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11
12/// Validation error types
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub enum ValidationError {
15    /// Duplicate step name
16    DuplicateStepName(String),
17    /// Missing dependency
18    MissingDependency { step: String, dependency: String },
19    /// Circular dependency
20    CircularDependency(Vec<String>),
21    /// Invalid condition
22    InvalidCondition { step: String, reason: String },
23    /// Invalid parameter
24    InvalidParameter {
25        step: String,
26        parameter: String,
27        reason: String,
28    },
29    /// Empty workflow
30    EmptyWorkflow,
31    /// Invalid for-each variable
32    InvalidForEachVariable { step: String, variable: String },
33}
34
35impl std::fmt::Display for ValidationError {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::DuplicateStepName(name) => write!(f, "Duplicate step name: {}", name),
39            Self::MissingDependency { step, dependency } => {
40                write!(
41                    f,
42                    "Step '{}' depends on non-existent step '{}'",
43                    step, dependency
44                )
45            }
46            Self::CircularDependency(cycle) => {
47                write!(f, "Circular dependency detected: {}", cycle.join(" -> "))
48            }
49            Self::InvalidCondition { step, reason } => {
50                write!(f, "Invalid condition in step '{}': {}", step, reason)
51            }
52            Self::InvalidParameter {
53                step,
54                parameter,
55                reason,
56            } => write!(
57                f,
58                "Invalid parameter '{}' in step '{}': {}",
59                parameter, step, reason
60            ),
61            Self::EmptyWorkflow => write!(f, "Workflow has no steps"),
62            Self::InvalidForEachVariable { step, variable } => {
63                write!(
64                    f,
65                    "Invalid for-each variable '{}' in step '{}'",
66                    variable, step
67                )
68            }
69        }
70    }
71}
72
73/// Validation result
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ValidationResult {
76    /// Whether validation passed
77    pub valid: bool,
78    /// List of errors
79    pub errors: Vec<ValidationError>,
80    /// List of warnings
81    pub warnings: Vec<String>,
82}
83
84impl ValidationResult {
85    /// Create successful validation result
86    pub fn success() -> Self {
87        Self {
88            valid: true,
89            errors: Vec::new(),
90            warnings: Vec::new(),
91        }
92    }
93
94    /// Create failed validation result
95    pub fn failure(errors: Vec<ValidationError>) -> Self {
96        Self {
97            valid: false,
98            errors,
99            warnings: Vec::new(),
100        }
101    }
102
103    /// Add a warning
104    pub fn with_warning(mut self, warning: String) -> Self {
105        self.warnings.push(warning);
106        self
107    }
108
109    /// Check if has errors
110    pub fn has_errors(&self) -> bool {
111        !self.errors.is_empty()
112    }
113
114    /// Check if has warnings
115    pub fn has_warnings(&self) -> bool {
116        !self.warnings.is_empty()
117    }
118}
119
120/// Workflow validator
121pub struct WorkflowValidator {
122    /// Maximum allowed steps
123    max_steps: usize,
124    /// Maximum dependency depth
125    max_dependency_depth: usize,
126}
127
128impl WorkflowValidator {
129    /// Create new validator with defaults
130    pub fn new() -> Self {
131        Self {
132            max_steps: 1000,
133            max_dependency_depth: 100,
134        }
135    }
136
137    /// Create validator with custom limits
138    pub fn with_limits(max_steps: usize, max_dependency_depth: usize) -> Self {
139        Self {
140            max_steps,
141            max_dependency_depth,
142        }
143    }
144
145    /// Validate a workflow
146    pub fn validate(&self, workflow: &Workflow) -> Result<ValidationResult> {
147        let mut errors = Vec::new();
148        let mut warnings = Vec::new();
149
150        // Check if workflow is empty
151        if workflow.steps.is_empty() {
152            errors.push(ValidationError::EmptyWorkflow);
153            return Ok(ValidationResult::failure(errors));
154        }
155
156        // Check step count
157        if workflow.steps.len() > self.max_steps {
158            warnings.push(format!(
159                "Workflow has {} steps, which exceeds recommended limit of {}",
160                workflow.steps.len(),
161                self.max_steps
162            ));
163        }
164
165        // Check for duplicate step names
166        let mut step_names = HashSet::new();
167        for step in &workflow.steps {
168            if !step_names.insert(&step.name) {
169                errors.push(ValidationError::DuplicateStepName(step.name.clone()));
170            }
171        }
172
173        // Build step map for dependency checking
174        let step_map: HashMap<&String, &super::definition::Step> =
175            workflow.steps.iter().map(|s| (&s.name, s)).collect();
176
177        // Check dependencies exist
178        for step in &workflow.steps {
179            for dep in &step.depends_on {
180                if !step_map.contains_key(&dep.step_name) {
181                    errors.push(ValidationError::MissingDependency {
182                        step: step.name.clone(),
183                        dependency: dep.step_name.clone(),
184                    });
185                }
186            }
187        }
188
189        // Check for circular dependencies
190        if let Some(cycle) = self.detect_cycles(workflow) {
191            errors.push(ValidationError::CircularDependency(cycle));
192        }
193
194        // Check dependency depth
195        for step in &workflow.steps {
196            let depth = self.calculate_dependency_depth(&step.name, workflow, &mut HashSet::new());
197            if depth > self.max_dependency_depth {
198                warnings.push(format!(
199                    "Step '{}' has dependency depth of {}, which exceeds recommended limit of {}",
200                    step.name, depth, self.max_dependency_depth
201                ));
202            }
203        }
204
205        // Validate conditions
206        for step in &workflow.steps {
207            if let Some(ref condition) = step.condition {
208                // Basic validation of condition syntax
209                if condition.left.is_empty() || condition.right.is_empty() {
210                    errors.push(ValidationError::InvalidCondition {
211                        step: step.name.clone(),
212                        reason: "Condition operands cannot be empty".to_string(),
213                    });
214                }
215            }
216        }
217
218        // Validate for-each loops
219        for step in &workflow.steps {
220            if let Some(ref for_each_var) = step.for_each {
221                // Check if variable is properly formatted
222                if !for_each_var.starts_with("${") || !for_each_var.ends_with('}') {
223                    errors.push(ValidationError::InvalidForEachVariable {
224                        step: step.name.clone(),
225                        variable: for_each_var.clone(),
226                    });
227                }
228            }
229        }
230
231        if errors.is_empty() {
232            let mut result = ValidationResult::success();
233            result.warnings = warnings;
234            Ok(result)
235        } else {
236            let mut result = ValidationResult::failure(errors);
237            result.warnings = warnings;
238            Ok(result)
239        }
240    }
241
242    /// Detect circular dependencies
243    fn detect_cycles(&self, workflow: &Workflow) -> Option<Vec<String>> {
244        let mut visited = HashSet::new();
245        let mut recursion_stack = Vec::new();
246
247        for step in &workflow.steps {
248            if self.has_cycle_dfs(&step.name, workflow, &mut visited, &mut recursion_stack) {
249                return Some(recursion_stack);
250            }
251        }
252
253        None
254    }
255
256    /// DFS-based cycle detection
257    fn has_cycle_dfs(
258        &self,
259        node: &str,
260        workflow: &Workflow,
261        visited: &mut HashSet<String>,
262        recursion_stack: &mut Vec<String>,
263    ) -> bool {
264        if recursion_stack.iter().any(|s| s == node) {
265            recursion_stack.push(node.to_string());
266            return true;
267        }
268
269        if visited.contains(node) {
270            return false;
271        }
272
273        visited.insert(node.to_string());
274        recursion_stack.push(node.to_string());
275
276        // Find step and check dependencies
277        if let Some(step) = workflow.steps.iter().find(|s| s.name == node) {
278            for dep in &step.depends_on {
279                if self.has_cycle_dfs(&dep.step_name, workflow, visited, recursion_stack) {
280                    return true;
281                }
282            }
283        }
284
285        recursion_stack.pop();
286        false
287    }
288
289    /// Calculate dependency depth
290    fn calculate_dependency_depth(
291        &self,
292        step_name: &str,
293        workflow: &Workflow,
294        visited: &mut HashSet<String>,
295    ) -> usize {
296        if visited.contains(step_name) {
297            return 0;
298        }
299
300        visited.insert(step_name.to_string());
301
302        let step = workflow.steps.iter().find(|s| s.name == step_name);
303
304        if let Some(step) = step {
305            if step.depends_on.is_empty() {
306                return 1;
307            }
308
309            let max_dep_depth = step
310                .depends_on
311                .iter()
312                .map(|dep| self.calculate_dependency_depth(&dep.step_name, workflow, visited))
313                .max()
314                .unwrap_or(0);
315
316            max_dep_depth + 1
317        } else {
318            0
319        }
320    }
321}
322
323impl Default for WorkflowValidator {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::workflow::definition::{Step, StepDependency, StepType};
333    use std::collections::HashMap;
334
335    #[test]
336    fn test_validator_creation() {
337        let validator = WorkflowValidator::new();
338        assert_eq!(validator.max_steps, 1000);
339    }
340
341    #[test]
342    fn test_validate_empty_workflow() {
343        let validator = WorkflowValidator::new();
344        let workflow = Workflow::new("test", "1.0", "Test");
345
346        let result = validator.validate(&workflow).unwrap();
347        assert!(!result.valid);
348        assert!(result.has_errors());
349    }
350
351    #[test]
352    fn test_validate_valid_workflow() {
353        let validator = WorkflowValidator::new();
354        let mut workflow = Workflow::new("test", "1.0", "Test");
355
356        let step = Step {
357            name: "step1".to_string(),
358            step_type: StepType::Command,
359            description: None,
360            parameters: HashMap::new(),
361            condition: None,
362            depends_on: Vec::new(),
363            retry: None,
364            for_each: None,
365            parallel: false,
366        };
367
368        workflow.add_step(step);
369
370        let result = validator.validate(&workflow).unwrap();
371        assert!(result.valid);
372        assert!(!result.has_errors());
373    }
374
375    #[test]
376    fn test_validate_duplicate_step_names() {
377        let validator = WorkflowValidator::new();
378        let mut workflow = Workflow::new("test", "1.0", "Test");
379
380        let step1 = Step {
381            name: "duplicate".to_string(),
382            step_type: StepType::Command,
383            description: None,
384            parameters: HashMap::new(),
385            condition: None,
386            depends_on: Vec::new(),
387            retry: None,
388            for_each: None,
389            parallel: false,
390        };
391
392        workflow.add_step(step1.clone());
393        workflow.add_step(step1);
394
395        let result = validator.validate(&workflow).unwrap();
396        assert!(!result.valid);
397        assert!(result.has_errors());
398    }
399
400    #[test]
401    fn test_validate_missing_dependency() {
402        let validator = WorkflowValidator::new();
403        let mut workflow = Workflow::new("test", "1.0", "Test");
404
405        let step = Step {
406            name: "step1".to_string(),
407            step_type: StepType::Command,
408            description: None,
409            parameters: HashMap::new(),
410            condition: None,
411            depends_on: vec![StepDependency {
412                step_name: "nonexistent".to_string(),
413                must_succeed: true,
414            }],
415            retry: None,
416            for_each: None,
417            parallel: false,
418        };
419
420        workflow.add_step(step);
421
422        let result = validator.validate(&workflow).unwrap();
423        assert!(!result.valid);
424        assert!(result.has_errors());
425    }
426
427    #[test]
428    fn test_validation_result_success() {
429        let result = ValidationResult::success();
430        assert!(result.valid);
431        assert!(!result.has_errors());
432    }
433
434    #[test]
435    fn test_validation_result_with_warning() {
436        let result = ValidationResult::success().with_warning("Test warning".to_string());
437        assert!(result.valid);
438        assert!(result.has_warnings());
439        assert_eq!(result.warnings.len(), 1);
440    }
441}