summaryrefslogblamecommitdiff
path: root/makima/src/daemon/chain/parser.rs
blob: 0f16710266dbdc041f3ef9cf881fad3e8a535356 (plain) (tree)







































































































































































































































































































































































































                                                                                         
//! 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())
        );
    }
}