use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Spec {
pub spec_id: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance_config_schema: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub display: BTreeMap<String, serde_json::Value>,
pub branches: Vec<Branch>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Branch {
pub branch_id: String,
pub nodes: Vec<Node>,
#[serde(default)]
pub edges: Vec<Edge>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
pub id: String,
#[serde(rename = "type")]
pub node_type: String,
#[serde(default)]
pub config: serde_json::Value,
}
impl Node {
pub fn category(&self) -> &str {
self.node_type.split('.').next().unwrap_or(&self.node_type)
}
pub fn action(&self) -> &str {
self.node_type.split_once('.').map(|(_, a)| a).unwrap_or("")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
pub source: String,
pub target: String,
}
#[derive(Debug, thiserror::Error)]
pub enum SpecError {
#[error("spec parse error: {0}")]
Parse(#[from] serde_json::Error),
#[error("invalid spec '{spec_id}': {reason}")]
Invalid {
spec_id: String,
reason: String,
},
}
impl Spec {
pub fn from_json(s: &str) -> Result<Self, SpecError> {
Ok(serde_json::from_str(s)?)
}
pub fn validate_structure(&self) -> Result<(), SpecError> {
for branch in &self.branches {
let ids: std::collections::HashSet<&str> =
branch.nodes.iter().map(|n| n.id.as_str()).collect();
for edge in &branch.edges {
for (role, endpoint) in [("source", &edge.source), ("target", &edge.target)] {
if !ids.contains(endpoint.as_str()) {
return Err(SpecError::Invalid {
spec_id: self.spec_id.clone(),
reason: format!(
"branch '{}' edge {} '{}' references unknown node",
branch.branch_id, role, endpoint
),
});
}
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn node_type_split() {
let n = Node {
id: "x".into(),
node_type: "transform.state_append".into(),
config: serde_json::json!({}),
};
assert_eq!(n.category(), "transform");
assert_eq!(n.action(), "state_append");
}
#[test]
fn validate_catches_dangling_edge() {
let spec = Spec {
spec_id: "t".into(),
version: "1.0".into(),
description: String::new(),
aliases: vec![],
instance_config_schema: None,
display: Default::default(),
branches: vec![Branch {
branch_id: "__root__".into(),
nodes: vec![Node {
id: "a".into(),
node_type: "ingress.cron".into(),
config: serde_json::json!({}),
}],
edges: vec![Edge {
source: "a".into(),
target: "ghost".into(),
}],
}],
};
assert!(spec.validate_structure().is_err());
}
}