//! Pipeline v2 Parser //! //! Parses YAML pipeline definitions into PipelineV2 structs. //! //! # Example //! //! ```yaml //! apiVersion: zclaw/v2 //! kind: Pipeline //! metadata: //! name: course-generator //! displayName: 课程生成器 //! trigger: //! keywords: [课程, 教程] //! patterns: //! - "帮我做*课程" //! params: //! - name: topic //! type: string //! required: true //! stages: //! - id: outline //! type: llm //! prompt: "为{params.topic}创建课程大纲" //! ``` use std::collections::HashSet; use std::path::Path; use thiserror::Error; use crate::types_v2::{PipelineV2, API_VERSION_V2, Stage}; /// Parser errors #[derive(Debug, Error)] pub enum ParseErrorV2 { #[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("Validation error: {0}")] Validation(String), } /// Pipeline v2 parser pub struct PipelineParserV2; impl PipelineParserV2 { /// Parse a pipeline from YAML string pub fn parse(yaml: &str) -> Result { let pipeline: PipelineV2 = serde_yaml::from_str(yaml)?; // Validate API version if pipeline.api_version != API_VERSION_V2 { return Err(ParseErrorV2::InvalidVersion { expected: API_VERSION_V2.to_string(), actual: pipeline.api_version.clone(), }); } // Validate kind if pipeline.kind != "Pipeline" { return Err(ParseErrorV2::InvalidKind(pipeline.kind.clone())); } // Validate required fields if pipeline.metadata.name.is_empty() { return Err(ParseErrorV2::MissingField("metadata.name".to_string())); } // Validate stages if pipeline.stages.is_empty() { return Err(ParseErrorV2::Validation( "Pipeline must have at least one stage".to_string(), )); } // Validate stage IDs are unique let mut seen_ids = HashSet::new(); validate_stage_ids(&pipeline.stages, &mut seen_ids)?; // Validate parameter names are unique let mut seen_params = HashSet::new(); for param in &pipeline.params { if !seen_params.insert(¶m.name) { return Err(ParseErrorV2::Validation(format!( "Duplicate parameter name: {}", param.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 all v2 pipelines in a directory pub fn parse_directory(dir: &Path) -> Result, ParseErrorV2> { 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) } /// Try to parse as v2, return None if not v2 format pub fn try_parse(yaml: &str) -> Option> { // Quick check for v2 version marker if !yaml.contains("apiVersion: zclaw/v2") && !yaml.contains("apiVersion: 'zclaw/v2'") { return None; } Some(Self::parse(yaml)) } } /// Recursively validate stage IDs are unique fn validate_stage_ids(stages: &[Stage], seen_ids: &mut HashSet) -> Result<(), ParseErrorV2> { for stage in stages { let id = stage.id().to_string(); if !seen_ids.insert(id.clone()) { return Err(ParseErrorV2::Validation(format!("Duplicate stage ID: {}", id))); } // Recursively validate nested stages match stage { Stage::Parallel { stage, .. } => { validate_stage_ids(std::slice::from_ref(stage), seen_ids)?; } Stage::Sequential { stages: sub_stages, .. } => { validate_stage_ids(sub_stages, seen_ids)?; } Stage::Conditional { branches, default, .. } => { for branch in branches { validate_stage_ids(std::slice::from_ref(&branch.then), seen_ids)?; } if let Some(default_stage) = default { validate_stage_ids(std::slice::from_ref(default_stage), seen_ids)?; } } _ => {} } } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_valid_pipeline_v2() { let yaml = r#" apiVersion: zclaw/v2 kind: Pipeline metadata: name: test-pipeline displayName: 测试流水线 trigger: keywords: [测试, pipeline] patterns: - "测试*流水线" params: - name: topic type: string required: true label: 主题 stages: - id: step1 type: llm prompt: "test" "#; let pipeline = PipelineParserV2::parse(yaml).unwrap(); assert_eq!(pipeline.metadata.name, "test-pipeline"); assert_eq!(pipeline.metadata.display_name, Some("测试流水线".to_string())); assert_eq!(pipeline.stages.len(), 1); } #[test] fn test_parse_invalid_version() { let yaml = r#" apiVersion: zclaw/v1 kind: Pipeline metadata: name: test stages: - id: step1 type: llm prompt: "test" "#; let result = PipelineParserV2::parse(yaml); assert!(matches!(result, Err(ParseErrorV2::InvalidVersion { .. }))); } #[test] fn test_parse_invalid_kind() { let yaml = r#" apiVersion: zclaw/v2 kind: NotPipeline metadata: name: test stages: - id: step1 type: llm prompt: "test" "#; let result = PipelineParserV2::parse(yaml); assert!(matches!(result, Err(ParseErrorV2::InvalidKind(_)))); } #[test] fn test_parse_empty_stages() { let yaml = r#" apiVersion: zclaw/v2 kind: Pipeline metadata: name: test stages: [] "#; let result = PipelineParserV2::parse(yaml); assert!(matches!(result, Err(ParseErrorV2::Validation(_)))); } #[test] fn test_parse_duplicate_stage_ids() { let yaml = r#" apiVersion: zclaw/v2 kind: Pipeline metadata: name: test stages: - id: step1 type: llm prompt: "test" - id: step1 type: llm prompt: "test2" "#; let result = PipelineParserV2::parse(yaml); assert!(matches!(result, Err(ParseErrorV2::Validation(_)))); } #[test] fn test_parse_parallel_stage() { let yaml = r#" apiVersion: zclaw/v2 kind: Pipeline metadata: name: test stages: - id: parallel1 type: parallel each: "${params.items}" stage: id: inner type: llm prompt: "process ${item}" "#; let pipeline = PipelineParserV2::parse(yaml).unwrap(); assert_eq!(pipeline.metadata.name, "test"); assert_eq!(pipeline.stages.len(), 1); } #[test] fn test_parse_conditional_stage() { let yaml = r#" apiVersion: zclaw/v2 kind: Pipeline metadata: name: test stages: - id: cond1 type: conditional condition: "${params.level} == 'advanced'" branches: - when: "${params.level} == 'advanced'" then: id: advanced type: llm prompt: "advanced content" default: id: basic type: llm prompt: "basic content" "#; let pipeline = PipelineParserV2::parse(yaml).unwrap(); assert_eq!(pipeline.metadata.name, "test"); } #[test] fn test_parse_sequential_stage() { let yaml = r#" apiVersion: zclaw/v2 kind: Pipeline metadata: name: test stages: - id: seq1 type: sequential stages: - id: sub1 type: llm prompt: "step 1" - id: sub2 type: llm prompt: "step 2" "#; let pipeline = PipelineParserV2::parse(yaml).unwrap(); assert_eq!(pipeline.metadata.name, "test"); } #[test] fn test_parse_all_stage_types() { let yaml = r#" apiVersion: zclaw/v2 kind: Pipeline metadata: name: test-all-types stages: - id: llm1 type: llm prompt: "llm prompt" model: "gpt-4" temperature: 0.7 max_tokens: 1000 - id: compose1 type: compose template: '{"result": "${stages.llm1}"}' - id: skill1 type: skill skill_id: "research-skill" input: query: "${params.topic}" - id: hand1 type: hand hand_id: "browser" action: "navigate" params: url: "https://example.com" - id: http1 type: http url: "https://api.example.com/data" method: "POST" headers: Content-Type: "application/json" body: '{"query": "${params.query}"}' - id: setvar1 type: set_var name: "customVar" value: "${stages.http1.result}" "#; let pipeline = PipelineParserV2::parse(yaml).unwrap(); assert_eq!(pipeline.metadata.name, "test-all-types"); assert_eq!(pipeline.stages.len(), 6); } #[test] fn test_try_parse_v2() { // v2 format - should return Some let yaml_v2 = r#" apiVersion: zclaw/v2 kind: Pipeline metadata: name: test stages: - id: s1 type: llm prompt: "test" "#; assert!(PipelineParserV2::try_parse(yaml_v2).is_some()); // v1 format - should return None let yaml_v1 = r#" apiVersion: zclaw/v1 kind: Pipeline metadata: name: test spec: steps: [] "#; assert!(PipelineParserV2::try_parse(yaml_v1).is_none()); } #[test] fn test_parse_output_config() { let yaml = r#" apiVersion: zclaw/v2 kind: Pipeline metadata: name: test stages: - id: s1 type: llm prompt: "test" output: type: dynamic allowSwitch: true supportedTypes: [slideshow, quiz, document] defaultType: slideshow "#; let pipeline = PipelineParserV2::parse(yaml).unwrap(); assert!(pipeline.output.allow_switch); assert_eq!(pipeline.output.supported_types.len(), 3); } }