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