use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{braced, Ident, LitStr, Token};
pub struct ParsedTopology {
pub trigger: TriggerSpec,
pub edges: Vec<ParsedEdge>,
}
impl std::fmt::Debug for ParsedTopology {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ParsedTopology")
.field("trigger", &self.trigger)
.field("edges", &self.edges)
.finish()
}
}
#[derive(Debug)]
pub enum TriggerSpec {
ByReactor(String),
None,
}
#[derive(Debug)]
pub enum ParsedEdge {
Linear {
from: Ident,
from_inputs: Vec<Ident>,
to: Ident,
},
Routing {
from: Ident,
from_inputs: Vec<Ident>,
variants: Vec<RoutingVariant>,
},
}
#[derive(Debug)]
pub struct RoutingVariant {
pub variant_name: Ident,
pub target: Ident,
}
#[allow(clippy::wrong_self_convention)]
impl ParsedEdge {
pub fn from_name(&self) -> &Ident {
match self {
ParsedEdge::Linear { from, .. } => from,
ParsedEdge::Routing { from, .. } => from,
}
}
pub fn from_inputs(&self) -> &[Ident] {
match self {
ParsedEdge::Linear { from_inputs, .. } => from_inputs,
ParsedEdge::Routing { from_inputs, .. } => from_inputs,
}
}
}
impl Parse for ParsedTopology {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut trigger_reactor: Option<String> = None;
let mut edges: Option<Vec<ParsedEdge>> = None;
while !input.is_empty() {
let key: Ident = input.parse()?;
match key.to_string().as_str() {
"react" => {
return Err(syn::Error::new(
key.span(),
"the bundled `#[computation_graph(react = ...)]` form has been \
removed. Declare a standalone `#[reactor]` and reference it via \
`#[computation_graph(trigger = reactor(\"name\"), ...)]` — see \
initiative CLOACI-I-0101 for migration guidance.",
));
}
"trigger" => {
input.parse::<Token![=]>()?;
if trigger_reactor.is_some() {
return Err(syn::Error::new(key.span(), "duplicate 'trigger' field"));
}
let kind: Ident = input.parse()?;
if kind != "reactor" {
return Err(syn::Error::new(
kind.span(),
format!(
"unknown trigger kind '{}', expected 'reactor' \
(e.g. trigger = reactor(\"risk_signals\"))",
kind
),
));
}
let paren;
syn::parenthesized!(paren in input);
let name: LitStr = paren.parse().map_err(|_| {
syn::Error::new(
paren.span(),
"reactor(...) expects a string literal name \
(e.g. reactor(\"risk_signals\")). The type-path form was \
removed in CLOACI-I-0102 — cross-reference is now by name \
with runtime contract validation.",
)
})?;
if !paren.is_empty() {
return Err(syn::Error::new(
paren.span(),
"reactor(...) takes exactly one string-literal argument",
));
}
trigger_reactor = Some(name.value());
}
"graph" => {
input.parse::<Token![=]>()?;
if edges.is_some() {
return Err(syn::Error::new(key.span(), "duplicate 'graph' field"));
}
edges = Some(parse_graph_block(input)?);
}
other => {
return Err(syn::Error::new(
key.span(),
format!("unknown field '{}', expected 'trigger' or 'graph'", other),
));
}
}
let _ = input.parse::<Token![,]>();
}
let edges = edges.ok_or_else(|| {
syn::Error::new(proc_macro2::Span::call_site(), "missing 'graph' field")
})?;
let trigger = match trigger_reactor {
Some(name) => TriggerSpec::ByReactor(name),
None => TriggerSpec::None,
};
Ok(ParsedTopology { trigger, edges })
}
}
fn parse_graph_block(input: ParseStream) -> syn::Result<Vec<ParsedEdge>> {
let content;
braced!(content in input);
let mut edges = Vec::new();
while !content.is_empty() {
edges.push(parse_edge(&content)?);
let _ = content.parse::<Token![,]>();
}
Ok(edges)
}
fn parse_edge(input: ParseStream) -> syn::Result<ParsedEdge> {
let from: Ident = input.parse()?;
let from_inputs = if input.peek(syn::token::Paren) {
let content;
syn::parenthesized!(content in input);
let inputs: Punctuated<Ident, Token![,]> =
content.parse_terminated(Ident::parse, Token![,])?;
inputs.into_iter().collect()
} else {
Vec::new()
};
if input.peek(Token![=>]) {
input.parse::<Token![=>]>()?;
let variants_content;
braced!(variants_content in input);
let mut variants = Vec::new();
while !variants_content.is_empty() {
let variant_name: Ident = variants_content.parse()?;
variants_content.parse::<Token![->]>()?;
let target: Ident = variants_content.parse()?;
variants.push(RoutingVariant {
variant_name,
target,
});
let _ = variants_content.parse::<Token![,]>();
}
if variants.is_empty() {
return Err(syn::Error::new(
from.span(),
"routing edge must have at least one variant",
));
}
Ok(ParsedEdge::Routing {
from,
from_inputs,
variants,
})
} else if input.peek(Token![->]) {
input.parse::<Token![->]>()?;
let to: Ident = input.parse()?;
Ok(ParsedEdge::Linear {
from,
from_inputs,
to,
})
} else {
Err(syn::Error::new(
from.span(),
format!(
"expected '->' or '=>' after node '{}'. Terminal nodes are detected automatically \
from the graph — they don't need explicit declaration.",
from
),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use quote::quote;
fn parse_topology(tokens: proc_macro2::TokenStream) -> syn::Result<ParsedTopology> {
syn::parse2::<ParsedTopology>(tokens)
}
#[test]
fn test_error_react_form_removed() {
let tokens = quote! {
react = when_any(alpha),
graph = {
entry(alpha) -> output,
}
};
let result = parse_topology(tokens);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("bundled") && err.contains("CLOACI-I-0101"),
"expected migration diagnostic pointing at CLOACI-I-0101, got: {}",
err
);
}
#[test]
fn test_parse_split_form_trigger_reactor() {
let tokens = quote! {
trigger = reactor("risk_signals"),
graph = {
entry(alpha) -> output,
}
};
let topology = parse_topology(tokens).unwrap();
match topology.trigger {
TriggerSpec::ByReactor(name) => {
assert_eq!(name, "risk_signals");
}
other => panic!("expected ByReactor, got {:?}", other),
}
}
#[test]
fn test_error_trigger_reactor_type_path_rejected() {
let tokens = quote! {
trigger = reactor(MyReactor),
graph = { a -> b },
};
let result = parse_topology(tokens);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("string literal"),
"expected migration diagnostic, got: {}",
err
);
}
#[test]
fn test_parse_triggerless_form() {
let tokens = quote! {
graph = {
entry(alpha) -> output,
}
};
let topology = parse_topology(tokens).unwrap();
assert!(matches!(topology.trigger, TriggerSpec::None));
}
#[test]
fn test_error_trigger_unknown_kind() {
let tokens = quote! {
trigger = schedule("0 * * * *"),
graph = { a -> b },
};
let result = parse_topology(tokens);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("unknown trigger kind"), "got: {}", err);
}
#[test]
fn test_parse_linear_edge() {
let tokens = quote! {
trigger = reactor("r"),
graph = {
entry(alpha) -> middle,
middle -> output,
}
};
let topology = parse_topology(tokens).unwrap();
assert_eq!(topology.edges.len(), 2);
match &topology.edges[0] {
ParsedEdge::Linear {
from,
from_inputs,
to,
} => {
assert_eq!(from.to_string(), "entry");
assert_eq!(from_inputs.len(), 1);
assert_eq!(from_inputs[0].to_string(), "alpha");
assert_eq!(to.to_string(), "middle");
}
_ => panic!("expected linear edge"),
}
match &topology.edges[1] {
ParsedEdge::Linear {
from,
from_inputs,
to,
} => {
assert_eq!(from.to_string(), "middle");
assert!(from_inputs.is_empty());
assert_eq!(to.to_string(), "output");
}
_ => panic!("expected linear edge"),
}
}
#[test]
fn test_parse_routing_edge() {
let tokens = quote! {
trigger = reactor("r"),
graph = {
decision(alpha) => {
Signal -> handler_a,
NoAction -> handler_b,
},
}
};
let topology = parse_topology(tokens).unwrap();
assert_eq!(topology.edges.len(), 1);
match &topology.edges[0] {
ParsedEdge::Routing {
from,
from_inputs,
variants,
} => {
assert_eq!(from.to_string(), "decision");
assert_eq!(from_inputs.len(), 1);
assert_eq!(variants.len(), 2);
assert_eq!(variants[0].variant_name.to_string(), "Signal");
assert_eq!(variants[0].target.to_string(), "handler_a");
assert_eq!(variants[1].variant_name.to_string(), "NoAction");
assert_eq!(variants[1].target.to_string(), "handler_b");
}
_ => panic!("expected routing edge"),
}
}
#[test]
fn test_parse_mixed_edges() {
let tokens = quote! {
trigger = reactor("r"),
graph = {
decision_engine(alpha, beta, gamma) => {
Signal -> risk_check,
NoAction -> audit_logger,
},
risk_check(gamma) => {
Approved -> output_handler,
Blocked -> alert_handler,
},
}
};
let topology = parse_topology(tokens).unwrap();
assert_eq!(topology.edges.len(), 2);
match &topology.edges[0] {
ParsedEdge::Routing {
from, from_inputs, ..
} => {
assert_eq!(from.to_string(), "decision_engine");
assert_eq!(from_inputs.len(), 3);
}
_ => panic!("expected routing edge"),
}
match &topology.edges[1] {
ParsedEdge::Routing {
from,
from_inputs,
variants,
} => {
assert_eq!(from.to_string(), "risk_check");
assert_eq!(from_inputs.len(), 1);
assert_eq!(from_inputs[0].to_string(), "gamma");
assert_eq!(variants.len(), 2);
}
_ => panic!("expected routing edge"),
}
}
#[test]
fn test_parse_fan_in() {
let tokens = quote! {
trigger = reactor("r"),
graph = {
validate_a(a) -> merge,
validate_b(b) -> merge,
}
};
let topology = parse_topology(tokens).unwrap();
assert_eq!(topology.edges.len(), 2);
match &topology.edges[0] {
ParsedEdge::Linear { to, .. } => assert_eq!(to.to_string(), "merge"),
_ => panic!("expected linear edge"),
}
match &topology.edges[1] {
ParsedEdge::Linear { to, .. } => assert_eq!(to.to_string(), "merge"),
_ => panic!("expected linear edge"),
}
}
#[test]
fn test_parse_fan_out() {
let tokens = quote! {
trigger = reactor("r"),
graph = {
compute(a) -> output_handler,
compute(a) -> audit_logger,
}
};
let topology = parse_topology(tokens).unwrap();
assert_eq!(topology.edges.len(), 2);
match &topology.edges[0] {
ParsedEdge::Linear { from, to, .. } => {
assert_eq!(from.to_string(), "compute");
assert_eq!(to.to_string(), "output_handler");
}
_ => panic!("expected linear edge"),
}
match &topology.edges[1] {
ParsedEdge::Linear { from, to, .. } => {
assert_eq!(from.to_string(), "compute");
assert_eq!(to.to_string(), "audit_logger");
}
_ => panic!("expected linear edge"),
}
}
#[test]
fn test_error_missing_graph() {
let tokens = quote! {
trigger = reactor("r")
};
let result = parse_topology(tokens);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("missing 'graph' field"), "got: {}", err);
}
#[test]
fn test_error_unknown_field() {
let tokens = quote! {
trigger = reactor("r"),
graph = { a -> b },
bogus = something,
};
let result = parse_topology(tokens);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("unknown field 'bogus'"), "got: {}", err);
}
#[test]
fn test_error_empty_routing() {
let tokens = quote! {
trigger = reactor("r"),
graph = {
a(a) => {},
}
};
let result = parse_topology(tokens);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("at least one variant"), "got: {}", err);
}
#[test]
fn test_error_duplicate_trigger() {
let tokens = quote! {
trigger = reactor("r1"),
trigger = reactor("r2"),
graph = { a -> b },
};
let result = parse_topology(tokens);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("duplicate 'trigger'"), "got: {}", err);
}
}