bzzz-core 0.1.0

Bzzz core library - Declarative orchestration engine for AI Agents
Documentation
//! Parser and Validator for Bzzz protocol objects

use std::path::Path;

use crate::{AgentSpec, RunError, SwarmFile};

/// Parse result
pub type ParseResult<T> = Result<T, ParseError>;

/// Parse error
#[derive(Debug, Clone, thiserror::Error)]
pub enum ParseError {
    /// I/O error reading file
    #[error("IO error: {0}")]
    Io(String),
    /// YAML parsing error
    #[error("YAML parse error: {0}")]
    Yaml(String),
    /// JSON parsing error
    #[error("JSON parse error: {0}")]
    Json(String),
    /// Validation error
    #[error("Validation error: {0}")]
    Validation(String),
}

impl From<ParseError> for RunError {
    fn from(e: ParseError) -> Self {
        RunError::InvalidConfig {
            message: e.to_string(),
        }
    }
}

/// Parser for Agent Spec files
pub struct AgentSpecParser;

impl AgentSpecParser {
    /// Parse an Agent Spec from a YAML file
    pub fn from_yaml_file(path: &Path) -> ParseResult<AgentSpec> {
        let content = std::fs::read_to_string(path)
            .map_err(|e| ParseError::Io(format!("Failed to read {}: {}", path.display(), e)))?;

        Self::from_yaml_str(&content)
    }

    /// Parse an Agent Spec from a YAML string
    pub fn from_yaml_str(content: &str) -> ParseResult<AgentSpec> {
        let spec: AgentSpec = serde_yaml::from_str(content)
            .map_err(|e| ParseError::Yaml(format!("Failed to parse YAML: {}", e)))?;

        spec.validate()
            .map_err(|e| ParseError::Validation(e.to_string()))?;

        Ok(spec)
    }

    /// Parse an Agent Spec from a JSON string
    pub fn from_json_str(content: &str) -> ParseResult<AgentSpec> {
        let spec: AgentSpec = serde_json::from_str(content)
            .map_err(|e| ParseError::Json(format!("Failed to parse JSON: {}", e)))?;

        spec.validate()
            .map_err(|e| ParseError::Validation(e.to_string()))?;

        Ok(spec)
    }
}

/// Parser for SwarmFile
pub struct SwarmFileParser;

impl SwarmFileParser {
    /// Parse a SwarmFile from a YAML file
    ///
    /// The parsed SwarmFile will have its `file_path` field set to the source file path,
    /// which is essential for resolving relative paths to worker specs and delegate swarms.
    pub fn from_yaml_file(path: &Path) -> ParseResult<SwarmFile> {
        let content = std::fs::read_to_string(path)
            .map_err(|e| ParseError::Io(format!("Failed to read {}: {}", path.display(), e)))?;

        let mut swarm: SwarmFile = serde_yaml::from_str(&content)
            .map_err(|e| ParseError::Yaml(format!("Failed to parse YAML: {}", e)))?;

        swarm
            .validate()
            .map_err(|e| ParseError::Validation(e.to_string()))?;

        // Set the file_path for relative path resolution
        swarm.file_path = Some(path.to_path_buf());

        Ok(swarm)
    }

    /// Parse a SwarmFile from a YAML string
    pub fn from_yaml_str(content: &str) -> ParseResult<SwarmFile> {
        let swarm: SwarmFile = serde_yaml::from_str(content)
            .map_err(|e| ParseError::Yaml(format!("Failed to parse YAML: {}", e)))?;

        swarm
            .validate()
            .map_err(|e| ParseError::Validation(e.to_string()))?;

        Ok(swarm)
    }

    /// Parse a SwarmFile from a JSON string
    pub fn from_json_str(content: &str) -> ParseResult<SwarmFile> {
        let swarm: SwarmFile = serde_json::from_str(content)
            .map_err(|e| ParseError::Json(format!("Failed to parse JSON: {}", e)))?;

        swarm
            .validate()
            .map_err(|e| ParseError::Validation(e.to_string()))?;

        Ok(swarm)
    }
}

/// Validator for protocol objects
pub struct Validator;

impl Validator {
    /// Validate an Agent Spec
    pub fn validate_agent_spec(spec: &AgentSpec) -> ParseResult<()> {
        spec.validate()
            .map_err(|e| ParseError::Validation(e.to_string()))
    }

    /// Validate a SwarmFile
    pub fn validate_swarmfile(swarm: &SwarmFile) -> ParseResult<()> {
        swarm
            .validate()
            .map_err(|e| ParseError::Validation(e.to_string()))
    }

    /// Validate that all workers in a SwarmFile reference valid agent specs
    pub fn validate_swarm_workers(swarm: &SwarmFile, base_path: &Path) -> ParseResult<()> {
        for worker in &swarm.workers {
            // A2A workers reference remote agents — no local spec file to validate
            if worker.a2a.is_some() {
                continue;
            }
            if let Some(spec) = &worker.spec {
                let spec_path = base_path.join(spec);
                if !spec_path.exists() {
                    return Err(ParseError::Validation(format!(
                        "Worker '{}' references non-existent spec: {}",
                        worker.name,
                        spec_path.display()
                    )));
                }
            }
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{FlowPattern, RuntimeKind};

    #[test]
    fn test_parse_agent_spec_yaml() {
        let yaml = r#"
apiVersion: v1
id: test-agent
runtime:
  kind: Local
"#;
        let spec = AgentSpecParser::from_yaml_str(yaml).unwrap();
        assert_eq!(spec.id.as_str(), "test-agent");
        assert_eq!(spec.runtime.kind, RuntimeKind::Local);
    }

    #[test]
    fn test_parse_agent_spec_json() {
        let json = r#"{"apiVersion":"v1","id":"test","runtime":{"kind":"Local"}}"#;
        let spec = AgentSpecParser::from_json_str(json).unwrap();
        assert_eq!(spec.id.as_str(), "test");
    }

    #[test]
    fn test_parse_agent_spec_invalid_yaml() {
        let yaml = "invalid: yaml: :";
        let result = AgentSpecParser::from_yaml_str(yaml);
        assert!(result.is_err());
    }

    #[test]
    fn test_parse_agent_spec_validation_error() {
        let yaml = r#"
apiVersion: v1
id: ""
runtime:
  kind: Local
"#;
        let result = AgentSpecParser::from_yaml_str(yaml);
        assert!(matches!(result, Err(ParseError::Validation(_))));
    }

    #[test]
    fn test_parse_swarmfile_yaml() {
        let yaml = r#"
apiVersion: bzzz.dev/v1
kind: swarm
id: test-swarm
workers: []
flow:
  type: sequence
  steps: []
"#;
        let swarm = SwarmFileParser::from_yaml_str(yaml).unwrap();
        assert_eq!(swarm.id.as_str(), "test-swarm");
    }

    #[test]
    fn test_parse_swarmfile_with_workers() {
        let yaml = r#"
apiVersion: bzzz.dev/v1
kind: swarm
id: my-swarm
workers:
  - name: agent1
    spec: agents/a.yaml
flow:
  type: parallel
  branches: ["agent1"]
"#;
        let swarm = SwarmFileParser::from_yaml_str(yaml).unwrap();
        assert_eq!(swarm.workers.len(), 1);
        assert_eq!(swarm.workers[0].name, "agent1");
    }

    #[test]
    fn test_validate_agent_spec() {
        let spec = AgentSpec::new("test", RuntimeKind::Local);
        assert!(Validator::validate_agent_spec(&spec).is_ok());

        let invalid = AgentSpec::new("", RuntimeKind::Local);
        assert!(Validator::validate_agent_spec(&invalid).is_err());
    }

    #[test]
    fn test_validate_swarmfile() {
        let swarm = SwarmFile::new("test", FlowPattern::Sequence { steps: vec![] });
        assert!(Validator::validate_swarmfile(&swarm).is_ok());

        let invalid = SwarmFile::new("", FlowPattern::Sequence { steps: vec![] });
        assert!(Validator::validate_swarmfile(&invalid).is_err());
    }

    #[test]
    fn test_parse_all_flow_patterns() {
        let patterns = vec![
            (r#"{"type":"sequence","steps":[]}"#, "sequence"),
            (
                r#"{"type":"parallel","branches":[],"fail_fast":false}"#,
                "parallel",
            ),
            (
                r#"{"type":"conditional","condition":"x","then":"a"}"#,
                "conditional",
            ),
            (r#"{"type":"loop","over":"items","do_":"process"}"#, "loop"),
            (r#"{"type":"delegate","swarm":"sub.yaml"}"#, "delegate"),
            (
                r#"{"type":"supervisor","workers":[],"restart_policy":"on_failure"}"#,
                "supervisor",
            ),
            (r#"{"type":"compete","workers":[]}"#, "compete"),
            (
                r#"{"type":"escalation","primary":"main","chain":[]}"#,
                "escalation",
            ),
            (
                r#"{"type":"alongside","main":"main","side":[]}"#,
                "alongside",
            ),
        ];

        for (json, name) in patterns {
            // Include workers that may be statically referenced by any pattern under test.
            let yaml = format!(
                r#"
apiVersion: bzzz.dev/v1
kind: swarm
id: test
workers:
  - name: a
    spec: a.yaml
  - name: process
    spec: process.yaml
  - name: main
    spec: main.yaml
flow: {}
"#,
                json
            );
            let result = SwarmFileParser::from_yaml_str(&yaml);
            assert!(result.is_ok(), "Failed to parse {} pattern", name);
        }
    }
}