//! Chain YAML parser. //! //! Parses chain definition files in YAML format into structured data //! that can be used to create chains and contracts. use serde::{Deserialize, Serialize}; use std::path::Path; use thiserror::Error; /// Error type for chain parsing operations. #[derive(Error, Debug)] pub enum ParseError { #[error("Failed to read chain file: {0}")] IoError(#[from] std::io::Error), #[error("Failed to parse YAML: {0}")] YamlError(#[from] serde_yaml::Error), #[error("Validation error: {0}")] ValidationError(String), } /// Chain definition parsed from YAML. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChainDefinition { /// Name of the chain pub name: String, /// Optional description pub description: Option, /// Repository URL (optional - contracts may have their own repos) #[serde(alias = "repo")] pub repository_url: Option, /// Local path for repository pub local_path: Option, /// Contracts in this chain pub contracts: Vec, /// Loop configuration #[serde(rename = "loop")] pub loop_config: Option, } /// Contract definition within a chain. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContractDefinition { /// Name of the contract pub name: String, /// Optional description pub description: Option, /// Contract type (defaults to "simple") #[serde(rename = "type", default = "default_contract_type")] pub contract_type: String, /// Phases for this contract pub phases: Option>, /// Names of contracts this depends on (DAG edges) pub depends_on: Option>, /// Tasks to create in this contract pub tasks: Option>, /// Deliverables for this contract pub deliverables: Option>, } fn default_contract_type() -> String { "simple".to_string() } /// Task definition within a contract. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaskDefinition { /// Name of the task pub name: String, /// Plan/instructions for the task pub plan: String, } /// Deliverable definition within a contract. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeliverableDefinition { /// Unique identifier for the deliverable pub id: String, /// Name of the deliverable pub name: String, /// Priority level (defaults to "required") #[serde(default = "default_priority")] pub priority: String, } fn default_priority() -> String { "required".to_string() } /// Loop configuration for chain iteration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoopConfig { /// Whether loop is enabled #[serde(default)] pub enabled: bool, /// Maximum number of iterations #[serde(default = "default_max_iterations")] pub max_iterations: i32, /// Progress check prompt/criteria pub progress_check: Option, } fn default_max_iterations() -> i32 { 10 } impl ChainDefinition { /// Validate the chain definition. pub fn validate(&self) -> Result<(), ParseError> { // Check for empty name if self.name.trim().is_empty() { return Err(ParseError::ValidationError( "Chain name cannot be empty".to_string(), )); } // Check for at least one contract if self.contracts.is_empty() { return Err(ParseError::ValidationError( "Chain must have at least one contract".to_string(), )); } // Collect all contract names for dependency validation let contract_names: std::collections::HashSet<_> = self.contracts.iter().map(|c| c.name.as_str()).collect(); // Check for duplicate contract names if contract_names.len() != self.contracts.len() { return Err(ParseError::ValidationError( "Duplicate contract names found".to_string(), )); } // Validate each contract for contract in &self.contracts { contract.validate(&contract_names)?; } Ok(()) } } impl ContractDefinition { /// Validate the contract definition. pub fn validate( &self, valid_contract_names: &std::collections::HashSet<&str>, ) -> Result<(), ParseError> { // Check for empty name if self.name.trim().is_empty() { return Err(ParseError::ValidationError( "Contract name cannot be empty".to_string(), )); } // Validate dependencies exist if let Some(deps) = &self.depends_on { for dep in deps { if !valid_contract_names.contains(dep.as_str()) { return Err(ParseError::ValidationError(format!( "Contract '{}' depends on unknown contract '{}'", self.name, dep ))); } // Self-dependency check if dep == &self.name { return Err(ParseError::ValidationError(format!( "Contract '{}' cannot depend on itself", self.name ))); } } } // Validate tasks if let Some(tasks) = &self.tasks { for task in tasks { if task.name.trim().is_empty() { return Err(ParseError::ValidationError(format!( "Task name cannot be empty in contract '{}'", self.name ))); } if task.plan.trim().is_empty() { return Err(ParseError::ValidationError(format!( "Task '{}' in contract '{}' has empty plan", task.name, self.name ))); } } } Ok(()) } } /// Parse a chain definition from a YAML file. pub fn parse_chain_file>(path: P) -> Result { let content = std::fs::read_to_string(path)?; parse_chain_yaml(&content) } /// Parse a chain definition from a YAML string. pub fn parse_chain_yaml(yaml: &str) -> Result { let definition: ChainDefinition = serde_yaml::from_str(yaml)?; definition.validate()?; Ok(definition) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_simple_chain() { let yaml = r#" name: Test Chain description: A test chain contracts: - name: Research type: simple tasks: - name: Analyze plan: "Analyze the codebase" - name: Implement type: simple depends_on: [Research] tasks: - name: Build plan: "Build the feature" "#; let chain = parse_chain_yaml(yaml).unwrap(); assert_eq!(chain.name, "Test Chain"); assert_eq!(chain.contracts.len(), 2); assert_eq!(chain.contracts[0].name, "Research"); assert_eq!(chain.contracts[1].name, "Implement"); assert_eq!( chain.contracts[1].depends_on, Some(vec!["Research".to_string()]) ); } #[test] fn test_parse_chain_with_loop() { let yaml = r#" name: Iterative Chain contracts: - name: Phase1 tasks: - name: Task1 plan: "Do something" loop: enabled: true max_iterations: 5 progress_check: "Check if goals are met" "#; let chain = parse_chain_yaml(yaml).unwrap(); assert!(chain.loop_config.is_some()); let loop_config = chain.loop_config.unwrap(); assert!(loop_config.enabled); assert_eq!(loop_config.max_iterations, 5); } #[test] fn test_parse_chain_with_deliverables() { let yaml = r#" name: Feature Chain contracts: - name: Research tasks: - name: Survey plan: "Survey existing code" deliverables: - id: analysis name: Codebase Analysis priority: required "#; let chain = parse_chain_yaml(yaml).unwrap(); let deliverables = chain.contracts[0].deliverables.as_ref().unwrap(); assert_eq!(deliverables.len(), 1); assert_eq!(deliverables[0].id, "analysis"); } #[test] fn test_validation_empty_name() { let yaml = r#" name: "" contracts: - name: Phase1 tasks: - name: Task1 plan: "Do something" "#; let result = parse_chain_yaml(yaml); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("name cannot be empty")); } #[test] fn test_validation_no_contracts() { let yaml = r#" name: Empty Chain contracts: [] "#; let result = parse_chain_yaml(yaml); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("at least one contract")); } #[test] fn test_validation_unknown_dependency() { let yaml = r#" name: Bad Chain contracts: - name: Phase1 depends_on: [NonExistent] tasks: - name: Task1 plan: "Do something" "#; let result = parse_chain_yaml(yaml); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("unknown contract")); } #[test] fn test_validation_self_dependency() { let yaml = r#" name: Self Ref Chain contracts: - name: Phase1 depends_on: [Phase1] tasks: - name: Task1 plan: "Do something" "#; let result = parse_chain_yaml(yaml); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("cannot depend on itself")); } #[test] fn test_validation_duplicate_names() { let yaml = r#" name: Dup Chain contracts: - name: Phase1 tasks: - name: Task1 plan: "Do something" - name: Phase1 tasks: - name: Task2 plan: "Do another thing" "#; let result = parse_chain_yaml(yaml); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("Duplicate contract names")); } #[test] fn test_repo_alias() { let yaml = r#" name: Repo Chain repo: https://github.com/user/project contracts: - name: Phase1 tasks: - name: Task1 plan: "Work on repo" "#; let chain = parse_chain_yaml(yaml).unwrap(); assert_eq!( chain.repository_url, Some("https://github.com/user/project".to_string()) ); } }