allora_runtime/spec/
channel_spec_yaml.rs

1//! YAML parser/loader for ChannelSpec (schema v1).
2//! Focus: translate a YAML document into a validated ChannelSpec; no component instantiation.
3//!
4//! # Responsibilities
5//! * Validate top-level `version` (currently must be `1`).
6//! * Extract and validate `channel` mapping (kind + optional id).
7//! * Enforce non-empty id if present; leave id generation to builders when absent.
8//! * Return a `ChannelSpec` without performing instantiation.
9//!
10//! # YAML Schema (v1)
11//! ```yaml
12//! version: 1
13//! channel:
14//!   kind: direct   # optional (defaults to direct if omitted)
15//!   id: my-channel    # optional
16//! ```
17//!
18//! # Errors
19//! * Missing fields -> `Error::Serialization` with descriptive message.
20//! * Type mismatches (non-integer version, non-string kind) -> `Error::Serialization`.
21//! * Unsupported kind / version -> `Error::Serialization`.
22//! * Malformed YAML -> `Error::Serialization("yaml parse error: ...")` via `parse_str`.
23//!
24//! # Example
25//! ```rust
26//! use allora_runtime::spec::ChannelSpecYamlParser;
27//! let raw = "version: 1\nchannel:\n  id: parser-demo";
28//! let spec = ChannelSpecYamlParser::parse_str(raw).unwrap();
29//! assert_eq!(spec.channel_id(), Some("parser-demo"));
30//! ```
31//!
32//! # Extending to New Versions
33//! Introduce `ChannelSpecYamlParserV2` (keeping V1 intact) and dispatch based on detected `version`.
34//! Avoid breaking changes in existing parsers; prefer additive fields or new versioned parsers.
35//!
36//! # Future Formats
37//! Parallel parsers (e.g. `ChannelSpecJsonParser`) should replicate the same validation
38//! semantics and return `ChannelSpec` for uniform builder consumption.
39use super::{ChannelKindSpec, ChannelSpec};
40use crate::error::{Error, Result};
41use serde_yaml::Value as YamlValue;
42
43/// Parses YAML into a ChannelSpec (v1 schema).
44pub struct ChannelSpecYamlParser;
45
46impl ChannelSpecYamlParser {
47    /// Internal helper: validate presence of required top-level / nested keys.
48    fn validate_required(yaml: &YamlValue) -> Result<()> {
49        // version key present?
50        if yaml.get("version").is_none() {
51            return Err(Error::serialization("missing 'version'"));
52        }
53        // enforce root additionalProperties: false
54        if let Some(root_map) = yaml.as_mapping() {
55            for (k, _) in root_map.iter() {
56                if let Some(key_str) = k.as_str() {
57                    if key_str != "version" && key_str != "channel" {
58                        return Err(Error::serialization(format!(
59                            "unknown root property '{key_str}'"
60                        )));
61                    }
62                }
63            }
64        }
65        let channel = yaml
66            .get("channel")
67            .ok_or_else(|| Error::serialization("missing 'channel' section"))?;
68        if !channel.is_mapping() {
69            return Err(Error::serialization("'channel' must be a mapping"));
70        }
71        // enforce channel.additionalProperties: false
72        if let Some(ch_map) = channel.as_mapping() {
73            for (k, _) in ch_map.iter() {
74                if let Some(key_str) = k.as_str() {
75                    if key_str != "kind" && key_str != "id" {
76                        return Err(Error::serialization(format!(
77                            "unknown channel property '{key_str}'"
78                        )));
79                    }
80                }
81            }
82        }
83        Ok(())
84    }
85    /// Parse an already loaded YAML Value into a ChannelSpec.
86    pub fn parse_value(yaml: &YamlValue) -> Result<ChannelSpec> {
87        Self::validate_required(yaml)?; // early structural presence check
88        let version = yaml
89            .get("version")
90            .ok_or_else(|| Error::serialization("missing 'version'"))?;
91        if !version.is_i64() && !version.is_u64() {
92            return Err(Error::serialization("'version' must be integer"));
93        }
94        let v = version
95            .as_i64()
96            .unwrap_or(version.as_u64().unwrap_or(0) as i64);
97        if v != 1 {
98            return Err(Error::serialization("unsupported version (expected 1)"));
99        }
100        let channel = yaml
101            .get("channel")
102            .ok_or_else(|| Error::serialization("missing 'channel' section"))?;
103        if !channel.is_mapping() {
104            return Err(Error::serialization("'channel' must be a mapping"));
105        }
106        let kind_spec = if let Some(kind) = channel.get("kind") {
107            let kind_str = kind
108                .as_str()
109                .ok_or_else(|| Error::serialization("channel.kind must be string"))?;
110            match kind_str {
111                "direct" => ChannelKindSpec::Direct,
112                "queue" => ChannelKindSpec::Queue,
113                other => {
114                    return Err(Error::serialization(format!(
115                        "unsupported channel.kind '{other}'"
116                    )))
117                }
118            }
119        } else {
120            tracing::info!("channel.kind missing; defaulting to 'direct'");
121            ChannelKindSpec::Direct
122        };
123        let id_opt = channel
124            .get("id")
125            .and_then(|v| v.as_str())
126            .map(|s| s.to_string());
127        if let Some(ref id) = id_opt {
128            if id.is_empty() {
129                return Err(Error::serialization("channel.id must not be empty"));
130            }
131        }
132        Ok(ChannelSpec {
133            id: id_opt,
134            kind: kind_spec,
135        })
136    }
137    /// Convenience: parse raw YAML string directly (one-shot). Suitable for tests or embedding.
138    pub fn parse_str(raw: &str) -> Result<ChannelSpec> {
139        let val: YamlValue = serde_yaml::from_str(raw)
140            .map_err(|e| Error::serialization(format!("yaml parse error: {e}")))?;
141        Self::parse_value(&val)
142    }
143}