use super::edges::RefEdge;
use super::nodes::RefNode;
use super::RefGraph;
use petgraph::algo::toposort;
use petgraph::graph::NodeIndex;
use petgraph::visit::EdgeRef;
use petgraph::Direction;
#[derive(Debug, Clone)]
pub struct QueryResult {
pub nodes: Vec<NodeIndex>,
}
impl QueryResult {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
pub fn len(&self) -> usize {
self.nodes.len()
}
}
impl RefGraph {
pub fn find_usages(&self, target: NodeIndex) -> QueryResult {
let nodes = self
.graph
.edges_directed(target, Direction::Incoming)
.map(|e| e.source())
.collect();
QueryResult { nodes }
}
pub fn find_dependencies(&self, source: NodeIndex) -> QueryResult {
let nodes = self
.graph
.edges_directed(source, Direction::Outgoing)
.map(|e| e.target())
.collect();
QueryResult { nodes }
}
pub fn find_incoming_transitions(&self, topic: NodeIndex) -> QueryResult {
let nodes = self
.graph
.edges_directed(topic, Direction::Incoming)
.filter(|e| {
matches!(e.weight(), RefEdge::TransitionsTo | RefEdge::Delegates | RefEdge::Routes)
})
.map(|e| e.source())
.collect();
QueryResult { nodes }
}
pub fn find_outgoing_transitions(&self, topic: NodeIndex) -> QueryResult {
let nodes = self
.graph
.edges_directed(topic, Direction::Outgoing)
.filter(|e| matches!(e.weight(), RefEdge::TransitionsTo | RefEdge::Delegates))
.map(|e| e.target())
.collect();
QueryResult { nodes }
}
pub fn find_action_invokers(&self, action_def: NodeIndex) -> QueryResult {
let nodes = self
.graph
.edges_directed(action_def, Direction::Incoming)
.filter(|e| matches!(e.weight(), RefEdge::Invokes))
.map(|e| e.source())
.collect();
QueryResult { nodes }
}
pub fn find_variable_readers(&self, variable: NodeIndex) -> QueryResult {
let nodes = self
.graph
.edges_directed(variable, Direction::Incoming)
.filter(|e| matches!(e.weight(), RefEdge::Reads))
.map(|e| e.source())
.collect();
QueryResult { nodes }
}
pub fn find_variable_writers(&self, variable: NodeIndex) -> QueryResult {
let nodes = self
.graph
.edges_directed(variable, Direction::Incoming)
.filter(|e| matches!(e.weight(), RefEdge::Writes))
.map(|e| e.source())
.collect();
QueryResult { nodes }
}
pub fn topic_execution_order(&self) -> Option<Vec<NodeIndex>> {
let topic_indices: Vec<_> = self.topics.values().copied().collect();
toposort(&self.graph, None).ok().map(|sorted| {
sorted
.into_iter()
.filter(|idx| topic_indices.contains(idx))
.collect()
})
}
pub fn get_topic_reasoning_actions(&self, topic_name: &str) -> Vec<NodeIndex> {
self.reasoning_actions
.iter()
.filter_map(|((t, _), &idx)| if t == topic_name { Some(idx) } else { None })
.collect()
}
pub fn get_topic_action_defs(&self, topic_name: &str) -> Vec<NodeIndex> {
self.action_defs
.iter()
.filter_map(|((t, _), &idx)| if t == topic_name { Some(idx) } else { None })
.collect()
}
pub fn stats(&self) -> GraphStats {
let mut stats = GraphStats::default();
for idx in self.graph.node_indices() {
match self.graph.node_weight(idx) {
Some(RefNode::Topic { .. }) => stats.topics += 1,
Some(RefNode::ActionDef { .. }) => stats.action_defs += 1,
Some(RefNode::ReasoningAction { .. }) => stats.reasoning_actions += 1,
Some(RefNode::Variable { .. }) => stats.variables += 1,
Some(RefNode::StartAgent { .. }) => stats.has_start_agent = true,
Some(RefNode::Connection { .. }) => stats.connections += 1,
None => {}
}
}
for edge in self.graph.edge_references() {
match edge.weight() {
RefEdge::TransitionsTo | RefEdge::Delegates => stats.transitions += 1,
RefEdge::Invokes => stats.invocations += 1,
RefEdge::Reads => stats.reads += 1,
RefEdge::Writes => stats.writes += 1,
_ => {}
}
}
stats
}
}
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct GraphStats {
pub topics: usize,
pub action_defs: usize,
pub reasoning_actions: usize,
pub variables: usize,
pub connections: usize,
pub has_start_agent: bool,
pub transitions: usize,
pub invocations: usize,
pub reads: usize,
pub writes: usize,
}
impl GraphStats {
pub fn total_definitions(&self) -> usize {
self.topics + self.action_defs + self.reasoning_actions + self.variables + self.connections
}
pub fn total_edges(&self) -> usize {
self.transitions + self.invocations + self.reads + self.writes
}
}
#[cfg(test)]
mod tests {
use crate::graph::RefGraph;
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")
}
fn two_topic_source() -> &'static str {
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"
"#
}
#[test]
fn test_find_outgoing_transitions_from_topic_a() {
let graph = parse_and_build(two_topic_source());
let topic_a_idx = graph.get_topic("topic_a").expect("topic_a not found");
let topic_b_idx = graph.get_topic("topic_b").expect("topic_b not found");
let result = graph.find_outgoing_transitions(topic_a_idx);
assert_eq!(result.len(), 1, "Expected exactly 1 outgoing transition from topic_a");
assert_eq!(result.nodes[0], topic_b_idx, "Expected transition target to be topic_b");
}
#[test]
fn test_find_incoming_transitions_to_topic_b() {
let graph = parse_and_build(two_topic_source());
let topic_a_idx = graph.get_topic("topic_a").expect("topic_a not found");
let topic_b_idx = graph.get_topic("topic_b").expect("topic_b not found");
let result = graph.find_incoming_transitions(topic_b_idx);
assert_eq!(result.len(), 1, "Expected exactly 1 incoming transition to topic_b");
assert_eq!(result.nodes[0], topic_a_idx, "Expected transition source to be topic_a");
}
#[test]
fn test_find_outgoing_transitions_empty_for_leaf_topic() {
let graph = parse_and_build(two_topic_source());
let topic_b_idx = graph.get_topic("topic_b").expect("topic_b not found");
let result = graph.find_outgoing_transitions(topic_b_idx);
assert!(result.is_empty(), "Expected no outgoing transitions from leaf topic_b");
}
#[test]
fn test_topic_execution_order_for_acyclic_graph() {
let graph = parse_and_build(two_topic_source());
let order = graph.topic_execution_order();
assert!(order.is_some(), "Expected a valid topological order for an acyclic graph");
let order = order.unwrap();
let topic_a_pos = order
.iter()
.position(|&idx| idx == graph.get_topic("topic_a").unwrap());
let topic_b_pos = order
.iter()
.position(|&idx| idx == graph.get_topic("topic_b").unwrap());
assert!(topic_a_pos.is_some(), "topic_a should appear in execution order");
assert!(topic_b_pos.is_some(), "topic_b should appear in execution order");
assert!(
topic_a_pos.unwrap() < topic_b_pos.unwrap(),
"topic_a should come before topic_b in topological order"
);
}
#[test]
fn test_stats_counts_nodes_correctly() {
let source = r#"config:
agent_name: "Test"
variables:
order_id: mutable string = ""
description: "Order ID"
start_agent selector:
description: "Route"
reasoning:
instructions: "Select"
actions:
go_main: @utils.transition to @topic.main
description: "Go to main"
topic main:
description: "Main topic"
actions:
get_order:
description: "Gets an order"
inputs:
id: string
description: "Order identifier"
outputs:
status: string
description: "Order status"
target: "flow://GetOrder"
reasoning:
instructions: "Help"
"#;
let graph = parse_and_build(source);
let stats = graph.stats();
assert_eq!(stats.topics, 1, "Expected 1 topic");
assert!(stats.has_start_agent, "Expected start_agent to be present");
assert_eq!(stats.action_defs, 1, "Expected 1 action def (get_order)");
assert_eq!(stats.variables, 1, "Expected 1 variable (order_id)");
assert!(graph.edge_count() > 0, "Expected at least one edge in the graph");
}
}