diff options
| author | soryu <soryu@soryu.co> | 2026-02-03 23:19:40 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-03 23:19:40 +0000 |
| commit | bfa3af9ef16fd5e255bdb606a99a5ebb535ba7cc (patch) | |
| tree | 53da855b4ca61a5c0856fc15112daa7a3748c637 /makima/src/daemon/chain/parser.rs | |
| parent | 1ce281adb89683a5fccfd153706383b14b944f32 (diff) | |
| parent | dcbf8c834626870a43b633b099f409d69d4f9b87 (diff) | |
| download | soryu-bfa3af9ef16fd5e255bdb606a99a5ebb535ba7cc.tar.gz soryu-bfa3af9ef16fd5e255bdb606a99a5ebb535ba7cc.zip | |
fix: Resolve merge conflict in server/mod.rsmakima/discuss-contract-feature
Combine imports from both branches - include both chains and contract_discuss handlers.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'makima/src/daemon/chain/parser.rs')
| -rw-r--r-- | makima/src/daemon/chain/parser.rs | 392 |
1 files changed, 392 insertions, 0 deletions
diff --git a/makima/src/daemon/chain/parser.rs b/makima/src/daemon/chain/parser.rs new file mode 100644 index 0000000..0f16710 --- /dev/null +++ b/makima/src/daemon/chain/parser.rs @@ -0,0 +1,392 @@ +//! 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<String>, + /// Repository URL (optional - contracts may have their own repos) + #[serde(alias = "repo")] + pub repository_url: Option<String>, + /// Local path for repository + pub local_path: Option<String>, + /// Contracts in this chain + pub contracts: Vec<ContractDefinition>, + /// Loop configuration + #[serde(rename = "loop")] + pub loop_config: Option<LoopConfig>, +} + +/// 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<String>, + /// Contract type (defaults to "simple") + #[serde(rename = "type", default = "default_contract_type")] + pub contract_type: String, + /// Phases for this contract + pub phases: Option<Vec<String>>, + /// Names of contracts this depends on (DAG edges) + pub depends_on: Option<Vec<String>>, + /// Tasks to create in this contract + pub tasks: Option<Vec<TaskDefinition>>, + /// Deliverables for this contract + pub deliverables: Option<Vec<DeliverableDefinition>>, +} + +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<String>, +} + +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<P: AsRef<Path>>(path: P) -> Result<ChainDefinition, ParseError> { + 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<ChainDefinition, ParseError> { + 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()) + ); + } +} |
