1use crate::compiled::CompiledGraph;
8use pe_core::error::PeError;
9use pe_core::node::NodeFn;
10use pe_core::state::State;
11use pe_core::types::{END, START};
12use std::collections::{HashMap, HashSet, VecDeque};
13use std::sync::Arc;
14
15#[derive(Debug, Clone)]
17pub(crate) struct Edge {
18 pub from: String,
19 pub to: String,
20}
21
22pub(crate) type RouterFn<S> = Arc<dyn Fn(&S) -> Vec<String> + Send + Sync>;
24
25pub(crate) struct ConditionalEdge<S: State> {
27 pub from: String,
28 pub router: RouterFn<S>,
29}
30
31pub struct StateGraph<S: State> {
50 pub(crate) nodes: HashMap<String, Arc<dyn NodeFn<S>>>,
51 pub(crate) edges: Vec<Edge>,
52 pub(crate) conditional_edges: Vec<ConditionalEdge<S>>,
53}
54
55impl<S: State> StateGraph<S> {
56 pub fn new() -> Self {
58 Self {
59 nodes: HashMap::new(),
60 edges: Vec::new(),
61 conditional_edges: Vec::new(),
62 }
63 }
64
65 pub fn add_node(mut self, name: impl Into<String>, node: impl NodeFn<S> + 'static) -> Self {
70 let name = name.into();
71 self.nodes.insert(name, Arc::new(node));
72 self
73 }
74
75 pub fn add_edge(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
79 self.edges.push(Edge {
80 from: from.into(),
81 to: to.into(),
82 });
83 self
84 }
85
86 pub fn add_conditional_edge(
91 mut self,
92 from: impl Into<String>,
93 router: impl Fn(&S) -> Vec<String> + Send + Sync + 'static,
94 ) -> Self {
95 self.conditional_edges.push(ConditionalEdge {
96 from: from.into(),
97 router: Arc::new(router),
98 });
99 self
100 }
101
102 pub(crate) fn fixed_successors(&self, node_name: &str) -> Vec<String> {
113 self.edges
114 .iter()
115 .filter(|e| e.from == node_name)
116 .map(|e| e.to.clone())
117 .collect()
118 }
119
120 #[must_use = "CompiledGraph is the executable form — don't discard it"]
128 pub fn compile(self) -> Result<CompiledGraph<S>, PeError> {
129 self.validate()?;
130 Ok(CompiledGraph::new(Arc::new(self)))
131 }
132
133 fn validate(&self) -> Result<(), PeError> {
135 let node_names: HashSet<&str> = self.nodes.keys().map(|s| s.as_str()).collect();
136
137 if node_names.contains(START) || node_names.contains(END) {
139 return Err(PeError::GraphValue {
140 details: format!(
141 "Cannot use reserved names '{}' or '{}' as node names",
142 START, END
143 ),
144 });
145 }
146
147 let has_start_edge = self.edges.iter().any(|e| e.from == START);
149 if !has_start_edge {
150 return Err(PeError::GraphValue {
151 details: "START has no outgoing edges — add at least one edge from START".into(),
152 });
153 }
154
155 if self.conditional_edges.iter().any(|ce| ce.from == START) {
157 return Err(PeError::GraphValue {
158 details: "Conditional edges from START are not allowed — use a router node instead"
159 .into(),
160 });
161 }
162
163 for edge in &self.edges {
166 if edge.from != START && !node_names.contains(edge.from.as_str()) {
167 return Err(PeError::GraphValue {
168 details: format!("Edge source '{}' is not a known node", edge.from),
169 });
170 }
171 if edge.to != END && !node_names.contains(edge.to.as_str()) {
172 return Err(PeError::GraphValue {
173 details: format!("Edge target '{}' is not a known node", edge.to),
174 });
175 }
176 }
177
178 for ce in &self.conditional_edges {
180 if !node_names.contains(ce.from.as_str()) {
181 return Err(PeError::GraphValue {
182 details: format!("Conditional edge source '{}' is not a known node", ce.from),
183 });
184 }
185 }
186
187 let reachable = self.reachable_from_start();
190 for name in node_names {
191 if !reachable.contains(name) {
192 return Err(PeError::UnreachableNode {
193 node: name.to_string(),
194 });
195 }
196 }
197
198 Ok(())
199 }
200
201 fn reachable_from_start(&self) -> HashSet<String> {
205 let mut reachable = HashSet::new();
206 let mut queue = VecDeque::new();
207
208 for edge in &self.edges {
210 if edge.from == START && edge.to != END {
211 queue.push_back(edge.to.clone());
212 }
213 }
214
215 let cond_sources: HashSet<&str> = self
218 .conditional_edges
219 .iter()
220 .map(|ce| ce.from.as_str())
221 .collect();
222
223 while let Some(node) = queue.pop_front() {
224 if !reachable.insert(node.clone()) {
225 continue; }
227
228 if cond_sources.contains(node.as_str()) {
230 for name in self.nodes.keys() {
231 queue.push_back(name.clone());
232 }
233 }
234
235 for edge in &self.edges {
237 if edge.from == node && edge.to != END {
238 queue.push_back(edge.to.clone());
239 }
240 }
241 }
242
243 reachable
244 }
245}
246
247impl<S: State> Default for StateGraph<S> {
248 fn default() -> Self {
249 Self::new()
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use crate::tests::{AppendNode, TestState};
257
258 #[test]
259 fn test_valid_linear_graph_compiles() {
260 let graph = StateGraph::<TestState>::new()
261 .add_node("a", AppendNode::new("a", "hello"))
262 .add_node("b", AppendNode::new("b", "world"))
263 .add_edge(START, "a")
264 .add_edge("a", "b")
265 .add_edge("b", END)
266 .compile();
267
268 assert!(graph.is_ok());
269 }
270
271 #[test]
272 fn test_no_start_edge_rejected() {
273 let result = StateGraph::<TestState>::new()
274 .add_node("a", AppendNode::new("a", "hello"))
275 .add_edge("a", END)
276 .compile();
277
278 assert!(result.is_err());
279 let err = result.unwrap_err().to_string();
280 assert!(err.contains("START"), "Error should mention START: {err}");
281 }
282
283 #[test]
284 fn test_unknown_edge_target_rejected() {
285 let result = StateGraph::<TestState>::new()
286 .add_node("a", AppendNode::new("a", "hello"))
287 .add_edge(START, "a")
288 .add_edge("a", "nonexistent")
289 .compile();
290
291 assert!(result.is_err());
292 let err = result.unwrap_err().to_string();
293 assert!(
294 err.contains("nonexistent"),
295 "Error should mention missing node: {err}"
296 );
297 }
298
299 #[test]
300 fn test_unreachable_node_rejected() {
301 let result = StateGraph::<TestState>::new()
302 .add_node("a", AppendNode::new("a", "hello"))
303 .add_node("orphan", AppendNode::new("orphan", "lost"))
304 .add_edge(START, "a")
305 .add_edge("a", END)
306 .compile();
307
308 assert!(result.is_err());
309 let err = result.unwrap_err().to_string();
310 assert!(
311 err.contains("orphan"),
312 "Error should mention unreachable node: {err}"
313 );
314 }
315
316 #[test]
317 fn test_conditional_from_start_rejected() {
318 let result = StateGraph::<TestState>::new()
319 .add_node("a", AppendNode::new("a", "hello"))
320 .add_edge(START, "a")
321 .add_conditional_edge(START, |_: &TestState| vec!["a".into()])
322 .add_edge("a", END)
323 .compile();
324
325 assert!(result.is_err());
326 let err = result.unwrap_err().to_string();
327 assert!(
328 err.contains("Conditional edges from START"),
329 "Error should explain restriction: {err}"
330 );
331 }
332
333 #[test]
334 fn test_conditional_edge_makes_nodes_reachable() {
335 let graph = StateGraph::<TestState>::new()
337 .add_node("a", AppendNode::new("a", "hello"))
338 .add_node("b", AppendNode::new("b", "world"))
339 .add_edge(START, "a")
340 .add_conditional_edge("a", |_: &TestState| vec![END.into()])
341 .add_edge("b", END)
342 .compile();
343
344 assert!(
345 graph.is_ok(),
346 "Nodes reachable via conditional edges should be valid"
347 );
348 }
349
350 #[test]
351 fn test_reserved_name_rejected_at_compile() {
352 let err = StateGraph::<TestState>::new()
353 .add_node(START, AppendNode::new(START, "bad"))
354 .add_edge(START, START)
355 .compile()
356 .unwrap_err()
357 .to_string();
358
359 assert!(
360 err.contains("reserved name"),
361 "Error should mention reserved name: {err}"
362 );
363 }
364}