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}