// vapora-backend: Workflow YAML parser // Phase 3: Parse workflow definitions from YAML use std::fs; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::workflow::state::{Phase, StepStatus, Workflow, WorkflowStep}; #[derive(Debug, Error)] #[allow(dead_code, clippy::enum_variant_names)] pub enum ParserError { #[error("Failed to read file: {0}")] FileError(#[from] std::io::Error), #[error("Failed to parse YAML: {0}")] YamlError(#[from] serde_yaml::Error), #[error("Invalid workflow definition: {0}")] ValidationError(String), } #[derive(Debug, Deserialize, Serialize)] #[allow(dead_code)] pub struct WorkflowYaml { pub workflow: WorkflowDef, } #[derive(Debug, Deserialize, Serialize)] #[allow(dead_code)] pub struct WorkflowDef { pub id: String, pub title: String, pub phases: Vec, } #[derive(Debug, Deserialize, Serialize)] #[allow(dead_code)] pub struct PhaseDef { pub id: String, pub name: String, #[serde(default)] pub parallel: bool, #[serde(default = "default_estimated_hours")] pub estimated_hours: f32, pub steps: Vec, } #[derive(Debug, Deserialize, Serialize)] #[allow(dead_code)] pub struct StepDef { pub id: String, pub name: String, pub agent: String, #[serde(default)] pub depends_on: Vec, #[serde(default)] pub parallelizable: bool, } #[allow(dead_code)] fn default_estimated_hours() -> f32 { 1.0 } #[allow(dead_code)] pub struct WorkflowParser; #[allow(dead_code)] impl WorkflowParser { /// Parse workflow from YAML file pub fn parse_file(path: &str) -> Result { let content = fs::read_to_string(path)?; Self::parse_string(&content) } /// Parse workflow from YAML string pub fn parse_string(yaml: &str) -> Result { let workflow_yaml: WorkflowYaml = serde_yaml::from_str(yaml)?; Self::validate_and_convert(workflow_yaml) } /// Validate and convert YAML definition to runtime Workflow fn validate_and_convert(yaml: WorkflowYaml) -> Result { let def = yaml.workflow; // Validate workflow has phases if def.phases.is_empty() { return Err(ParserError::ValidationError( "Workflow must have at least one phase".to_string(), )); } // Convert phases let mut phases = Vec::new(); for phase_def in def.phases { // Validate phase has steps if phase_def.steps.is_empty() { return Err(ParserError::ValidationError(format!( "Phase '{}' must have at least one step", phase_def.id ))); } // Convert steps let steps: Vec = phase_def .steps .into_iter() .map(|step_def| WorkflowStep { id: step_def.id, name: step_def.name, agent_role: step_def.agent, status: StepStatus::Pending, depends_on: step_def.depends_on, can_parallelize: step_def.parallelizable, started_at: None, completed_at: None, result: None, error: None, }) .collect(); // Validate dependencies exist Self::validate_dependencies(&steps)?; phases.push(Phase { id: phase_def.id, name: phase_def.name, status: StepStatus::Pending, steps, parallel: phase_def.parallel, estimated_hours: phase_def.estimated_hours, }); } Ok(Workflow::new(def.id, def.title, phases)) } /// Validate that all step dependencies exist fn validate_dependencies(steps: &[WorkflowStep]) -> Result<(), ParserError> { let step_ids: std::collections::HashSet<_> = steps.iter().map(|s| &s.id).collect(); for step in steps { for dep in &step.depends_on { if !step_ids.contains(dep) { return Err(ParserError::ValidationError(format!( "Step '{}' depends on non-existent step '{}'", step.id, dep ))); } } } Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_workflow_yaml() { let yaml = r#" workflow: id: feature-auth title: Implement MFA phases: - id: phase_1 name: Design parallel: false estimated_hours: 2.0 steps: - id: step_1_1 name: Architect Design agent: architect depends_on: [] parallelizable: false - id: phase_2 name: Implementation parallel: true estimated_hours: 8.0 steps: - id: step_2_1 name: Backend API agent: developer depends_on: [] parallelizable: true - id: step_2_2 name: Frontend UI agent: developer depends_on: [] parallelizable: true "#; let result = WorkflowParser::parse_string(yaml); assert!(result.is_ok()); let workflow = result.unwrap(); assert_eq!(workflow.id, "feature-auth"); assert_eq!(workflow.title, "Implement MFA"); assert_eq!(workflow.phases.len(), 2); assert_eq!(workflow.phases[0].steps.len(), 1); assert_eq!(workflow.phases[1].steps.len(), 2); assert!(workflow.phases[1].parallel); } #[test] fn test_empty_phases_error() { let yaml = r#" workflow: id: test title: Test phases: [] "#; let result = WorkflowParser::parse_string(yaml); assert!(result.is_err()); } #[test] fn test_empty_steps_error() { let yaml = r#" workflow: id: test title: Test phases: - id: phase_1 name: Phase steps: [] "#; let result = WorkflowParser::parse_string(yaml); assert!(result.is_err()); } #[test] fn test_invalid_dependency() { let yaml = r#" workflow: id: test title: Test phases: - id: phase_1 name: Phase steps: - id: step_1 name: Step 1 agent: developer depends_on: [nonexistent] "#; let result = WorkflowParser::parse_string(yaml); assert!(result.is_err()); } #[test] fn test_valid_dependencies() { let yaml = r#" workflow: id: test title: Test phases: - id: phase_1 name: Phase steps: - id: step_1 name: Step 1 agent: developer depends_on: [] - id: step_2 name: Step 2 agent: developer depends_on: [step_1] "#; let result = WorkflowParser::parse_string(yaml); assert!(result.is_ok()); let workflow = result.unwrap(); assert_eq!(workflow.phases[0].steps[1].depends_on, vec!["step_1"]); } }