1use std::collections::{HashMap, HashSet};
2
3use anyhow::{Context, Result};
4use rusqlite::Connection;
5use tracing::{info, warn};
6
7use crate::{
8 agents::{PlannedNode, PlannerGraphOutput},
9 db::graph::{self, GraphNodeRow, NodeState},
10 issues::PipelineIssue,
11};
12
13#[derive(Debug, Clone)]
15pub struct GraphNode {
16 pub issue_number: u32,
17 pub title: String,
18 pub area: String,
19 pub predicted_files: Vec<String>,
20 pub has_migration: bool,
21 pub complexity: String,
22 pub state: NodeState,
23 pub pr_number: Option<u32>,
24 pub run_id: Option<String>,
25 pub issue: Option<PipelineIssue>,
26 pub target_repo: Option<String>,
29}
30
31pub struct DependencyGraph {
36 session_id: String,
37 nodes: HashMap<u32, GraphNode>,
38 edges: HashMap<u32, HashSet<u32>>,
40 reverse_edges: HashMap<u32, HashSet<u32>>,
42}
43
44impl DependencyGraph {
45 pub fn new(session_id: &str) -> Self {
46 Self {
47 session_id: session_id.to_string(),
48 nodes: HashMap::new(),
49 edges: HashMap::new(),
50 reverse_edges: HashMap::new(),
51 }
52 }
53
54 pub fn session_id(&self) -> &str {
55 &self.session_id
56 }
57
58 pub fn contains(&self, issue: u32) -> bool {
59 self.nodes.contains_key(&issue)
60 }
61
62 pub fn node(&self, issue: u32) -> Option<&GraphNode> {
63 self.nodes.get(&issue)
64 }
65
66 pub fn node_count(&self) -> usize {
67 self.nodes.len()
68 }
69
70 pub fn add_node(&mut self, node: GraphNode) {
71 let num = node.issue_number;
72 self.nodes.insert(num, node);
73 self.edges.entry(num).or_default();
74 self.reverse_edges.entry(num).or_default();
75 }
76
77 pub fn add_edge(&mut self, from: u32, to: u32) -> bool {
81 if from == to || self.would_create_cycle(from, to) {
82 return false;
83 }
84 self.edges.entry(from).or_default().insert(to);
85 self.reverse_edges.entry(to).or_default().insert(from);
86 true
87 }
88
89 pub fn would_create_cycle(&self, from: u32, to: u32) -> bool {
93 let mut visited = HashSet::new();
94 let mut stack = vec![to];
95 while let Some(current) = stack.pop() {
96 if current == from {
97 return true;
98 }
99 if visited.insert(current) {
100 if let Some(deps) = self.edges.get(¤t) {
101 for &dep in deps {
102 if !visited.contains(&dep) {
103 stack.push(dep);
104 }
105 }
106 }
107 }
108 }
109 false
110 }
111
112 pub fn ready_issues(&self) -> Vec<u32> {
114 self.nodes
115 .iter()
116 .filter(|(_, node)| node.state == NodeState::Pending)
117 .filter(|(num, _)| {
118 self.edges.get(num).is_none_or(|deps| {
119 deps.iter()
120 .all(|d| self.nodes.get(d).is_some_and(|n| n.state == NodeState::Merged))
121 })
122 })
123 .map(|(num, _)| *num)
124 .collect()
125 }
126
127 pub fn awaiting_merge(&self) -> Vec<u32> {
129 self.nodes
130 .iter()
131 .filter(|(_, node)| node.state == NodeState::AwaitingMerge)
132 .map(|(num, _)| *num)
133 .collect()
134 }
135
136 pub fn transition(&mut self, issue: u32, state: NodeState) {
138 if let Some(node) = self.nodes.get_mut(&issue) {
139 info!(
140 issue,
141 from = %node.state,
142 to = %state,
143 "graph node state transition"
144 );
145 node.state = state;
146 }
147 }
148
149 pub fn set_pr_number(&mut self, issue: u32, pr_number: u32) {
151 if let Some(node) = self.nodes.get_mut(&issue) {
152 node.pr_number = Some(pr_number);
153 }
154 }
155
156 pub fn set_run_id(&mut self, issue: u32, run_id: &str) {
158 if let Some(node) = self.nodes.get_mut(&issue) {
159 node.run_id = Some(run_id.to_string());
160 }
161 }
162
163 pub fn dependencies(&self, issue: u32) -> HashSet<u32> {
165 self.edges.get(&issue).cloned().unwrap_or_default()
166 }
167
168 pub fn dependents(&self, issue: u32) -> HashSet<u32> {
170 self.reverse_edges.get(&issue).cloned().unwrap_or_default()
171 }
172
173 pub fn all_terminal(&self) -> bool {
175 self.nodes.values().all(|n| matches!(n.state, NodeState::Merged | NodeState::Failed))
176 }
177
178 pub fn is_blocked(&self, issue: u32) -> bool {
180 self.edges.get(&issue).is_some_and(|deps| {
181 deps.iter().any(|d| self.nodes.get(d).is_some_and(|n| n.state == NodeState::Failed))
182 })
183 }
184
185 pub fn propagate_failure(&mut self, issue: u32) -> Vec<u32> {
191 use std::collections::VecDeque;
192
193 let mut queue = VecDeque::new();
194 let mut newly_failed = Vec::new();
195
196 if let Some(dependents) = self.reverse_edges.get(&issue) {
198 queue.extend(dependents.iter().copied());
199 }
200
201 let mut visited = HashSet::new();
202 visited.insert(issue);
203
204 while let Some(current) = queue.pop_front() {
205 if !visited.insert(current) {
206 continue;
207 }
208 let dominated = self
209 .nodes
210 .get(¤t)
211 .is_some_and(|n| matches!(n.state, NodeState::Pending | NodeState::InFlight));
212 if !dominated {
213 continue;
214 }
215 self.transition(current, NodeState::Failed);
216 newly_failed.push(current);
217 if let Some(dependents) = self.reverse_edges.get(¤t) {
218 queue.extend(dependents.iter().copied());
219 }
220 }
221
222 newly_failed
223 }
224
225 pub fn remove_node(&mut self, issue: u32) {
227 self.nodes.remove(&issue);
228 if let Some(deps) = self.edges.remove(&issue) {
229 for dep in &deps {
230 if let Some(rev) = self.reverse_edges.get_mut(dep) {
231 rev.remove(&issue);
232 }
233 }
234 }
235 if let Some(dependents) = self.reverse_edges.remove(&issue) {
236 for dependent in &dependents {
237 if let Some(fwd) = self.edges.get_mut(dependent) {
238 fwd.remove(&issue);
239 }
240 }
241 }
242 }
243
244 pub fn all_issues(&self) -> Vec<u32> {
246 let mut nums: Vec<u32> = self.nodes.keys().copied().collect();
247 nums.sort_unstable();
248 nums
249 }
250
251 pub fn from_db(conn: &Connection, session_id: &str) -> Result<Self> {
253 let db_nodes = graph::get_nodes(conn, session_id).context("loading graph nodes")?;
254 let db_edges = graph::get_edges(conn, session_id).context("loading graph edges")?;
255
256 let mut g = Self::new(session_id);
257 for row in &db_nodes {
258 g.add_node(GraphNode {
259 issue_number: row.issue_number,
260 title: row.title.clone(),
261 area: row.area.clone(),
262 predicted_files: row.predicted_files.clone(),
263 has_migration: row.has_migration,
264 complexity: row.complexity.clone(),
265 state: row.state,
266 pr_number: row.pr_number,
267 run_id: row.run_id.clone(),
268 issue: None,
269 target_repo: row.target_repo.clone(),
270 });
271 }
272 for (from, to) in &db_edges {
273 if !g.add_edge(*from, *to) {
274 warn!(from, to, "skipping persisted edge that would create cycle");
275 }
276 }
277
278 Ok(g)
279 }
280
281 pub fn save_to_db(&self, conn: &Connection) -> Result<()> {
285 let tx = conn.unchecked_transaction().context("starting graph save transaction")?;
286
287 graph::delete_session(&tx, &self.session_id)?;
288 for node in self.nodes.values() {
289 let row = GraphNodeRow {
290 issue_number: node.issue_number,
291 session_id: self.session_id.clone(),
292 state: node.state,
293 pr_number: node.pr_number,
294 run_id: node.run_id.clone(),
295 title: node.title.clone(),
296 area: node.area.clone(),
297 predicted_files: node.predicted_files.clone(),
298 has_migration: node.has_migration,
299 complexity: node.complexity.clone(),
300 target_repo: node.target_repo.clone(),
301 };
302 graph::insert_node(&tx, &self.session_id, &row)?;
303 }
304 for (&from, deps) in &self.edges {
305 for &to in deps {
306 graph::insert_edge(&tx, &self.session_id, from, to)?;
307 }
308 }
309
310 tx.commit().context("committing graph save transaction")?;
311 Ok(())
312 }
313
314 pub fn from_planner_output(
316 session_id: &str,
317 plan: &PlannerGraphOutput,
318 issues: &[PipelineIssue],
319 ) -> Self {
320 let issue_map: HashMap<u32, &PipelineIssue> =
321 issues.iter().map(|i| (i.number, i)).collect();
322 let mut g = Self::new(session_id);
323 for node in &plan.nodes {
324 g.add_node(node_from_planned(node, issue_map.get(&node.number).copied()));
325 }
326 add_planned_edges(&mut g, &plan.nodes);
327 g
328 }
329
330 pub fn merge_planner_output(&mut self, plan: &PlannerGraphOutput, issues: &[PipelineIssue]) {
335 let issue_map: HashMap<u32, &PipelineIssue> =
336 issues.iter().map(|i| (i.number, i)).collect();
337 let new_nodes: Vec<&PlannedNode> =
338 plan.nodes.iter().filter(|n| !self.contains(n.number)).collect();
339 for node in &new_nodes {
340 self.add_node(node_from_planned(node, issue_map.get(&node.number).copied()));
341 }
342 add_planned_edges(self, &new_nodes);
343 }
344
345 fn compute_layers(&self) -> Vec<Vec<u32>> {
347 fn compute_depth(
348 node: u32,
349 edges: &HashMap<u32, HashSet<u32>>,
350 depth: &mut HashMap<u32, usize>,
351 ) -> usize {
352 if let Some(&d) = depth.get(&node) {
353 return d;
354 }
355 let d = edges.get(&node).map_or(0, |deps| {
356 deps.iter().map(|&dep| compute_depth(dep, edges, depth) + 1).max().unwrap_or(0)
357 });
358 depth.insert(node, d);
359 d
360 }
361
362 let mut depth: HashMap<u32, usize> = HashMap::new();
363 let issues = self.all_issues();
364
365 for &num in &issues {
366 compute_depth(num, &self.edges, &mut depth);
367 }
368
369 let max_depth = depth.values().copied().max().unwrap_or(0);
370 let mut layers: Vec<Vec<u32>> = vec![vec![]; max_depth + 1];
371 for &num in &issues {
372 let d = depth[&num];
373 layers[d].push(num);
374 }
375 for layer in &mut layers {
376 layer.sort_unstable();
377 }
378 layers
379 }
380
381 pub fn display_layered(&self) -> Vec<String> {
383 if self.nodes.is_empty() {
384 return vec![" (empty graph)".to_string()];
385 }
386
387 let layers = self.compute_layers();
388 let mut lines = Vec::new();
389
390 let total = self.nodes.len();
392 let merged = self.nodes.values().filter(|n| n.state == NodeState::Merged).count();
393 let in_flight = self.nodes.values().filter(|n| n.state == NodeState::InFlight).count();
394 let failed = self.nodes.values().filter(|n| n.state == NodeState::Failed).count();
395
396 let mut summary_parts = vec![format!("{total} issues")];
397 if merged > 0 {
398 summary_parts.push(format!("{merged} merged"));
399 }
400 if in_flight > 0 {
401 summary_parts.push(format!("{in_flight} in flight"));
402 }
403 if failed > 0 {
404 summary_parts.push(format!("{failed} failed"));
405 }
406 lines.push(format!(" {}", summary_parts.join(", ")));
407 lines.push(String::new());
408
409 let box_width = self.compute_box_width();
410 let full_box = box_width + 2;
411 let spacing = 2;
412 let max_per_row = (terminal_width().saturating_sub(4) / (full_box + spacing)).max(1);
413
414 for (layer_idx, layer) in layers.iter().enumerate() {
415 let label = if layer_idx == 0 { "no deps" } else { "" };
417 if label.is_empty() {
418 lines.push(format!(" Layer {layer_idx}"));
419 } else {
420 lines.push(format!(" Layer {layer_idx} ({label})"));
421 }
422 lines.push(String::new());
423
424 let boxes: Vec<Vec<String>> =
426 layer.iter().map(|&num| self.render_box(num, box_width)).collect();
427
428 for chunk in boxes.chunks(max_per_row) {
429 let max_height = chunk.iter().map(Vec::len).max().unwrap_or(0);
430 for row in 0..max_height {
431 let mut line = String::from(" ");
432 for (i, b) in chunk.iter().enumerate() {
433 if i > 0 {
434 line.push_str(" ");
435 }
436 if row < b.len() {
437 line.push_str(&b[row]);
438 } else {
439 line.push_str(&" ".repeat(full_box));
440 }
441 }
442 lines.push(line);
443 }
444 }
445
446 if layer_idx + 1 < layers.len() {
448 let next_layer = &layers[layer_idx + 1];
449 let connector_lines =
450 self.render_connectors(layer, next_layer, box_width, max_per_row);
451 lines.extend(connector_lines);
452 }
453 }
454
455 lines.push(String::new());
457 lines.push(
458 " Legend: [*] merged [~] in flight [ ] pending [?] awaiting merge [!] failed"
459 .to_string(),
460 );
461
462 lines
463 }
464
465 fn compute_box_width(&self) -> usize {
467 let min_width = 30;
468 let max_width = 44;
469 let longest = self
470 .nodes
471 .values()
472 .map(|n| {
473 let issue_label = format!("#{}", n.issue_number);
474 3 + 1 + issue_label.len() + 2 + n.title.len()
476 })
477 .max()
478 .unwrap_or(min_width);
479 longest.clamp(min_width, max_width)
480 }
481
482 fn render_box(&self, issue: u32, width: usize) -> Vec<String> {
484 let Some(node) = self.nodes.get(&issue) else {
485 return vec![];
486 };
487
488 let state_char = match node.state {
489 NodeState::Merged => '*',
490 NodeState::InFlight => '~',
491 NodeState::Pending => ' ',
492 NodeState::AwaitingMerge => '?',
493 NodeState::Failed => '!',
494 };
495
496 let blocked = if self.is_blocked(issue) { " BLOCKED" } else { "" };
497
498 let issue_label = format!("#{issue}");
499 let title_line = format!("[{state_char}] {issue_label} {}", node.title);
500 let title_truncated = if title_line.len() > width {
501 format!("{}..", &title_line[..width - 2])
502 } else {
503 title_line
504 };
505
506 let mut detail_parts = vec![node.area.clone()];
508 if let Some(pr) = node.pr_number {
509 detail_parts.push(format!("PR #{pr}"));
510 }
511 let mut detail_line = detail_parts.join(" ");
512 if !blocked.is_empty() {
513 detail_line.push_str(blocked);
514 }
515 let detail_truncated = if detail_line.len() > width {
516 format!("{}..", &detail_line[..width - 2])
517 } else {
518 detail_line
519 };
520
521 let top = format!("\u{250c}{}\u{2510}", "\u{2500}".repeat(width));
522 let mid = format!("\u{2502}{title_truncated:<width$}\u{2502}");
523 let mid2 = format!("\u{2502}{detail_truncated:<width$}\u{2502}");
524 let bot = format!("\u{2514}{}\u{2518}", "\u{2500}".repeat(width));
525
526 vec![top, mid, mid2, bot]
527 }
528
529 fn render_connectors(
531 &self,
532 from_layer: &[u32],
533 to_layer: &[u32],
534 box_width: usize,
535 max_per_row: usize,
536 ) -> Vec<String> {
537 let full_box_width = box_width + 2; let spacing = 2usize;
541
542 let from_centers: Vec<(u32, usize)> = from_layer
544 .iter()
545 .enumerate()
546 .map(|(i, &num)| {
547 let col = i % max_per_row;
548 let x = col * (full_box_width + spacing) + full_box_width / 2;
549 (num, x)
550 })
551 .collect();
552 let to_centers: Vec<(u32, usize)> = to_layer
553 .iter()
554 .enumerate()
555 .map(|(i, &num)| {
556 let col = i % max_per_row;
557 let x = col * (full_box_width + spacing) + full_box_width / 2;
558 (num, x)
559 })
560 .collect();
561
562 let mut connections: Vec<(usize, usize)> = Vec::new();
564 for &(to_num, to_x) in &to_centers {
565 let deps = self.dependencies(to_num);
566 for &(from_num, from_x) in &from_centers {
567 if deps.contains(&from_num) {
568 connections.push((from_x, to_x));
569 }
570 }
571 }
572 connections.sort_unstable();
573 connections.dedup();
574
575 if connections.is_empty() {
576 return lines_with_gap(1);
577 }
578
579 let max_x =
580 from_centers.iter().chain(to_centers.iter()).map(|(_, x)| *x).max().unwrap_or(0)
581 + full_box_width;
582 let width = max_x + 4;
583
584 let mut source_cols: HashSet<usize> = HashSet::new();
586 let mut target_cols: HashSet<usize> = HashSet::new();
587 for &(from_x, to_x) in &connections {
588 source_cols.insert(from_x);
589 target_cols.insert(to_x);
590 }
591
592 let mut row1 = vec![' '; width];
594 for &col in &source_cols {
595 row1[col + 2] = '\u{2502}'; }
597
598 let row2 = build_connector_row(&connections, &source_cols, &target_cols, width);
600
601 let mut row3 = vec![' '; width];
603 for &col in &target_cols {
604 row3[col + 2] = '\u{25bc}'; }
606
607 vec![
608 format!(" {}", row1.iter().collect::<String>().trim_end()),
609 format!(" {}", row2.trim_end()),
610 format!(" {}", row3.iter().collect::<String>().trim_end()),
611 String::new(),
612 ]
613 }
614
615 pub fn to_graph_context(&self) -> Vec<crate::agents::GraphContextNode> {
620 self.all_issues()
621 .into_iter()
622 .filter_map(|num| {
623 let node = self.nodes.get(&num)?;
624 let depends_on: Vec<u32> = self.edges.get(&num).map_or_else(Vec::new, |deps| {
625 let mut v: Vec<u32> = deps.iter().copied().collect();
626 v.sort_unstable();
627 v
628 });
629 Some(crate::agents::GraphContextNode {
630 number: num,
631 title: node.title.clone(),
632 state: node.state,
633 area: node.area.clone(),
634 predicted_files: node.predicted_files.clone(),
635 has_migration: node.has_migration,
636 depends_on,
637 target_repo: node.target_repo.clone(),
638 })
639 })
640 .collect()
641 }
642}
643
644fn build_connector_row(
649 connections: &[(usize, usize)],
650 source_cols: &HashSet<usize>,
651 target_cols: &HashSet<usize>,
652 width: usize,
653) -> String {
654 let mut dirs = vec![0u8; width];
655
656 for &col in source_cols {
657 dirs[col + 2] |= DIR_UP;
658 }
659 for &col in target_cols {
660 dirs[col + 2] |= DIR_DOWN;
661 }
662
663 for &(from_x, to_x) in connections {
664 if from_x == to_x {
665 continue;
666 }
667 let (lo, hi) = if from_x < to_x { (from_x, to_x) } else { (to_x, from_x) };
668 dirs[lo + 2] |= DIR_RIGHT;
669 dirs[hi + 2] |= DIR_LEFT;
670 for col in (lo + 1)..hi {
671 dirs[col + 2] |= DIR_LEFT | DIR_RIGHT;
672 }
673 }
674
675 dirs.iter().map(|&d| box_drawing_char(d)).collect()
676}
677
678const DIR_UP: u8 = 0b1000;
679const DIR_DOWN: u8 = 0b0100;
680const DIR_LEFT: u8 = 0b0010;
681const DIR_RIGHT: u8 = 0b0001;
682
683const fn box_drawing_char(dirs: u8) -> char {
685 match dirs {
686 0b1100 => '\u{2502}', 0b0011 => '\u{2500}', 0b1001 => '\u{2514}', 0b1010 => '\u{2518}', 0b0101 => '\u{250c}', 0b0110 => '\u{2510}', 0b1110 => '\u{2524}', 0b1101 => '\u{251c}', 0b0111 => '\u{252c}', 0b1011 => '\u{2534}', 0b1111 => '\u{253c}', 0b1000 => '\u{2575}', 0b0100 => '\u{2577}', 0b0010 => '\u{2574}', 0b0001 => '\u{2576}', _ => ' ',
702 }
703}
704
705fn terminal_width() -> usize {
706 std::env::var("COLUMNS").ok().and_then(|s| s.parse::<usize>().ok()).unwrap_or(120)
707}
708
709fn lines_with_gap(count: usize) -> Vec<String> {
710 vec![String::new(); count]
711}
712
713fn node_from_planned(node: &PlannedNode, issue: Option<&PipelineIssue>) -> GraphNode {
714 GraphNode {
715 issue_number: node.number,
716 title: node.title.clone(),
717 area: node.area.clone(),
718 predicted_files: node.predicted_files.clone(),
719 has_migration: node.has_migration,
720 complexity: node.complexity.to_string(),
721 state: NodeState::Pending,
722 pr_number: None,
723 run_id: None,
724 target_repo: issue.and_then(|i| i.target_repo.clone()),
725 issue: issue.cloned(),
726 }
727}
728
729fn add_planned_edges(graph: &mut DependencyGraph, nodes: &[impl std::borrow::Borrow<PlannedNode>]) {
730 for node in nodes {
731 let node = node.borrow();
732 for &dep in &node.depends_on {
733 if !graph.add_edge(node.number, dep) {
734 warn!(
735 from = node.number,
736 to = dep,
737 "skipping planner edge that would create cycle"
738 );
739 }
740 }
741 }
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 fn make_node(num: u32) -> GraphNode {
749 GraphNode {
750 issue_number: num,
751 title: format!("Issue #{num}"),
752 area: "test".to_string(),
753 predicted_files: vec![],
754 has_migration: false,
755 complexity: "full".to_string(),
756 state: NodeState::Pending,
757 pr_number: None,
758 run_id: None,
759 issue: None,
760 target_repo: None,
761 }
762 }
763
764 #[test]
765 fn add_node_and_check() {
766 let mut g = DependencyGraph::new("test");
767 g.add_node(make_node(1));
768 assert!(g.contains(1));
769 assert!(!g.contains(2));
770 assert_eq!(g.node_count(), 1);
771 }
772
773 #[test]
774 fn add_edge_and_check() {
775 let mut g = DependencyGraph::new("test");
776 g.add_node(make_node(1));
777 g.add_node(make_node(2));
778 assert!(g.add_edge(2, 1)); assert_eq!(g.dependencies(2), HashSet::from([1]));
781 assert_eq!(g.dependents(1), HashSet::from([2]));
782 }
783
784 #[test]
785 fn self_edge_rejected() {
786 let mut g = DependencyGraph::new("test");
787 g.add_node(make_node(1));
788 assert!(!g.add_edge(1, 1));
789 }
790
791 #[test]
792 fn direct_cycle_detected() {
793 let mut g = DependencyGraph::new("test");
794 g.add_node(make_node(1));
795 g.add_node(make_node(2));
796 assert!(g.add_edge(2, 1)); assert!(!g.add_edge(1, 2)); }
799
800 #[test]
801 fn indirect_cycle_detected() {
802 let mut g = DependencyGraph::new("test");
803 g.add_node(make_node(1));
804 g.add_node(make_node(2));
805 g.add_node(make_node(3));
806 assert!(g.add_edge(2, 1)); assert!(g.add_edge(3, 2)); assert!(!g.add_edge(1, 3)); }
810
811 #[test]
812 fn valid_dag_no_false_cycle() {
813 let mut g = DependencyGraph::new("test");
814 g.add_node(make_node(1));
815 g.add_node(make_node(2));
816 g.add_node(make_node(3));
817 assert!(g.add_edge(2, 1));
818 assert!(g.add_edge(3, 1)); assert!(g.add_edge(3, 2)); }
821
822 #[test]
823 fn ready_issues_returns_pending_with_merged_deps() {
824 let mut g = DependencyGraph::new("test");
825 g.add_node(make_node(1));
826 g.add_node(make_node(2));
827 g.add_edge(2, 1);
828
829 assert_eq!(g.ready_issues(), vec![1]);
831
832 g.transition(1, NodeState::Merged);
834 let ready = g.ready_issues();
835 assert_eq!(ready, vec![2]);
836 }
837
838 #[test]
839 fn ready_issues_empty_when_deps_in_flight() {
840 let mut g = DependencyGraph::new("test");
841 g.add_node(make_node(1));
842 g.add_node(make_node(2));
843 g.add_edge(2, 1);
844 g.transition(1, NodeState::InFlight);
845 assert!(g.ready_issues().is_empty());
846 }
847
848 #[test]
849 fn ready_issues_empty_when_deps_awaiting_merge() {
850 let mut g = DependencyGraph::new("test");
851 g.add_node(make_node(1));
852 g.add_node(make_node(2));
853 g.add_edge(2, 1);
854 g.transition(1, NodeState::AwaitingMerge);
855 assert!(g.ready_issues().is_empty());
856 }
857
858 #[test]
859 fn awaiting_merge_returns_correct_nodes() {
860 let mut g = DependencyGraph::new("test");
861 g.add_node(make_node(1));
862 g.add_node(make_node(2));
863 g.transition(1, NodeState::AwaitingMerge);
864 let awaiting = g.awaiting_merge();
865 assert_eq!(awaiting, vec![1]);
866 }
867
868 #[test]
869 fn all_terminal_checks_all_nodes() {
870 let mut g = DependencyGraph::new("test");
871 g.add_node(make_node(1));
872 g.add_node(make_node(2));
873 assert!(!g.all_terminal());
874
875 g.transition(1, NodeState::Merged);
876 assert!(!g.all_terminal());
877
878 g.transition(2, NodeState::Failed);
879 assert!(g.all_terminal());
880 }
881
882 #[test]
883 fn is_blocked_when_dep_failed() {
884 let mut g = DependencyGraph::new("test");
885 g.add_node(make_node(1));
886 g.add_node(make_node(2));
887 g.add_edge(2, 1);
888 g.transition(1, NodeState::Failed);
889 assert!(g.is_blocked(2));
890 assert!(!g.is_blocked(1));
891 }
892
893 #[test]
894 fn remove_node_cleans_edges() {
895 let mut g = DependencyGraph::new("test");
896 g.add_node(make_node(1));
897 g.add_node(make_node(2));
898 g.add_node(make_node(3));
899 g.add_edge(2, 1);
900 g.add_edge(3, 2);
901
902 g.remove_node(2);
903 assert!(!g.contains(2));
904 assert!(g.dependencies(3).is_empty());
906 assert!(g.dependents(1).is_empty());
908 }
909
910 #[test]
911 fn db_roundtrip() {
912 let conn = crate::db::open_in_memory().unwrap();
913 let mut g = DependencyGraph::new("test-session");
914 g.add_node(make_node(1));
915 g.add_node(make_node(2));
916 g.add_node(make_node(3));
917 g.add_edge(2, 1);
918 g.add_edge(3, 1);
919 g.add_edge(3, 2);
920 g.transition(1, NodeState::Merged);
921 g.set_pr_number(1, 99);
922 g.set_run_id(1, "abc");
923
924 g.save_to_db(&conn).unwrap();
925
926 let loaded = DependencyGraph::from_db(&conn, "test-session").unwrap();
927 assert_eq!(loaded.node_count(), 3);
928 assert_eq!(loaded.dependencies(2), HashSet::from([1]));
929 assert_eq!(loaded.dependencies(3), HashSet::from([1, 2]));
930 assert_eq!(loaded.node(1).unwrap().state, NodeState::Merged);
931 assert_eq!(loaded.node(1).unwrap().pr_number, Some(99));
932 assert_eq!(loaded.node(1).unwrap().run_id.as_deref(), Some("abc"));
933 }
934
935 #[test]
936 fn diamond_graph_ready_ordering() {
937 let mut g = DependencyGraph::new("test");
939 g.add_node(make_node(1)); g.add_node(make_node(2)); g.add_node(make_node(3)); g.add_node(make_node(4)); g.add_edge(2, 1); g.add_edge(3, 1); g.add_edge(4, 2); g.add_edge(4, 3); assert_eq!(g.ready_issues(), vec![1]);
951
952 g.transition(1, NodeState::Merged);
954 let mut ready = g.ready_issues();
955 ready.sort_unstable();
956 assert_eq!(ready, vec![2, 3]);
957
958 g.transition(2, NodeState::Merged);
960 assert_eq!(g.ready_issues(), vec![3]);
961
962 g.transition(3, NodeState::Merged);
964 assert_eq!(g.ready_issues(), vec![4]);
965 }
966
967 #[test]
968 fn empty_graph_is_all_terminal() {
969 let g = DependencyGraph::new("test");
970 assert!(g.all_terminal());
971 }
972
973 #[test]
974 fn independent_nodes_all_ready() {
975 let mut g = DependencyGraph::new("test");
976 g.add_node(make_node(1));
977 g.add_node(make_node(2));
978 g.add_node(make_node(3));
979
980 let mut ready = g.ready_issues();
981 ready.sort_unstable();
982 assert_eq!(ready, vec![1, 2, 3]);
983 }
984
985 fn make_planned(number: u32, depends_on: Vec<u32>) -> crate::agents::PlannedNode {
986 crate::agents::PlannedNode {
987 number,
988 title: format!("Issue #{number}"),
989 area: "test".to_string(),
990 predicted_files: vec![],
991 has_migration: false,
992 complexity: crate::agents::Complexity::Full,
993 depends_on,
994 reasoning: String::new(),
995 }
996 }
997
998 fn make_issue(number: u32) -> PipelineIssue {
999 PipelineIssue {
1000 number,
1001 title: format!("Issue #{number}"),
1002 body: String::new(),
1003 source: crate::issues::IssueOrigin::Github,
1004 target_repo: None,
1005 author: None,
1006 }
1007 }
1008
1009 #[test]
1010 fn from_planner_output_basic() {
1011 let plan = crate::agents::PlannerGraphOutput {
1012 nodes: vec![
1013 make_planned(1, vec![]),
1014 make_planned(2, vec![]),
1015 make_planned(3, vec![1, 2]),
1016 ],
1017 total_issues: 3,
1018 parallel_capacity: 2,
1019 };
1020 let issues = vec![make_issue(1), make_issue(2), make_issue(3)];
1021
1022 let g = DependencyGraph::from_planner_output("sess", &plan, &issues);
1023 assert_eq!(g.node_count(), 3);
1024 assert_eq!(g.dependencies(3), HashSet::from([1, 2]));
1025 assert!(g.dependencies(1).is_empty());
1026 assert!(g.node(1).unwrap().issue.is_some());
1028 assert!(g.node(2).unwrap().issue.is_some());
1029 }
1030
1031 #[test]
1032 fn from_planner_output_skips_cycle() {
1033 let plan = crate::agents::PlannerGraphOutput {
1034 nodes: vec![make_planned(1, vec![2]), make_planned(2, vec![1])],
1035 total_issues: 2,
1036 parallel_capacity: 1,
1037 };
1038
1039 let g = DependencyGraph::from_planner_output("sess", &plan, &[]);
1040 assert_eq!(g.node_count(), 2);
1042 let total_edges: usize = [1, 2].iter().map(|n| g.dependencies(*n).len()).sum();
1043 assert_eq!(total_edges, 1);
1044 }
1045
1046 #[test]
1047 fn merge_planner_output_adds_new_only() {
1048 let plan1 = crate::agents::PlannerGraphOutput {
1049 nodes: vec![make_planned(1, vec![])],
1050 total_issues: 1,
1051 parallel_capacity: 1,
1052 };
1053 let mut g = DependencyGraph::from_planner_output("sess", &plan1, &[make_issue(1)]);
1054 g.transition(1, NodeState::InFlight);
1055
1056 let plan2 = crate::agents::PlannerGraphOutput {
1058 nodes: vec![make_planned(1, vec![]), make_planned(2, vec![1])],
1059 total_issues: 2,
1060 parallel_capacity: 1,
1061 };
1062 g.merge_planner_output(&plan2, &[make_issue(2)]);
1063
1064 assert_eq!(g.node_count(), 2);
1065 assert_eq!(g.node(1).unwrap().state, NodeState::InFlight);
1067 assert_eq!(g.node(2).unwrap().state, NodeState::Pending);
1069 assert_eq!(g.dependencies(2), HashSet::from([1]));
1070 }
1071
1072 #[test]
1073 fn merge_planner_output_cross_edges() {
1074 let mut g = DependencyGraph::new("sess");
1075 g.add_node(make_node(1));
1076 g.transition(1, NodeState::Merged);
1077
1078 let plan = crate::agents::PlannerGraphOutput {
1079 nodes: vec![make_planned(2, vec![1])],
1080 total_issues: 1,
1081 parallel_capacity: 1,
1082 };
1083 g.merge_planner_output(&plan, &[make_issue(2)]);
1084
1085 assert_eq!(g.dependencies(2), HashSet::from([1]));
1086 assert_eq!(g.ready_issues(), vec![2]);
1088 }
1089
1090 #[test]
1091 fn propagate_failure_linear_chain() {
1092 let mut g = DependencyGraph::new("test");
1093 g.add_node(make_node(1));
1094 g.add_node(make_node(2));
1095 g.add_node(make_node(3));
1096 g.add_edge(2, 1);
1097 g.add_edge(3, 2);
1098
1099 g.transition(1, NodeState::Failed);
1100 let mut failed = g.propagate_failure(1);
1101 failed.sort_unstable();
1102 assert_eq!(failed, vec![2, 3]);
1103 assert_eq!(g.node(2).unwrap().state, NodeState::Failed);
1104 assert_eq!(g.node(3).unwrap().state, NodeState::Failed);
1105 }
1106
1107 #[test]
1108 fn propagate_failure_diamond() {
1109 let mut g = DependencyGraph::new("test");
1111 for i in 1..=4 {
1112 g.add_node(make_node(i));
1113 }
1114 g.add_edge(2, 1);
1115 g.add_edge(3, 1);
1116 g.add_edge(4, 2);
1117 g.add_edge(4, 3);
1118
1119 g.transition(1, NodeState::Failed);
1120 let mut failed = g.propagate_failure(1);
1121 failed.sort_unstable();
1122 assert_eq!(failed, vec![2, 3, 4]);
1123 }
1124
1125 #[test]
1126 fn propagate_failure_partial_branch() {
1127 let mut g = DependencyGraph::new("test");
1129 for i in 1..=4 {
1130 g.add_node(make_node(i));
1131 }
1132 g.add_edge(3, 1);
1133 g.add_edge(4, 2);
1134
1135 g.transition(1, NodeState::Failed);
1136 let failed = g.propagate_failure(1);
1137 assert_eq!(failed, vec![3]);
1138 assert_eq!(g.node(4).unwrap().state, NodeState::Pending);
1140 }
1141
1142 #[test]
1143 fn propagate_failure_skips_merged() {
1144 let mut g = DependencyGraph::new("test");
1145 g.add_node(make_node(1));
1146 g.add_node(make_node(2));
1147 g.add_node(make_node(3));
1148 g.add_edge(2, 1);
1149 g.add_edge(3, 2);
1150 g.transition(2, NodeState::Merged);
1152
1153 g.transition(1, NodeState::Failed);
1154 let failed = g.propagate_failure(1);
1155 assert!(failed.is_empty());
1157 assert_eq!(g.node(2).unwrap().state, NodeState::Merged);
1158 assert_eq!(g.node(3).unwrap().state, NodeState::Pending);
1159 }
1160
1161 #[test]
1162 fn propagate_failure_returns_newly_failed() {
1163 let mut g = DependencyGraph::new("test");
1164 g.add_node(make_node(1));
1165 g.add_node(make_node(2));
1166 g.add_node(make_node(3));
1167 g.add_edge(2, 1);
1168 g.add_edge(3, 1);
1169
1170 g.transition(1, NodeState::Failed);
1171 let mut failed = g.propagate_failure(1);
1172 failed.sort_unstable();
1173 assert_eq!(failed, vec![2, 3]);
1174 let failed2 = g.propagate_failure(1);
1176 assert!(failed2.is_empty());
1177 }
1178
1179 #[test]
1180 fn to_graph_context_includes_all_nodes() {
1181 let mut g = DependencyGraph::new("test");
1182 g.add_node(make_node(1));
1183 g.add_node(make_node(2));
1184 g.add_node(make_node(3));
1185 g.add_edge(2, 1);
1186 g.add_edge(3, 1);
1187 g.add_edge(3, 2);
1188 g.transition(1, NodeState::InFlight);
1189
1190 let ctx = g.to_graph_context();
1191 assert_eq!(ctx.len(), 3);
1192
1193 let ctx_map: HashMap<u32, &crate::agents::GraphContextNode> =
1194 ctx.iter().map(|c| (c.number, c)).collect();
1195
1196 let c1 = ctx_map[&1];
1197 assert_eq!(c1.state, NodeState::InFlight);
1198 assert!(c1.depends_on.is_empty());
1199
1200 let c2 = ctx_map[&2];
1201 assert_eq!(c2.state, NodeState::Pending);
1202 assert_eq!(c2.depends_on, vec![1]);
1203
1204 let c3 = ctx_map[&3];
1205 assert_eq!(c3.state, NodeState::Pending);
1206 assert_eq!(c3.depends_on, vec![1, 2]);
1207 }
1208
1209 #[test]
1210 fn save_to_db_is_atomic_on_success() {
1211 let conn = crate::db::open_in_memory().unwrap();
1212 let mut g = DependencyGraph::new("atomic-test");
1213 g.add_node(make_node(1));
1214 g.add_node(make_node(2));
1215 g.add_edge(2, 1);
1216
1217 g.save_to_db(&conn).unwrap();
1218
1219 let mut g2 = DependencyGraph::new("atomic-test");
1221 g2.add_node(make_node(10));
1222 g2.save_to_db(&conn).unwrap();
1223
1224 let loaded = DependencyGraph::from_db(&conn, "atomic-test").unwrap();
1225 assert_eq!(loaded.node_count(), 1);
1227 assert!(loaded.contains(10));
1228 assert!(!loaded.contains(1));
1229 assert!(!loaded.contains(2));
1230 }
1231
1232 fn make_named_node(num: u32, title: &str, area: &str) -> GraphNode {
1233 GraphNode {
1234 issue_number: num,
1235 title: title.to_string(),
1236 area: area.to_string(),
1237 predicted_files: vec![],
1238 has_migration: false,
1239 complexity: "full".to_string(),
1240 state: NodeState::Pending,
1241 pr_number: None,
1242 run_id: None,
1243 issue: None,
1244 target_repo: None,
1245 }
1246 }
1247
1248 #[test]
1251 fn display_layered_empty_graph() {
1252 let g = DependencyGraph::new("test");
1253 let lines = g.display_layered();
1254 assert_eq!(lines.len(), 1);
1255 assert!(lines[0].contains("empty graph"));
1256 }
1257
1258 #[test]
1259 fn display_layered_single_node() {
1260 let mut g = DependencyGraph::new("test");
1261 g.add_node(make_named_node(1, "Add auth", "backend"));
1262 let lines = g.display_layered();
1263 let text = lines.join("\n");
1264 assert!(text.contains("1 issues"));
1265 assert!(text.contains("Layer 0"));
1266 assert!(text.contains("#1"));
1267 assert!(text.contains("Add auth"));
1268 assert!(text.contains("Legend"));
1269 }
1270
1271 #[test]
1272 fn display_layered_linear_chain() {
1273 let mut g = DependencyGraph::new("test");
1274 g.add_node(make_named_node(1, "Database schema", "db"));
1275 g.add_node(make_named_node(2, "API endpoints", "backend"));
1276 g.add_node(make_named_node(3, "Frontend views", "frontend"));
1277 g.add_edge(2, 1);
1278 g.add_edge(3, 2);
1279
1280 let lines = g.display_layered();
1281 let text = lines.join("\n");
1282 assert!(text.contains("Layer 0"));
1284 assert!(text.contains("Layer 1"));
1285 assert!(text.contains("Layer 2"));
1286 assert!(text.contains("#1"));
1288 assert!(text.contains("#2"));
1289 assert!(text.contains("#3"));
1290 }
1291
1292 #[test]
1293 fn display_layered_diamond_dag() {
1294 let mut g = DependencyGraph::new("test");
1296 g.add_node(make_named_node(1, "Core lib", "core"));
1297 g.add_node(make_named_node(2, "Auth module", "auth"));
1298 g.add_node(make_named_node(3, "Logging module", "infra"));
1299 g.add_node(make_named_node(4, "Integration", "all"));
1300 g.add_edge(2, 1);
1301 g.add_edge(3, 1);
1302 g.add_edge(4, 2);
1303 g.add_edge(4, 3);
1304
1305 let lines = g.display_layered();
1306 let text = lines.join("\n");
1307 assert!(text.contains("Layer 0"));
1308 assert!(text.contains("Layer 1"));
1309 assert!(text.contains("Layer 2"));
1310 assert!(text.contains("4 issues"));
1311 assert!(text.contains("#2"));
1313 assert!(text.contains("#3"));
1314 }
1315
1316 #[test]
1317 fn display_layered_independent_nodes() {
1318 let mut g = DependencyGraph::new("test");
1319 g.add_node(make_named_node(1, "Fix typo", "docs"));
1320 g.add_node(make_named_node(2, "Add lint", "ci"));
1321 g.add_node(make_named_node(3, "Bump deps", "deps"));
1322
1323 let lines = g.display_layered();
1324 let text = lines.join("\n");
1325 assert!(text.contains("Layer 0 (no deps)"));
1327 assert!(!text.contains("Layer 1"));
1328 assert!(text.contains("#1"));
1330 assert!(text.contains("#2"));
1331 assert!(text.contains("#3"));
1332 }
1333
1334 #[test]
1335 fn display_layered_shows_state_indicators() {
1336 let mut g = DependencyGraph::new("test");
1337 g.add_node(make_named_node(1, "Done thing", "core"));
1338 g.add_node(make_named_node(2, "Running thing", "core"));
1339 g.add_node(make_named_node(3, "Broken thing", "core"));
1340 g.add_node(make_named_node(4, "Waiting thing", "core"));
1341 g.add_node(make_named_node(5, "Pending thing", "core"));
1342 g.transition(1, NodeState::Merged);
1343 g.transition(2, NodeState::InFlight);
1344 g.transition(3, NodeState::Failed);
1345 g.transition(4, NodeState::AwaitingMerge);
1346
1347 let lines = g.display_layered();
1348 let text = lines.join("\n");
1349 assert!(text.contains("[*]")); assert!(text.contains("[~]")); assert!(text.contains("[!]")); assert!(text.contains("[?]")); assert!(text.contains("[ ]")); }
1355
1356 #[test]
1357 fn display_layered_shows_pr_number() {
1358 let mut g = DependencyGraph::new("test");
1359 g.add_node(make_named_node(1, "Has PR", "core"));
1360 g.set_pr_number(1, 42);
1361
1362 let lines = g.display_layered();
1363 let text = lines.join("\n");
1364 assert!(text.contains("PR #42"));
1365 }
1366
1367 #[test]
1368 fn display_layered_summary_counts() {
1369 let mut g = DependencyGraph::new("test");
1370 g.add_node(make_named_node(1, "A", "core"));
1371 g.add_node(make_named_node(2, "B", "core"));
1372 g.add_node(make_named_node(3, "C", "core"));
1373 g.transition(1, NodeState::Merged);
1374 g.transition(2, NodeState::InFlight);
1375 g.transition(3, NodeState::Failed);
1376
1377 let lines = g.display_layered();
1378 let text = lines.join("\n");
1379 assert!(text.contains("3 issues"));
1380 assert!(text.contains("1 merged"));
1381 assert!(text.contains("1 in flight"));
1382 assert!(text.contains("1 failed"));
1383 }
1384
1385 #[test]
1386 fn display_layered_shows_blocked() {
1387 let mut g = DependencyGraph::new("test");
1388 g.add_node(make_named_node(1, "Root", "core"));
1389 g.add_node(make_named_node(2, "Blocked child", "core"));
1390 g.add_edge(2, 1);
1391 g.transition(1, NodeState::Failed);
1392
1393 let lines = g.display_layered();
1394 let text = lines.join("\n");
1395 assert!(text.contains("BLOCKED"));
1396 }
1397
1398 #[test]
1399 fn display_layered_has_connectors_between_layers() {
1400 let mut g = DependencyGraph::new("test");
1401 g.add_node(make_named_node(1, "Root", "core"));
1402 g.add_node(make_named_node(2, "Child", "core"));
1403 g.add_edge(2, 1);
1404
1405 let lines = g.display_layered();
1406 let text = lines.join("\n");
1407 assert!(text.contains('\u{25bc}')); }
1410
1411 #[test]
1412 fn display_layered_box_drawing_chars() {
1413 let mut g = DependencyGraph::new("test");
1414 g.add_node(make_named_node(1, "Test node", "core"));
1415
1416 let lines = g.display_layered();
1417 let text = lines.join("\n");
1418 assert!(text.contains('\u{250c}')); assert!(text.contains('\u{2510}')); assert!(text.contains('\u{2514}')); assert!(text.contains('\u{2518}')); assert!(text.contains('\u{2500}')); assert!(text.contains('\u{2502}')); }
1426
1427 #[test]
1428 fn compute_layers_linear() {
1429 let mut g = DependencyGraph::new("test");
1430 g.add_node(make_node(1));
1431 g.add_node(make_node(2));
1432 g.add_node(make_node(3));
1433 g.add_edge(2, 1);
1434 g.add_edge(3, 2);
1435
1436 let layers = g.compute_layers();
1437 assert_eq!(layers.len(), 3);
1438 assert_eq!(layers[0], vec![1]);
1439 assert_eq!(layers[1], vec![2]);
1440 assert_eq!(layers[2], vec![3]);
1441 }
1442
1443 #[test]
1444 fn compute_layers_diamond() {
1445 let mut g = DependencyGraph::new("test");
1446 g.add_node(make_node(1));
1447 g.add_node(make_node(2));
1448 g.add_node(make_node(3));
1449 g.add_node(make_node(4));
1450 g.add_edge(2, 1);
1451 g.add_edge(3, 1);
1452 g.add_edge(4, 2);
1453 g.add_edge(4, 3);
1454
1455 let layers = g.compute_layers();
1456 assert_eq!(layers.len(), 3);
1457 assert_eq!(layers[0], vec![1]);
1458 assert_eq!(layers[1], vec![2, 3]);
1459 assert_eq!(layers[2], vec![4]);
1460 }
1461
1462 #[test]
1463 fn compute_layers_independent() {
1464 let mut g = DependencyGraph::new("test");
1465 g.add_node(make_node(1));
1466 g.add_node(make_node(2));
1467 g.add_node(make_node(3));
1468
1469 let layers = g.compute_layers();
1470 assert_eq!(layers.len(), 1);
1471 assert_eq!(layers[0], vec![1, 2, 3]);
1472 }
1473
1474 #[test]
1475 fn display_layered_wraps_wide_layers() {
1476 let mut g = DependencyGraph::new("test");
1479 for i in 1..=12 {
1480 g.add_node(make_named_node(i, &format!("Task {i}"), "area"));
1481 }
1482
1483 let lines = g.display_layered();
1484 let text = lines.join("\n");
1485
1486 for i in 1..=12 {
1488 assert!(text.contains(&format!("#{i}")), "missing #{i}");
1489 }
1490
1491 let term_w = super::terminal_width();
1494 let max_line = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
1495 assert!(max_line <= term_w, "line too wide: {max_line} chars (expected <= {term_w})");
1496 }
1497
1498 #[test]
1499 fn display_layered_wrapping_preserves_connectors() {
1500 let mut g = DependencyGraph::new("test");
1503 for i in 1..=6 {
1504 g.add_node(make_named_node(i, &format!("Task {i}"), "area"));
1505 }
1506 g.add_node(make_named_node(7, "Dependent", "area"));
1507 g.add_edge(7, 1);
1508
1509 let lines = g.display_layered();
1510 let text = lines.join("\n");
1511
1512 assert!(text.contains('\u{25bc}'), "missing connector arrow");
1514 assert!(text.contains("Layer 0"));
1516 assert!(text.contains("Layer 1"));
1517 }
1518}