summaryrefslogtreecommitdiff
path: root/makima/src/daemon/chain/parser.rs
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-03 22:01:29 +0000
committersoryu <soryu@soryu.co>2026-02-03 22:01:37 +0000
commitcf0a25af1d2834bfe6c5ea892ce5769936e5a673 (patch)
tree476ba326ac1752281a441b5c17d2b3be4b23a2a9 /makima/src/daemon/chain/parser.rs
parent8361916ce67f3d2ba191ebf27cb50e79cb42e39c (diff)
downloadsoryu-cf0a25af1d2834bfe6c5ea892ce5769936e5a673.tar.gz
soryu-cf0a25af1d2834bfe6c5ea892ce5769936e5a673.zip
Add makima chain mechanism
Diffstat (limited to 'makima/src/daemon/chain/parser.rs')
-rw-r--r--makima/src/daemon/chain/parser.rs392
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())
+ );
+ }
+}