Skip to main content

oximedia_workflow/
validation.rs

1//! Workflow and task validation.
2
3use crate::error::{Result, WorkflowError};
4use crate::task::{Task, TaskType};
5use crate::workflow::Workflow;
6use std::collections::{HashMap, HashSet};
7use std::path::Path;
8
9/// Workflow validator.
10pub struct WorkflowValidator {
11    /// Validation rules.
12    rules: Vec<Box<dyn ValidationRule>>,
13}
14
15impl WorkflowValidator {
16    /// Create a new workflow validator.
17    #[must_use]
18    pub fn new() -> Self {
19        Self { rules: Vec::new() }
20    }
21
22    /// Add a validation rule.
23    #[must_use]
24    pub fn add_rule(mut self, rule: Box<dyn ValidationRule>) -> Self {
25        self.rules.push(rule);
26        self
27    }
28
29    /// Add default validation rules.
30    #[must_use]
31    pub fn with_defaults(self) -> Self {
32        self.add_rule(Box::new(NoCyclesRule))
33            .add_rule(Box::new(ValidEdgesRule))
34            .add_rule(Box::new(ValidTaskTypesRule))
35            .add_rule(Box::new(NoOrphanTasksRule))
36            .add_rule(Box::new(ValidDependenciesRule))
37    }
38
39    /// Validate a workflow.
40    pub fn validate(&self, workflow: &Workflow) -> Result<ValidationReport> {
41        let mut report = ValidationReport::new();
42
43        for rule in &self.rules {
44            if let Err(e) = rule.validate(workflow) {
45                report.add_error(e.to_string());
46            }
47        }
48
49        if let Err(e) = workflow.validate() {
50            report.add_error(e.to_string());
51        }
52
53        Ok(report)
54    }
55}
56
57impl Default for WorkflowValidator {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63/// Validation rule trait.
64pub trait ValidationRule: Send + Sync {
65    /// Validate a workflow.
66    fn validate(&self, workflow: &Workflow) -> Result<()>;
67
68    /// Get rule name.
69    fn name(&self) -> &str;
70}
71
72/// Rule: No cycles in workflow DAG.
73pub struct NoCyclesRule;
74
75impl ValidationRule for NoCyclesRule {
76    fn validate(&self, workflow: &Workflow) -> Result<()> {
77        if workflow.has_cycle() {
78            return Err(WorkflowError::CycleDetected);
79        }
80        Ok(())
81    }
82
83    fn name(&self) -> &'static str {
84        "NoCyclesRule"
85    }
86}
87
88/// Rule: All edges reference valid tasks.
89pub struct ValidEdgesRule;
90
91impl ValidationRule for ValidEdgesRule {
92    fn validate(&self, workflow: &Workflow) -> Result<()> {
93        for edge in &workflow.edges {
94            if !workflow.tasks.contains_key(&edge.from) {
95                return Err(WorkflowError::TaskNotFound(edge.from.to_string()));
96            }
97            if !workflow.tasks.contains_key(&edge.to) {
98                return Err(WorkflowError::TaskNotFound(edge.to.to_string()));
99            }
100        }
101        Ok(())
102    }
103
104    fn name(&self) -> &'static str {
105        "ValidEdgesRule"
106    }
107}
108
109/// Rule: All task types are valid.
110pub struct ValidTaskTypesRule;
111
112impl ValidationRule for ValidTaskTypesRule {
113    fn validate(&self, workflow: &Workflow) -> Result<()> {
114        for task in workflow.tasks.values() {
115            validate_task_type(&task.task_type)?;
116        }
117        Ok(())
118    }
119
120    fn name(&self) -> &'static str {
121        "ValidTaskTypesRule"
122    }
123}
124
125/// Rule: No orphan tasks (all tasks should be reachable).
126pub struct NoOrphanTasksRule;
127
128impl ValidationRule for NoOrphanTasksRule {
129    fn validate(&self, workflow: &Workflow) -> Result<()> {
130        if workflow.tasks.is_empty() {
131            return Ok(());
132        }
133
134        let root_tasks = workflow.get_root_tasks();
135        if root_tasks.is_empty() {
136            return Err(WorkflowError::InvalidConfiguration(
137                "No root tasks found".to_string(),
138            ));
139        }
140
141        // BFS to find all reachable tasks
142        let mut reachable = HashSet::new();
143        let mut queue = root_tasks.clone();
144
145        while let Some(task_id) = queue.pop() {
146            if reachable.insert(task_id) {
147                let dependents = workflow.get_dependents(&task_id);
148                queue.extend(dependents);
149            }
150        }
151
152        // Check for unreachable tasks
153        for task_id in workflow.tasks.keys() {
154            if !reachable.contains(task_id) {
155                return Err(WorkflowError::InvalidConfiguration(format!(
156                    "Task {task_id} is not reachable"
157                )));
158            }
159        }
160
161        Ok(())
162    }
163
164    fn name(&self) -> &'static str {
165        "NoOrphanTasksRule"
166    }
167}
168
169/// Rule: Task dependencies are valid.
170pub struct ValidDependenciesRule;
171
172impl ValidationRule for ValidDependenciesRule {
173    fn validate(&self, workflow: &Workflow) -> Result<()> {
174        for task in workflow.tasks.values() {
175            for dep_id in &task.dependencies {
176                if !workflow.tasks.contains_key(dep_id) {
177                    return Err(WorkflowError::TaskNotFound(dep_id.to_string()));
178                }
179            }
180        }
181        Ok(())
182    }
183
184    fn name(&self) -> &'static str {
185        "ValidDependenciesRule"
186    }
187}
188
189/// Validation report.
190#[derive(Debug, Clone)]
191pub struct ValidationReport {
192    /// Validation errors.
193    pub errors: Vec<String>,
194    /// Validation warnings.
195    pub warnings: Vec<String>,
196}
197
198impl ValidationReport {
199    /// Create a new validation report.
200    #[must_use]
201    pub fn new() -> Self {
202        Self {
203            errors: Vec::new(),
204            warnings: Vec::new(),
205        }
206    }
207
208    /// Add an error.
209    pub fn add_error(&mut self, error: String) {
210        self.errors.push(error);
211    }
212
213    /// Add a warning.
214    pub fn add_warning(&mut self, warning: String) {
215        self.warnings.push(warning);
216    }
217
218    /// Check if validation passed.
219    #[must_use]
220    pub fn is_valid(&self) -> bool {
221        self.errors.is_empty()
222    }
223
224    /// Get error count.
225    #[must_use]
226    pub fn error_count(&self) -> usize {
227        self.errors.len()
228    }
229
230    /// Get warning count.
231    #[must_use]
232    pub fn warning_count(&self) -> usize {
233        self.warnings.len()
234    }
235}
236
237impl Default for ValidationReport {
238    fn default() -> Self {
239        Self::new()
240    }
241}
242
243/// Validate task type configuration.
244fn validate_task_type(task_type: &TaskType) -> Result<()> {
245    match task_type {
246        TaskType::Transcode {
247            input,
248            output,
249            preset,
250            ..
251        } => {
252            if preset.is_empty() {
253                return Err(WorkflowError::InvalidConfiguration(
254                    "Transcode preset cannot be empty".to_string(),
255                ));
256            }
257            validate_file_path(input)?;
258            validate_file_path(output)?;
259        }
260
261        TaskType::QualityControl { input, profile, .. } => {
262            if profile.is_empty() {
263                return Err(WorkflowError::InvalidConfiguration(
264                    "QC profile cannot be empty".to_string(),
265                ));
266            }
267            validate_file_path(input)?;
268        }
269
270        TaskType::Transfer {
271            source,
272            destination,
273            ..
274        } => {
275            if source.is_empty() {
276                return Err(WorkflowError::InvalidConfiguration(
277                    "Transfer source cannot be empty".to_string(),
278                ));
279            }
280            if destination.is_empty() {
281                return Err(WorkflowError::InvalidConfiguration(
282                    "Transfer destination cannot be empty".to_string(),
283                ));
284            }
285        }
286
287        TaskType::Notification { message, .. } => {
288            if message.is_empty() {
289                return Err(WorkflowError::InvalidConfiguration(
290                    "Notification message cannot be empty".to_string(),
291                ));
292            }
293        }
294
295        TaskType::CustomScript { script, .. } => {
296            validate_file_path(script)?;
297        }
298
299        TaskType::Analysis { input, .. } => {
300            validate_file_path(input)?;
301        }
302
303        TaskType::HttpRequest { url, .. } => {
304            if url.is_empty() {
305                return Err(WorkflowError::InvalidConfiguration(
306                    "HTTP URL cannot be empty".to_string(),
307                ));
308            }
309        }
310
311        TaskType::Wait { duration } => {
312            if duration.is_zero() {
313                return Err(WorkflowError::InvalidConfiguration(
314                    "Wait duration cannot be zero".to_string(),
315                ));
316            }
317        }
318
319        TaskType::Conditional { condition, .. } => {
320            if condition.is_empty() {
321                return Err(WorkflowError::InvalidConfiguration(
322                    "Conditional expression cannot be empty".to_string(),
323                ));
324            }
325        }
326    }
327
328    Ok(())
329}
330
331fn validate_file_path(path: &Path) -> Result<()> {
332    let path_str = path.to_string_lossy();
333    if path_str.is_empty() {
334        return Err(WorkflowError::InvalidConfiguration(
335            "File path cannot be empty".to_string(),
336        ));
337    }
338
339    // Check for potentially dangerous paths
340    if path_str.contains("..") {
341        return Err(WorkflowError::InvalidConfiguration(
342            "Path traversal detected".to_string(),
343        ));
344    }
345
346    Ok(())
347}
348
349/// Task validator.
350pub struct TaskValidator;
351
352impl TaskValidator {
353    /// Validate a task.
354    pub fn validate(task: &Task) -> Result<()> {
355        // Validate task name
356        if task.name.is_empty() {
357            return Err(WorkflowError::InvalidConfiguration(
358                "Task name cannot be empty".to_string(),
359            ));
360        }
361
362        // Validate task type
363        validate_task_type(&task.task_type)?;
364
365        // Validate timeout
366        if task.timeout.is_zero() {
367            return Err(WorkflowError::InvalidConfiguration(
368                "Task timeout cannot be zero".to_string(),
369            ));
370        }
371
372        // Validate retry policy
373        if task.retry.max_attempts == 0 {
374            return Err(WorkflowError::InvalidConfiguration(
375                "Retry max_attempts must be at least 1".to_string(),
376            ));
377        }
378
379        Ok(())
380    }
381}
382
383/// Workflow complexity analyzer.
384pub struct ComplexityAnalyzer;
385
386impl ComplexityAnalyzer {
387    /// Analyze workflow complexity.
388    #[must_use]
389    pub fn analyze(workflow: &Workflow) -> ComplexityMetrics {
390        let task_count = workflow.tasks.len();
391        let edge_count = workflow.edges.len();
392
393        // Calculate depth (longest path)
394        let depth = Self::calculate_depth(workflow);
395
396        // Calculate width (maximum parallel tasks)
397        let width = Self::calculate_width(workflow);
398
399        // Calculate branching factor
400        let branching_factor = if task_count > 0 {
401            edge_count as f64 / task_count as f64
402        } else {
403            0.0
404        };
405
406        // Calculate cyclomatic complexity
407        let cyclomatic_complexity = edge_count.saturating_sub(task_count) + 2;
408
409        ComplexityMetrics {
410            task_count,
411            edge_count,
412            depth,
413            width,
414            branching_factor,
415            cyclomatic_complexity,
416        }
417    }
418
419    fn calculate_depth(workflow: &Workflow) -> usize {
420        let roots = workflow.get_root_tasks();
421        let mut max_depth = 0;
422
423        for root in roots {
424            let depth = Self::dfs_depth(workflow, root, &mut HashSet::new());
425            max_depth = max_depth.max(depth);
426        }
427
428        max_depth
429    }
430
431    fn dfs_depth(
432        workflow: &Workflow,
433        task_id: crate::task::TaskId,
434        visited: &mut HashSet<crate::task::TaskId>,
435    ) -> usize {
436        if visited.contains(&task_id) {
437            return 0;
438        }
439
440        visited.insert(task_id);
441
442        let dependents = workflow.get_dependents(&task_id);
443        if dependents.is_empty() {
444            return 1;
445        }
446
447        let mut max_depth = 0;
448        for dep in dependents {
449            let depth = Self::dfs_depth(workflow, dep, visited);
450            max_depth = max_depth.max(depth);
451        }
452
453        max_depth + 1
454    }
455
456    fn calculate_width(workflow: &Workflow) -> usize {
457        // Calculate maximum number of tasks at any level
458        let roots = workflow.get_root_tasks();
459        let mut levels: HashMap<crate::task::TaskId, usize> = HashMap::new();
460        let mut level_counts: HashMap<usize, usize> = HashMap::new();
461
462        for root in roots {
463            Self::assign_levels(workflow, root, 0, &mut levels);
464        }
465
466        for level in levels.values() {
467            *level_counts.entry(*level).or_insert(0) += 1;
468        }
469
470        level_counts.values().max().copied().unwrap_or(0)
471    }
472
473    fn assign_levels(
474        workflow: &Workflow,
475        task_id: crate::task::TaskId,
476        level: usize,
477        levels: &mut HashMap<crate::task::TaskId, usize>,
478    ) {
479        if let Some(&existing_level) = levels.get(&task_id) {
480            if level <= existing_level {
481                return;
482            }
483        }
484
485        levels.insert(task_id, level);
486
487        for dep in workflow.get_dependents(&task_id) {
488            Self::assign_levels(workflow, dep, level + 1, levels);
489        }
490    }
491}
492
493/// Workflow complexity metrics.
494#[derive(Debug, Clone)]
495pub struct ComplexityMetrics {
496    /// Total number of tasks.
497    pub task_count: usize,
498    /// Total number of edges.
499    pub edge_count: usize,
500    /// Maximum depth (longest path).
501    pub depth: usize,
502    /// Maximum width (parallel tasks).
503    pub width: usize,
504    /// Average branching factor.
505    pub branching_factor: f64,
506    /// Cyclomatic complexity.
507    pub cyclomatic_complexity: usize,
508}
509
510impl ComplexityMetrics {
511    /// Get complexity score (0-100).
512    #[must_use]
513    pub fn score(&self) -> f64 {
514        let task_score = (self.task_count as f64 / 100.0).min(1.0) * 25.0;
515        let depth_score = (self.depth as f64 / 10.0).min(1.0) * 25.0;
516        let branching_score = (self.branching_factor / 3.0).min(1.0) * 25.0;
517        let cyclomatic_score = (self.cyclomatic_complexity as f64 / 20.0).min(1.0) * 25.0;
518
519        task_score + depth_score + branching_score + cyclomatic_score
520    }
521
522    /// Get complexity level.
523    #[must_use]
524    pub fn level(&self) -> ComplexityLevel {
525        let score = self.score();
526        if score < 25.0 {
527            ComplexityLevel::Low
528        } else if score < 50.0 {
529            ComplexityLevel::Medium
530        } else if score < 75.0 {
531            ComplexityLevel::High
532        } else {
533            ComplexityLevel::VeryHigh
534        }
535    }
536}
537
538/// Complexity level.
539#[derive(Debug, Clone, Copy, PartialEq, Eq)]
540pub enum ComplexityLevel {
541    /// Low complexity.
542    Low,
543    /// Medium complexity.
544    Medium,
545    /// High complexity.
546    High,
547    /// Very high complexity.
548    VeryHigh,
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use std::time::Duration;
555
556    fn create_test_task(name: &str) -> Task {
557        Task::new(
558            name,
559            TaskType::Wait {
560                duration: Duration::from_secs(1),
561            },
562        )
563    }
564
565    #[test]
566    fn test_workflow_validator() {
567        let validator = WorkflowValidator::new().with_defaults();
568        let workflow = Workflow::new("test");
569
570        let report = validator
571            .validate(&workflow)
572            .expect("should succeed in test");
573        assert!(report.is_valid());
574    }
575
576    #[test]
577    fn test_cycle_detection() {
578        let mut workflow = Workflow::new("test");
579        let task1 = create_test_task("task1");
580        let task2 = create_test_task("task2");
581
582        let id1 = workflow.add_task(task1);
583        let id2 = workflow.add_task(task2);
584
585        workflow.add_edge(id1, id2).expect("should succeed in test");
586        workflow.add_edge(id2, id1).expect("should succeed in test");
587
588        let rule = NoCyclesRule;
589        assert!(rule.validate(&workflow).is_err());
590    }
591
592    #[test]
593    fn test_valid_edges_rule() {
594        let mut workflow = Workflow::new("test");
595        let task = create_test_task("task1");
596        workflow.add_task(task);
597
598        let rule = ValidEdgesRule;
599        assert!(rule.validate(&workflow).is_ok());
600    }
601
602    #[test]
603    fn test_task_validator() {
604        let task = create_test_task("test-task");
605        assert!(TaskValidator::validate(&task).is_ok());
606    }
607
608    #[test]
609    fn test_task_validator_empty_name() {
610        let task = create_test_task("");
611        assert!(TaskValidator::validate(&task).is_err());
612    }
613
614    #[test]
615    fn test_complexity_analyzer() {
616        let mut workflow = Workflow::new("test");
617        let task1 = create_test_task("task1");
618        let task2 = create_test_task("task2");
619        let task3 = create_test_task("task3");
620
621        let id1 = workflow.add_task(task1);
622        let id2 = workflow.add_task(task2);
623        let id3 = workflow.add_task(task3);
624
625        workflow.add_edge(id1, id2).expect("should succeed in test");
626        workflow.add_edge(id2, id3).expect("should succeed in test");
627
628        let metrics = ComplexityAnalyzer::analyze(&workflow);
629
630        assert_eq!(metrics.task_count, 3);
631        assert_eq!(metrics.edge_count, 2);
632        assert_eq!(metrics.depth, 3);
633    }
634
635    #[test]
636    fn test_complexity_score() {
637        let metrics = ComplexityMetrics {
638            task_count: 10,
639            edge_count: 12,
640            depth: 5,
641            width: 3,
642            branching_factor: 1.2,
643            cyclomatic_complexity: 4,
644        };
645
646        let score = metrics.score();
647        assert!(score > 0.0 && score <= 100.0);
648    }
649
650    #[test]
651    fn test_complexity_level() {
652        let low_metrics = ComplexityMetrics {
653            task_count: 2,
654            edge_count: 1,
655            depth: 2,
656            width: 1,
657            branching_factor: 0.5,
658            cyclomatic_complexity: 1,
659        };
660
661        assert_eq!(low_metrics.level(), ComplexityLevel::Low);
662    }
663
664    #[test]
665    fn test_validation_report() {
666        let mut report = ValidationReport::new();
667        assert!(report.is_valid());
668
669        report.add_error("Test error".to_string());
670        assert!(!report.is_valid());
671        assert_eq!(report.error_count(), 1);
672
673        report.add_warning("Test warning".to_string());
674        assert_eq!(report.warning_count(), 1);
675    }
676}