use std::sync::Arc;
use indexmap::IndexMap;
use crate::error::BuildError;
use crate::node::NodeKind;
use crate::state::State;
pub type EdgeCondition = Arc<dyn Fn(&State) -> bool + Send + Sync>;
pub struct Edge {
pub from: String,
pub to: String,
pub condition: Option<EdgeCondition>,
pub analysis: Option<EdgeAnalysis>,
pub policy: Option<EdgePolicy>,
pub fallback: bool,
}
#[derive(Debug, Clone)]
pub struct EdgeAnalysis {
pub max_visits: Option<usize>,
}
#[derive(Debug, Clone)]
pub enum EdgePolicy {
MaxVisits { limit: usize, on_exceeded: EdgeExceededStrategy },
}
#[derive(Debug, Clone, Copy, Default)]
pub enum EdgeExceededStrategy {
#[default]
Strict,
SoftFallback,
Drop,
}
pub struct Graph {
pub(crate) nodes: IndexMap<String, NodeKind>,
pub(crate) edges: Vec<Edge>,
pub(crate) start: String,
pub(crate) end: String,
}
impl Graph {
pub fn node_names(&self) -> Vec<&str> {
self.nodes.keys().map(|s| s.as_str()).collect()
}
pub fn start_node(&self) -> &str {
&self.start
}
pub fn end_node(&self) -> &str {
&self.end
}
pub fn edges_from(&self, from: &str) -> Vec<&Edge> {
self.edges.iter().filter(|e| e.from == from).collect()
}
pub fn find_edge(&self, from: &str, to: &str) -> Option<&Edge> {
self.edges.iter().find(|e| e.from == from && e.to == to)
}
pub fn find_fallback_edge(&self, from: &str) -> Option<String> {
self.edges
.iter()
.find(|e| e.from == from && e.fallback)
.map(|e| e.to.clone())
}
pub fn validate(&self) -> Result<(), crate::error::TerminalError> {
if !self.nodes.contains_key(&self.start) {
return Err(crate::error::TerminalError::InvalidGraph(format!(
"start node '{}' not found",
self.start
)));
}
if !self.nodes.contains_key(&self.end) {
return Err(crate::error::TerminalError::InvalidGraph(format!(
"end node '{}' not found",
self.end
)));
}
for edge in &self.edges {
if !self.nodes.contains_key(&edge.from) {
return Err(crate::error::TerminalError::InvalidGraph(format!(
"edge references non-existent source node '{}'",
edge.from
)));
}
if !self.nodes.contains_key(&edge.to) {
return Err(crate::error::TerminalError::InvalidGraph(format!(
"edge references non-existent target node '{}'",
edge.to
)));
}
}
Ok(())
}
pub fn analyze_cycles(&self) -> CycleAnalysis {
let mut cycles = Vec::new();
let mut path = Vec::new();
let mut adj: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for edge in &self.edges {
adj.entry(edge.from.clone()).or_default().push(edge.to.clone());
}
for node in self.nodes.keys() {
let mut in_path = std::collections::HashSet::new();
path.clear();
self.dfs_cycles(node, node, &adj, &mut in_path, &mut path, &mut cycles);
}
let mut unprotected = cycles
.iter()
.filter(|cycle| {
let has_protection = (0..cycle.len()).any(|i| {
let next = (i + 1) % cycle.len();
let from = cycle[i].as_str();
let to = cycle[next].as_str();
self.edges.iter().any(|e| {
e.from == from
&& e.to == to
&& (e.analysis.as_ref().is_some_and(|a| a.max_visits.is_some())
|| e.policy.is_some())
})
});
!has_protection
})
.cloned()
.collect::<Vec<_>>();
unprotected.sort();
unprotected.dedup();
CycleAnalysis {
has_cycles: !cycles.is_empty(),
cycles,
unprotected_cycles: unprotected,
total_edges: self.edges.len(),
protected_edges: self.edges.iter().filter(|e| {
e.analysis.as_ref().is_some_and(|a| a.max_visits.is_some()) || e.policy.is_some()
}).count(),
}
}
fn dfs_cycles(
&self,
start: &str,
current: &str,
adj: &std::collections::HashMap<String, Vec<String>>,
in_path: &mut std::collections::HashSet<String>,
path: &mut Vec<String>,
cycles: &mut Vec<Vec<String>>,
) {
if in_path.contains(current) {
return;
}
path.push(current.to_string());
in_path.insert(current.to_string());
if let Some(neighbors) = adj.get(current) {
for neighbor in neighbors {
if neighbor.as_str() == start && path.len() >= 2 {
cycles.push(path.clone());
} else if neighbor.as_str() > start && !in_path.contains(neighbor) {
self.dfs_cycles(start, neighbor, adj, in_path, path, cycles);
}
}
}
path.pop();
in_path.remove(current);
}
}
#[derive(Debug, Clone)]
pub struct CycleAnalysis {
pub has_cycles: bool,
pub cycles: Vec<Vec<String>>,
pub unprotected_cycles: Vec<Vec<String>>,
pub total_edges: usize,
pub protected_edges: usize,
}
impl CycleAnalysis {
pub fn all_protected(&self) -> bool {
self.unprotected_cycles.is_empty()
}
pub fn report(&self) -> String {
let mut lines = Vec::new();
lines.push("=== Graph Cycle Analysis ===".to_string());
if !self.has_cycles {
lines.push("No cycles detected — graph is a DAG.".to_string());
return lines.join("\n");
}
lines.push(format!("Found {} cycle(s).", self.cycles.len()));
lines.push(format!(
"Edge protection: {}/{} edges have analysis or policy set.",
self.protected_edges, self.total_edges
));
for (i, cycle) in self.cycles.iter().enumerate() {
let cycle_str = cycle.join(" → ");
lines.push(format!(" Cycle {}: {} → {}", i + 1, cycle_str, cycle[0]));
if self.unprotected_cycles.contains(cycle) {
lines.push(" ⚠️ UNPROTECTED — no max_visits or policy on back-edge".into());
} else {
lines.push(" ✅ Protected by edge-level analysis or policy".into());
}
}
if !self.all_protected() {
lines.push("".into());
lines.push(
"⚠️ Recommendation: Set analysis.max_visits or policy on back-edges."
.to_string(),
);
}
lines.join("\n")
}
}
pub struct GraphBuilder {
name: String,
nodes: IndexMap<String, NodeKind>,
edges: Vec<Edge>,
start: Option<String>,
end: Option<String>,
}
impl GraphBuilder {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
nodes: IndexMap::new(),
edges: Vec::new(),
start: None,
end: None,
}
}
pub fn start(&mut self, node: impl Into<String>) -> Result<&mut Self, BuildError> {
self.start = Some(node.into());
Ok(self)
}
pub fn end(&mut self, node: impl Into<String>) -> Result<&mut Self, BuildError> {
self.end = Some(node.into());
Ok(self)
}
pub fn node(
&mut self,
name: impl Into<String>,
kind: NodeKind,
) -> Result<&mut Self, BuildError> {
let name = name.into();
if self.nodes.contains_key(&name) {
return Err(BuildError::DuplicateNode { id: name });
}
self.nodes.insert(name, kind);
Ok(self)
}
pub fn edge(
&mut self,
from: impl Into<String>,
to: impl Into<String>,
) -> Result<&mut Self, BuildError> {
self.edges.push(Edge {
from: from.into(),
to: to.into(),
condition: None,
analysis: None,
policy: None,
fallback: false,
});
Ok(self)
}
pub fn edge_if(
&mut self,
from: impl Into<String>,
to: impl Into<String>,
condition: impl Fn(&State) -> bool + Send + Sync + 'static,
) -> Result<&mut Self, BuildError> {
self.edges.push(Edge {
from: from.into(),
to: to.into(),
condition: Some(Arc::new(condition)),
analysis: None,
policy: None,
fallback: false,
});
Ok(self)
}
pub fn edge_fallback(
&mut self,
from: impl Into<String>,
to: impl Into<String>,
) -> Result<&mut Self, BuildError> {
self.edges.push(Edge {
from: from.into(),
to: to.into(),
condition: None,
analysis: None,
policy: None,
fallback: true,
});
Ok(self)
}
pub fn edge_analysis(
&mut self,
from: impl Into<String>,
to: impl Into<String>,
max_visits: usize,
) -> Result<&mut Self, BuildError> {
self.edges.push(Edge {
from: from.into(),
to: to.into(),
condition: None,
analysis: Some(EdgeAnalysis {
max_visits: Some(max_visits),
}),
policy: None,
fallback: false,
});
Ok(self)
}
pub fn edge_policy(
&mut self,
from: impl Into<String>,
to: impl Into<String>,
policy: EdgePolicy,
) -> Result<&mut Self, BuildError> {
self.edges.push(Edge {
from: from.into(),
to: to.into(),
condition: None,
analysis: None,
policy: Some(policy),
fallback: false,
});
Ok(self)
}
pub fn build(self) -> Result<Graph, BuildError> {
let start = self.start.ok_or(BuildError::MissingEntryPoint)?;
let end = self.end.ok_or(BuildError::MissingExitPoint)?;
let graph = Graph {
nodes: self.nodes,
edges: self.edges,
start,
end,
};
for edge in &graph.edges {
if !graph.nodes.contains_key(&edge.from) {
return Err(BuildError::MissingNode {
from: edge.from.clone(),
to: edge.from.clone(),
});
}
if !graph.nodes.contains_key(&edge.to) {
return Err(BuildError::MissingNode {
from: edge.from.clone(),
to: edge.to.clone(),
});
}
}
graph.validate().map_err(|e| match e {
crate::error::TerminalError::InvalidGraph(msg) => {
BuildError::InvalidEdgeDefinition {
from: "unknown".into(),
to: "unknown".into(),
reason: msg,
}
}
_ => BuildError::InvalidEdgeDefinition {
from: "unknown".into(),
to: "unknown".into(),
reason: "validation failed".into(),
},
})?;
Ok(graph)
}
pub fn name(&self) -> &str {
&self.name
}
}