//! Pipeline DSL Parser //! //! Parses YAML pipeline definitions into Pipeline structs. use std::path::Path; use serde_yaml; use thiserror::Error; use crate::types::{Pipeline, API_VERSION}; /// Parser errors #[derive(Debug, Error)] pub enum ParseError { #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("YAML parse error: {0}")] Yaml(#[from] serde_yaml::Error), #[error("Invalid API version: expected '{expected}', got '{actual}'")] InvalidVersion { expected: String, actual: String }, #[error("Invalid kind: expected 'Pipeline', got '{0}'")] InvalidKind(String), #[error("Missing required field: {0}")] MissingField(String), #[error("Invalid action type: {0}")] InvalidAction(String), #[error("Validation error: {0}")] Validation(String), } /// Pipeline parser pub struct PipelineParser; impl PipelineParser { /// Parse a pipeline from YAML string pub fn parse(yaml: &str) -> Result { let pipeline: Pipeline = serde_yaml::from_str(yaml)?; // Validate API version if pipeline.api_version != API_VERSION { return Err(ParseError::InvalidVersion { expected: API_VERSION.to_string(), actual: pipeline.api_version.clone(), }); } // Validate kind if pipeline.kind != "Pipeline" { return Err(ParseError::InvalidKind(pipeline.kind.clone())); } // Validate required fields if pipeline.metadata.name.is_empty() { return Err(ParseError::MissingField("metadata.name".to_string())); } if pipeline.spec.steps.is_empty() { return Err(ParseError::Validation("Pipeline must have at least one step".to_string())); } // Validate step IDs are unique let mut seen_ids = std::collections::HashSet::new(); for step in &pipeline.spec.steps { if !seen_ids.insert(&step.id) { return Err(ParseError::Validation( format!("Duplicate step ID: {}", step.id) )); } } // Validate input names are unique let mut seen_inputs = std::collections::HashSet::new(); for input in &pipeline.spec.inputs { if !seen_inputs.insert(&input.name) { return Err(ParseError::Validation( format!("Duplicate input name: {}", input.name) )); } } Ok(pipeline) } /// Parse a pipeline from file pub fn parse_file(path: &Path) -> Result { let content = std::fs::read_to_string(path)?; Self::parse(&content) } /// Parse and validate all pipelines in a directory pub fn parse_directory(dir: &Path) -> Result, ParseError> { let mut pipelines = Vec::new(); if !dir.exists() { return Ok(pipelines); } for entry in std::fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.extension().map(|e| e == "yaml" || e == "yml").unwrap_or(false) { match Self::parse_file(&path) { Ok(pipeline) => { let filename = path.file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_default(); pipelines.push((filename, pipeline)); } Err(e) => { tracing::warn!("Failed to parse pipeline {:?}: {}", path, e); } } } } Ok(pipelines) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_valid_pipeline() { let yaml = r#" apiVersion: zclaw/v1 kind: Pipeline metadata: name: test-pipeline spec: steps: - id: step1 action: type: llm_generate template: "test" "#; let pipeline = PipelineParser::parse(yaml).unwrap(); assert_eq!(pipeline.metadata.name, "test-pipeline"); } #[test] fn test_parse_invalid_version() { let yaml = r#" apiVersion: invalid/v1 kind: Pipeline metadata: name: test spec: steps: [] "#; let result = PipelineParser::parse(yaml); assert!(matches!(result, Err(ParseError::InvalidVersion { .. }))); } #[test] fn test_parse_invalid_kind() { let yaml = r#" apiVersion: zclaw/v1 kind: NotPipeline metadata: name: test spec: steps: [] "#; let result = PipelineParser::parse(yaml); assert!(matches!(result, Err(ParseError::InvalidKind(_)))); } #[test] fn test_parse_empty_steps() { let yaml = r#" apiVersion: zclaw/v1 kind: Pipeline metadata: name: test spec: steps: [] "#; let result = PipelineParser::parse(yaml); assert!(matches!(result, Err(ParseError::Validation(_)))); } #[test] fn test_parse_duplicate_step_ids() { let yaml = r#" apiVersion: zclaw/v1 kind: Pipeline metadata: name: test spec: steps: - id: step1 action: type: llm_generate template: "test" - id: step1 action: type: llm_generate template: "test2" "#; let result = PipelineParser::parse(yaml); assert!(matches!(result, Err(ParseError::Validation(_)))); } }