use super::edges::RefEdge;
use super::error::ValidationError;
use super::nodes::RefNode;
use super::RefGraph;
use petgraph::algo::{is_cyclic_directed, tarjan_scc};
use petgraph::graph::NodeIndex;
use petgraph::visit::EdgeRef;
use petgraph::Direction;
use std::collections::HashSet;
#[derive(Debug, Default)]
pub struct ValidationResult {
pub errors: Vec<ValidationError>,
pub warnings: Vec<ValidationError>,
}
impl ValidationResult {
pub fn is_ok(&self) -> bool {
self.errors.is_empty()
}
pub fn has_issues(&self) -> bool {
!self.errors.is_empty() || !self.warnings.is_empty()
}
pub fn all_issues(&self) -> impl Iterator<Item = &ValidationError> {
self.errors.iter().chain(self.warnings.iter())
}
}
impl RefGraph {
pub fn validate(&self) -> ValidationResult {
let mut result = ValidationResult::default();
result.errors.extend(self.unresolved_references.clone());
result.errors.extend(self.find_cycles());
result.warnings.extend(self.find_unreachable_topics());
result.warnings.extend(self.find_unused_actions());
result.warnings.extend(self.find_unused_variables());
result
}
pub fn find_cycles(&self) -> Vec<ValidationError> {
if !is_cyclic_directed(&self.graph) {
return vec![];
}
let sccs = tarjan_scc(&self.graph);
let mut errors = Vec::new();
for scc in sccs {
if scc.len() > 1 {
let path: Vec<String> = scc
.iter()
.filter_map(|&idx| {
if let Some(RefNode::Topic { name, .. }) = self.graph.node_weight(idx) {
Some(name.clone())
} else {
None
}
})
.collect();
if !path.is_empty() {
errors.push(ValidationError::CycleDetected { path });
}
}
}
errors
}
pub fn find_unreachable_topics(&self) -> Vec<ValidationError> {
let start_idx = match self.start_agent {
Some(idx) => idx,
None => return vec![], };
let reachable = self.find_reachable_from(start_idx);
self.topics
.iter()
.filter_map(|(name, &idx)| {
if !reachable.contains(&idx) {
if let Some(RefNode::Topic { span, .. }) = self.graph.node_weight(idx) {
Some(ValidationError::UnreachableTopic {
name: name.clone(),
span: *span,
})
} else {
None
}
} else {
None
}
})
.collect()
}
pub fn find_unused_actions(&self) -> Vec<ValidationError> {
self.action_defs
.iter()
.filter_map(|((topic, name), &idx)| {
let has_incoming = self
.graph
.edges_directed(idx, Direction::Incoming)
.any(|e| matches!(e.weight(), RefEdge::Invokes));
if !has_incoming {
if let Some(RefNode::ActionDef { span, .. }) = self.graph.node_weight(idx) {
Some(ValidationError::UnusedActionDef {
name: name.clone(),
topic: topic.clone(),
span: *span,
})
} else {
None
}
} else {
None
}
})
.collect()
}
pub fn find_unused_variables(&self) -> Vec<ValidationError> {
self.variables
.iter()
.filter_map(|(name, &idx)| {
let has_readers = self
.graph
.edges_directed(idx, Direction::Incoming)
.any(|e| matches!(e.weight(), RefEdge::Reads));
if !has_readers {
if let Some(RefNode::Variable { span, .. }) = self.graph.node_weight(idx) {
Some(ValidationError::UnusedVariable {
name: name.clone(),
span: *span,
})
} else {
None
}
} else {
None
}
})
.collect()
}
fn find_reachable_from(&self, start: NodeIndex) -> HashSet<NodeIndex> {
let mut reachable = HashSet::new();
let mut stack = vec![start];
while let Some(idx) = stack.pop() {
if reachable.insert(idx) {
for edge in self.graph.edges_directed(idx, Direction::Outgoing) {
stack.push(edge.target());
}
}
}
reachable
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_and_build(source: &str) -> RefGraph {
let ast = crate::parse(source).expect("Failed to parse");
RefGraph::from_ast(&ast).expect("Failed to build graph")
}
#[test]
fn test_no_cycles() {
let source = r#"config:
agent_name: "Test"
start_agent topic_selector:
description: "Route to topics"
reasoning:
instructions: "Select the best topic"
actions:
go_help: @utils.transition to @topic.help
description: "Go to help topic"
topic help:
description: "Help topic"
reasoning:
instructions: "Provide help"
"#;
let graph = parse_and_build(source);
let result = graph.validate();
assert!(result.errors.is_empty());
}
#[test]
fn test_cycle_detected_between_two_topics() {
let source = r#"config:
agent_name: "Test"
start_agent selector:
description: "Route"
reasoning:
instructions: "Select"
actions:
go_a: @utils.transition to @topic.topic_a
description: "Go to A"
topic topic_a:
description: "Topic A"
reasoning:
instructions: "In A"
actions:
go_b: @utils.transition to @topic.topic_b
description: "Go to B"
topic topic_b:
description: "Topic B"
reasoning:
instructions: "In B"
actions:
go_a: @utils.transition to @topic.topic_a
description: "Back to A"
"#;
let graph = parse_and_build(source);
let cycles = graph.find_cycles();
assert!(!cycles.is_empty(), "Expected a cycle between topic_a and topic_b");
let cycle_names: Vec<_> = cycles
.iter()
.flat_map(|e| {
if let ValidationError::CycleDetected { path } = e {
path.clone()
} else {
vec![]
}
})
.collect();
assert!(
cycle_names.contains(&"topic_a".to_string())
|| cycle_names.contains(&"topic_b".to_string()),
"Cycle should involve topic_a and/or topic_b, got: {:?}",
cycle_names
);
}
#[test]
fn test_unreachable_topic_detected() {
let source = r#"config:
agent_name: "Test"
start_agent selector:
description: "Route"
reasoning:
instructions: "Select"
actions:
go_help: @utils.transition to @topic.help
description: "Go to help"
topic help:
description: "Help topic"
reasoning:
instructions: "Provide help"
topic orphan:
description: "This topic is never reached by any transition"
reasoning:
instructions: "Orphan"
"#;
let graph = parse_and_build(source);
let unreachable = graph.find_unreachable_topics();
assert!(!unreachable.is_empty(), "Expected 'orphan' to be detected as unreachable");
let unreachable_names: Vec<_> = unreachable
.iter()
.filter_map(|e| {
if let ValidationError::UnreachableTopic { name, .. } = e {
Some(name.clone())
} else {
None
}
})
.collect();
assert!(
unreachable_names.contains(&"orphan".to_string()),
"Expected 'orphan' in unreachable topics, got: {:?}",
unreachable_names
);
assert!(!unreachable_names.contains(&"help".to_string()), "'help' should be reachable");
}
#[test]
fn test_unused_action_def_detected() {
let source = r#"config:
agent_name: "Test"
topic main:
description: "Main topic"
actions:
get_data:
description: "Retrieves data from backend"
inputs:
record_id: string
description: "Record identifier"
outputs:
result: string
description: "Query result"
target: "flow://GetData"
reasoning:
instructions: "Help the user with their request"
"#;
let graph = parse_and_build(source);
let unused = graph.find_unused_actions();
assert!(!unused.is_empty(), "Expected 'get_data' to be detected as unused");
let unused_names: Vec<_> = unused
.iter()
.filter_map(|e| {
if let ValidationError::UnusedActionDef { name, topic, .. } = e {
Some((topic.clone(), name.clone()))
} else {
None
}
})
.collect();
assert!(
unused_names.contains(&("main".to_string(), "get_data".to_string())),
"Expected ('main', 'get_data') in unused actions, got: {:?}",
unused_names
);
}
#[test]
fn test_unused_variable_detected() {
let source = r#"config:
agent_name: "Test"
variables:
customer_name: mutable string = ""
description: "The customer's name — declared but never read"
topic main:
description: "Main topic"
reasoning:
instructions: "Help the user"
"#;
let graph = parse_and_build(source);
let unused = graph.find_unused_variables();
assert!(!unused.is_empty(), "Expected 'customer_name' to be detected as unused");
let unused_names: Vec<_> = unused
.iter()
.filter_map(|e| {
if let ValidationError::UnusedVariable { name, .. } = e {
Some(name.clone())
} else {
None
}
})
.collect();
assert!(
unused_names.contains(&"customer_name".to_string()),
"Expected 'customer_name' in unused variables, got: {:?}",
unused_names
);
}
#[test]
fn test_unresolved_topic_reference_detected() {
let source = r#"config:
agent_name: "Test"
start_agent selector:
description: "Route"
reasoning:
instructions: "Select"
actions:
go_missing: @utils.transition to @topic.nonexistent
description: "Go to a topic that does not exist"
topic real_topic:
description: "The only real topic"
reasoning:
instructions: "Real"
"#;
let graph = parse_and_build(source);
let result = graph.validate();
let unresolved: Vec<_> = result
.errors
.iter()
.filter(|e| matches!(e, ValidationError::UnresolvedReference { .. }))
.collect();
assert!(
!unresolved.is_empty(),
"Expected an unresolved reference error for @topic.nonexistent"
);
}
#[test]
fn test_validate_returns_ok_for_fully_connected_graph() {
let source = r#"config:
agent_name: "Test"
start_agent selector:
description: "Route to main"
reasoning:
instructions: "Select"
actions:
go_main: @utils.transition to @topic.main
description: "Enter main"
topic main:
description: "Main topic"
actions:
lookup:
description: "Look up a record"
inputs:
id: string
description: "Record ID"
outputs:
name: string
description: "Record name"
target: "flow://Lookup"
reasoning:
instructions: "Help"
actions:
do_lookup: @actions.lookup
description: "Perform the lookup"
"#;
let graph = parse_and_build(source);
let result = graph.validate();
assert!(result.errors.is_empty(), "Expected no errors, got: {:?}", result.errors);
let unused_action_warns: Vec<_> = result
.warnings
.iter()
.filter(|w| matches!(w, ValidationError::UnusedActionDef { .. }))
.collect();
assert!(
unused_action_warns.is_empty(),
"Expected no unused-action warnings, got: {:?}",
unused_action_warns
);
}
#[test]
fn test_three_node_cycle_detected() {
let source = r#"config:
agent_name: "Test"
start_agent selector:
description: "Route"
reasoning:
instructions: "Select"
actions:
go_a: @utils.transition to @topic.topic_a
description: "Go to A"
topic topic_a:
description: "Topic A"
reasoning:
instructions: "In A"
actions:
go_b: @utils.transition to @topic.topic_b
description: "Go to B"
topic topic_b:
description: "Topic B"
reasoning:
instructions: "In B"
actions:
go_c: @utils.transition to @topic.topic_c
description: "Go to C"
topic topic_c:
description: "Topic C"
reasoning:
instructions: "In C"
actions:
back_to_a: @utils.transition to @topic.topic_a
description: "Back to A"
"#;
let graph = parse_and_build(source);
let cycles = graph.find_cycles();
assert!(!cycles.is_empty(), "Expected a cycle among topic_a, topic_b, topic_c");
let cycle_names: Vec<_> = cycles
.iter()
.flat_map(|e| {
if let ValidationError::CycleDetected { path } = e {
path.clone()
} else {
vec![]
}
})
.collect();
assert!(
cycle_names
.iter()
.any(|n| { n == "topic_a" || n == "topic_b" || n == "topic_c" }),
"Cycle should involve topic_a/b/c, got: {:?}",
cycle_names
);
}
#[test]
fn test_invalid_property_access_on_non_object_variable() {
let source = r#"config:
agent_name: "Test"
variables:
count: mutable number = 0
description: "A counter"
start_agent selector:
description: "Route"
reasoning:
instructions:->
| Value: {!@variables.count.value}
actions:
go_main: @utils.transition to @topic.main
description: "Go to main"
topic main:
description: "Main"
reasoning:
instructions: "Help"
actions:
stay: @utils.transition to @topic.main
description: "Stay"
"#;
let graph = parse_and_build(source);
let result = graph.validate();
let invalid_access: Vec<_> = result
.errors
.iter()
.filter(|e| matches!(e, ValidationError::InvalidPropertyAccess { .. }))
.collect();
assert!(
!invalid_access.is_empty(),
"Expected InvalidPropertyAccess for @variables.count.value on number type"
);
}
#[test]
fn test_valid_property_access_on_object_variable() {
let source = r#"config:
agent_name: "Test"
variables:
stats: mutable object = {}
description: "Stats"
start_agent selector:
description: "Route"
reasoning:
instructions:->
| Total: {!@variables.stats.total}
actions:
go_main: @utils.transition to @topic.main
description: "Go to main"
topic main:
description: "Main"
reasoning:
instructions: "Help"
actions:
stay: @utils.transition to @topic.main
description: "Stay"
"#;
let graph = parse_and_build(source);
let result = graph.validate();
let invalid_access: Vec<_> = result
.errors
.iter()
.filter(|e| matches!(e, ValidationError::InvalidPropertyAccess { .. }))
.collect();
assert!(
invalid_access.is_empty(),
"Expected no InvalidPropertyAccess for @variables.stats.total on object type, got: {:?}",
invalid_access
);
}
#[test]
fn test_unresolved_variable_reference_detected() {
let source = r#"config:
agent_name: "Test"
variables:
real_var: mutable string = ""
start_agent selector:
description: "Route"
reasoning:
instructions: "Select"
actions:
go_main: @utils.transition to @topic.main
description: "Go to main"
topic main:
description: "Main"
reasoning:
instructions: "Help"
actions:
do_thing: @actions.do_thing
description: "Do a thing"
with id=@variables.nonexistent_var
"#;
let graph = parse_and_build(source);
let result = graph.validate();
let unresolved: Vec<_> = result
.errors
.iter()
.filter(|e| matches!(e, ValidationError::UnresolvedReference { .. }))
.collect();
assert!(
!unresolved.is_empty(),
"Expected an unresolved reference error for @variables.nonexistent_var"
);
}
}