1use super::graph::PipelineGraph;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum Severity {
8 Error,
9 Warning,
10}
11
12#[derive(Debug, Clone)]
14pub struct ValidationIssue {
15 pub severity: Severity,
16 pub rule: String,
17 pub message: String,
18 pub node_id: Option<String>,
19}
20
21pub fn validate(graph: &PipelineGraph) -> Vec<ValidationIssue> {
23 let mut issues = Vec::new();
24
25 check_start_node(graph, &mut issues);
26 check_terminal_node(graph, &mut issues);
27 check_reachability(graph, &mut issues);
28 check_edge_targets(graph, &mut issues);
29 check_start_no_incoming(graph, &mut issues);
30 check_exit_no_outgoing(graph, &mut issues);
31 check_condition_syntax(graph, &mut issues);
32 check_type_known(graph, &mut issues);
33 check_retry_target_exists(graph, &mut issues);
34 check_goal_gate_has_retry(graph, &mut issues);
35 check_prompt_on_llm_nodes(graph, &mut issues);
36
37 issues
38}
39
40pub fn is_valid(issues: &[ValidationIssue]) -> bool {
42 !issues.iter().any(|i| i.severity == Severity::Error)
43}
44
45fn check_start_node(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
46 let has_start = graph
47 .graph
48 .node_indices()
49 .any(|idx| graph.graph[idx].handler_type == "start");
50 if !has_start {
51 issues.push(ValidationIssue {
52 severity: Severity::Error,
53 rule: "start_node".into(),
54 message: "Graph must have exactly one start node (shape=Mdiamond)".into(),
55 node_id: None,
56 });
57 }
58}
59
60fn check_terminal_node(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
61 let has_exit = graph
62 .graph
63 .node_indices()
64 .any(|idx| graph.graph[idx].handler_type == "exit");
65 if !has_exit {
66 issues.push(ValidationIssue {
67 severity: Severity::Error,
68 rule: "terminal_node".into(),
69 message: "Graph must have at least one exit node (shape=Msquare)".into(),
70 node_id: None,
71 });
72 }
73}
74
75fn check_reachability(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
76 use petgraph::visit::Bfs;
77
78 let mut bfs = Bfs::new(&graph.graph, graph.start_node);
79 let mut reachable = std::collections::HashSet::new();
80 while let Some(node) = bfs.next(&graph.graph) {
81 reachable.insert(node);
82 }
83
84 for idx in graph.graph.node_indices() {
85 if !reachable.contains(&idx) {
86 let node = &graph.graph[idx];
87 issues.push(ValidationIssue {
88 severity: Severity::Error,
89 rule: "reachability".into(),
90 message: format!("Node '{}' is not reachable from start", node.id),
91 node_id: Some(node.id.clone()),
92 });
93 }
94 }
95}
96
97fn check_edge_targets(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
98 for idx in graph.graph.node_indices() {
101 let node = &graph.graph[idx];
102 if let Some(ref target) = node.retry_target {
103 if !graph.node_index.contains_key(target) {
104 issues.push(ValidationIssue {
105 severity: Severity::Error,
106 rule: "edge_target_exists".into(),
107 message: format!(
108 "Node '{}' has retry_target '{}' which does not exist",
109 node.id, target
110 ),
111 node_id: Some(node.id.clone()),
112 });
113 }
114 }
115 if let Some(ref target) = node.fallback_retry_target {
116 if !graph.node_index.contains_key(target) {
117 issues.push(ValidationIssue {
118 severity: Severity::Error,
119 rule: "edge_target_exists".into(),
120 message: format!(
121 "Node '{}' has fallback_retry_target '{}' which does not exist",
122 node.id, target
123 ),
124 node_id: Some(node.id.clone()),
125 });
126 }
127 }
128 }
129}
130
131fn check_start_no_incoming(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
132 let incoming = graph
133 .graph
134 .edges_directed(graph.start_node, petgraph::Direction::Incoming)
135 .count();
136 if incoming > 0 {
137 issues.push(ValidationIssue {
138 severity: Severity::Error,
139 rule: "start_no_incoming".into(),
140 message: "Start node must not have incoming edges".into(),
141 node_id: Some(graph.graph[graph.start_node].id.clone()),
142 });
143 }
144}
145
146fn check_exit_no_outgoing(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
147 let outgoing = graph
148 .graph
149 .edges_directed(graph.exit_node, petgraph::Direction::Outgoing)
150 .count();
151 if outgoing > 0 {
152 issues.push(ValidationIssue {
153 severity: Severity::Error,
154 rule: "exit_no_outgoing".into(),
155 message: "Exit node must not have outgoing edges".into(),
156 node_id: Some(graph.graph[graph.exit_node].id.clone()),
157 });
158 }
159}
160
161fn check_condition_syntax(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
162 use petgraph::visit::EdgeRef;
163 for edge_ref in graph.graph.edge_references() {
164 let edge = edge_ref.weight();
165 if !edge.condition.is_empty() {
166 let cond = edge.condition.trim();
168 if !cond.contains('=') && !cond.contains("!=") {
169 let from = &graph.graph[edge_ref.source()].id;
170 let to = &graph.graph[edge_ref.target()].id;
171 issues.push(ValidationIssue {
172 severity: Severity::Error,
173 rule: "condition_syntax".into(),
174 message: format!("Edge {} -> {} has invalid condition: '{}'", from, to, cond),
175 node_id: Some(from.clone()),
176 });
177 }
178 }
179 }
180}
181
182fn check_type_known(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
183 let known_types = [
184 "start",
185 "exit",
186 "codergen",
187 "conditional",
188 "wait.human",
189 "parallel",
190 "parallel.fan_in",
191 "tool",
192 "stack.manager_loop",
193 ];
194
195 for idx in graph.graph.node_indices() {
196 let node = &graph.graph[idx];
197 if !known_types.contains(&node.handler_type.as_str()) {
198 issues.push(ValidationIssue {
199 severity: Severity::Warning,
200 rule: "type_known".into(),
201 message: format!(
202 "Node '{}' has unknown handler type '{}'",
203 node.id, node.handler_type
204 ),
205 node_id: Some(node.id.clone()),
206 });
207 }
208 }
209}
210
211fn check_retry_target_exists(_graph: &PipelineGraph, _issues: &mut Vec<ValidationIssue>) {
212 }
214
215fn check_goal_gate_has_retry(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
216 for idx in graph.graph.node_indices() {
217 let node = &graph.graph[idx];
218 if node.goal_gate && node.retry_target.is_none() {
219 issues.push(ValidationIssue {
220 severity: Severity::Warning,
221 rule: "goal_gate_has_retry".into(),
222 message: format!("Node '{}' has goal_gate=true but no retry_target", node.id),
223 node_id: Some(node.id.clone()),
224 });
225 }
226 }
227}
228
229fn check_prompt_on_llm_nodes(graph: &PipelineGraph, issues: &mut Vec<ValidationIssue>) {
230 for idx in graph.graph.node_indices() {
231 let node = &graph.graph[idx];
232 if node.handler_type == "codergen" && node.prompt.is_empty() {
233 issues.push(ValidationIssue {
234 severity: Severity::Warning,
235 rule: "prompt_on_llm_nodes".into(),
236 message: format!("LLM node '{}' has no prompt attribute", node.id),
237 node_id: Some(node.id.clone()),
238 });
239 }
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use crate::attractor::dot_parser::parse_dot;
247 use crate::attractor::graph::PipelineGraph;
248
249 #[test]
250 fn test_valid_pipeline() {
251 let input = r#"
252 digraph test {
253 start [shape=Mdiamond]
254 task [shape=box, prompt="Do something"]
255 finish [shape=Msquare]
256 start -> task -> finish
257 }
258 "#;
259 let dot = parse_dot(input).unwrap();
260 let graph = PipelineGraph::from_dot(&dot).unwrap();
261 let issues = validate(&graph);
262
263 let errors: Vec<_> = issues
264 .iter()
265 .filter(|i| i.severity == Severity::Error)
266 .collect();
267 assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors);
268 }
269
270 #[test]
271 fn test_unreachable_node() {
272 let input = r#"
273 digraph test {
274 start [shape=Mdiamond]
275 task [shape=box, prompt="Do it"]
276 orphan [shape=box, prompt="Never reached"]
277 finish [shape=Msquare]
278 start -> task -> finish
279 }
280 "#;
281 let dot = parse_dot(input).unwrap();
282 let graph = PipelineGraph::from_dot(&dot).unwrap();
283 let issues = validate(&graph);
284
285 let reachability_errors: Vec<_> =
286 issues.iter().filter(|i| i.rule == "reachability").collect();
287 assert_eq!(reachability_errors.len(), 1);
288 assert!(reachability_errors[0].message.contains("orphan"));
289 }
290
291 #[test]
292 fn test_missing_prompt_warning() {
293 let input = r#"
294 digraph test {
295 start [shape=Mdiamond]
296 task [shape=box]
297 finish [shape=Msquare]
298 start -> task -> finish
299 }
300 "#;
301 let dot = parse_dot(input).unwrap();
302 let graph = PipelineGraph::from_dot(&dot).unwrap();
303 let issues = validate(&graph);
304
305 let warnings: Vec<_> = issues
306 .iter()
307 .filter(|i| i.rule == "prompt_on_llm_nodes")
308 .collect();
309 assert_eq!(warnings.len(), 1);
310 }
311
312 #[test]
313 fn test_goal_gate_without_retry_warning() {
314 let input = r#"
315 digraph test {
316 start [shape=Mdiamond]
317 task [shape=box, prompt="Do it"]
318 gate [shape=Msquare, goal_gate=true]
319 start -> task -> gate
320 }
321 "#;
322 let dot = parse_dot(input).unwrap();
323 let graph = PipelineGraph::from_dot(&dot).unwrap();
324 let issues = validate(&graph);
325
326 let warnings: Vec<_> = issues
327 .iter()
328 .filter(|i| i.rule == "goal_gate_has_retry")
329 .collect();
330 assert_eq!(warnings.len(), 1);
331 }
332}