Skip to main content

oven_cli/pipeline/
graph.rs

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/// A node in the dependency graph, holding all metadata for scheduling.
14#[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    /// Target repo name for multi-repo routing. Persisted separately from `issue`
27    /// so it survives DB round-trips (where `issue` is `None`).
28    pub target_repo: Option<String>,
29}
30
31/// Directed acyclic graph tracking issue dependencies.
32///
33/// Edges point from dependent to dependency: if A depends on B, `edges[A]`
34/// contains B. The graph enforces acyclicity on insertion.
35pub struct DependencyGraph {
36    session_id: String,
37    nodes: HashMap<u32, GraphNode>,
38    /// Forward edges: `edges[a]` = set of issues that `a` depends on.
39    edges: HashMap<u32, HashSet<u32>>,
40    /// Reverse edges: `reverse_edges[b]` = set of issues that depend on `b`.
41    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    /// Add a dependency edge: `from` depends on `to`.
78    ///
79    /// Returns `false` if the edge would create a cycle (edge not added).
80    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    /// Check if adding an edge from -> to would create a cycle.
90    ///
91    /// A cycle exists if `to` transitively depends on `from`.
92    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(&current) {
101                    for &dep in deps {
102                        if !visited.contains(&dep) {
103                            stack.push(dep);
104                        }
105                    }
106                }
107            }
108        }
109        false
110    }
111
112    /// Issues in `Pending` state whose dependencies are all `Merged`.
113    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    /// Issues currently in `AwaitingMerge` state.
128    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    /// Transition a node to a new state.
137    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    /// Set the PR number on a node.
150    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    /// Set the run ID on a node.
157    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    /// Get the set of issues that `issue` depends on.
164    pub fn dependencies(&self, issue: u32) -> HashSet<u32> {
165        self.edges.get(&issue).cloned().unwrap_or_default()
166    }
167
168    /// Get the set of issues that depend on `issue`.
169    pub fn dependents(&self, issue: u32) -> HashSet<u32> {
170        self.reverse_edges.get(&issue).cloned().unwrap_or_default()
171    }
172
173    /// Whether every node is in a terminal state (`Merged` or `Failed`).
174    pub fn all_terminal(&self) -> bool {
175        self.nodes.values().all(|n| matches!(n.state, NodeState::Merged | NodeState::Failed))
176    }
177
178    /// Whether a node is blocked because at least one dependency has failed.
179    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    /// Propagate failure from a node to all transitive dependents.
186    ///
187    /// Any `Pending` or `InFlight` node reachable via `reverse_edges` from the
188    /// failed node is transitioned to `Failed`. Returns the list of issue
189    /// numbers that were newly failed (excludes the original node).
190    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        // Seed with direct dependents of the failed node
197        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(&current)
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(&current) {
218                queue.extend(dependents.iter().copied());
219            }
220        }
221
222        newly_failed
223    }
224
225    /// Remove a node and all its edges (for stale issue cleanup).
226    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    /// All issue numbers in the graph.
245    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    /// Load graph state from the database.
252    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    /// Persist the full graph state to the database, replacing any existing data for
282    /// this session. Runs inside a transaction so a crash mid-save cannot leave a
283    /// partial graph.
284    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    /// Build a graph from planner output, matching issues by number.
315    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    /// Merge new planner output into an existing graph (polling mode).
331    ///
332    /// Only adds nodes not already present. Edges between new nodes and
333    /// existing nodes are added if they don't create cycles.
334    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    /// Compute topological layers: layer 0 has no deps, layer N has all deps in layers < N.
346    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    /// Render a layered box-drawing DAG visualization for terminal output.
382    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        // Summary line
391        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
411        for (layer_idx, layer) in layers.iter().enumerate() {
412            // Layer header
413            let label = if layer_idx == 0 { "no deps" } else { "" };
414            if label.is_empty() {
415                lines.push(format!("  Layer {layer_idx}"));
416            } else {
417                lines.push(format!("  Layer {layer_idx} ({label})"));
418            }
419            lines.push(String::new());
420
421            // Render boxes for this layer
422            let boxes: Vec<Vec<String>> =
423                layer.iter().map(|&num| self.render_box(num, box_width)).collect();
424
425            // Merge box lines side by side with spacing
426            let max_height = boxes.iter().map(Vec::len).max().unwrap_or(0);
427            for row in 0..max_height {
428                let mut line = String::from("  ");
429                for (i, b) in boxes.iter().enumerate() {
430                    if i > 0 {
431                        line.push_str("  ");
432                    }
433                    if row < b.len() {
434                        line.push_str(&b[row]);
435                    } else {
436                        // Pad with spaces to match box width
437                        line.push_str(&" ".repeat(box_width + 2));
438                    }
439                }
440                lines.push(line);
441            }
442
443            // Draw connectors to next layer if there is one
444            if layer_idx + 1 < layers.len() {
445                let next_layer = &layers[layer_idx + 1];
446                let connector_lines = self.render_connectors(layer, next_layer, box_width);
447                lines.extend(connector_lines);
448            }
449        }
450
451        // Legend
452        lines.push(String::new());
453        lines.push(
454            "  Legend: [*] merged  [~] in flight  [ ] pending  [?] awaiting merge  [!] failed"
455                .to_string(),
456        );
457
458        lines
459    }
460
461    /// Compute a consistent box width based on the longest content.
462    fn compute_box_width(&self) -> usize {
463        let min_width = 30;
464        let max_width = 44;
465        let longest = self
466            .nodes
467            .values()
468            .map(|n| {
469                let issue_label = format!("#{}", n.issue_number);
470                // State indicator (3) + space + issue label + 2 spaces + title
471                3 + 1 + issue_label.len() + 2 + n.title.len()
472            })
473            .max()
474            .unwrap_or(min_width);
475        longest.clamp(min_width, max_width)
476    }
477
478    /// Render a single node as a Unicode box.
479    fn render_box(&self, issue: u32, width: usize) -> Vec<String> {
480        let Some(node) = self.nodes.get(&issue) else {
481            return vec![];
482        };
483
484        let state_char = match node.state {
485            NodeState::Merged => '*',
486            NodeState::InFlight => '~',
487            NodeState::Pending => ' ',
488            NodeState::AwaitingMerge => '?',
489            NodeState::Failed => '!',
490        };
491
492        let blocked = if self.is_blocked(issue) { " BLOCKED" } else { "" };
493
494        let issue_label = format!("#{issue}");
495        let title_line = format!("[{state_char}] {issue_label}  {}", node.title);
496        let title_truncated = if title_line.len() > width {
497            format!("{}..", &title_line[..width - 2])
498        } else {
499            title_line
500        };
501
502        // Second line: area + PR + blocked
503        let mut detail_parts = vec![node.area.clone()];
504        if let Some(pr) = node.pr_number {
505            detail_parts.push(format!("PR #{pr}"));
506        }
507        let mut detail_line = detail_parts.join("  ");
508        if !blocked.is_empty() {
509            detail_line.push_str(blocked);
510        }
511        let detail_truncated = if detail_line.len() > width {
512            format!("{}..", &detail_line[..width - 2])
513        } else {
514            detail_line
515        };
516
517        let top = format!("\u{250c}{}\u{2510}", "\u{2500}".repeat(width));
518        let mid = format!("\u{2502}{title_truncated:<width$}\u{2502}");
519        let mid2 = format!("\u{2502}{detail_truncated:<width$}\u{2502}");
520        let bot = format!("\u{2514}{}\u{2518}", "\u{2500}".repeat(width));
521
522        vec![top, mid, mid2, bot]
523    }
524
525    /// Render connector lines between two adjacent layers.
526    fn render_connectors(
527        &self,
528        from_layer: &[u32],
529        to_layer: &[u32],
530        box_width: usize,
531    ) -> Vec<String> {
532        // For each node in to_layer, find which nodes in from_layer it depends on.
533        // We draw simple vertical/horizontal pipe connectors.
534        let full_box_width = box_width + 2; // include border chars
535        let spacing = 2usize;
536
537        // Compute center x position for each node in from_layer and to_layer
538        let from_centers: Vec<(u32, usize)> = from_layer
539            .iter()
540            .enumerate()
541            .map(|(i, &num)| {
542                let x = i * (full_box_width + spacing) + full_box_width / 2;
543                (num, x)
544            })
545            .collect();
546        let to_centers: Vec<(u32, usize)> = to_layer
547            .iter()
548            .enumerate()
549            .map(|(i, &num)| {
550                let x = i * (full_box_width + spacing) + full_box_width / 2;
551                (num, x)
552            })
553            .collect();
554
555        // Collect all edges between these two layers
556        let mut connections: Vec<(usize, usize)> = Vec::new(); // (from_x, to_x)
557        for &(to_num, to_x) in &to_centers {
558            let deps = self.dependencies(to_num);
559            for &(from_num, from_x) in &from_centers {
560                if deps.contains(&from_num) {
561                    connections.push((from_x, to_x));
562                }
563            }
564        }
565
566        if connections.is_empty() {
567            return lines_with_gap(1);
568        }
569
570        let max_x =
571            from_centers.iter().chain(to_centers.iter()).map(|(_, x)| *x).max().unwrap_or(0)
572                + full_box_width;
573        let width = max_x + 4;
574
575        // Track which columns are sources and targets
576        let mut source_cols: HashSet<usize> = HashSet::new();
577        let mut target_cols: HashSet<usize> = HashSet::new();
578        for &(from_x, to_x) in &connections {
579            source_cols.insert(from_x);
580            target_cols.insert(to_x);
581        }
582
583        // Row 1: vertical pipes from source boxes
584        let mut row1 = vec![' '; width];
585        for &col in &source_cols {
586            row1[col + 2] = '\u{2502}'; // │
587        }
588
589        // Row 2: build direction flags per column, then convert to box-drawing
590        let row2 = build_connector_row(&connections, &source_cols, &target_cols, width);
591
592        // Row 3: arrows into target boxes
593        let mut row3 = vec![' '; width];
594        for &col in &target_cols {
595            row3[col + 2] = '\u{25bc}'; // ▼
596        }
597
598        vec![
599            format!("  {}", row1.iter().collect::<String>().trim_end()),
600            format!("  {}", row2.trim_end()),
601            format!("  {}", row3.iter().collect::<String>().trim_end()),
602            String::new(),
603        ]
604    }
605
606    /// Build planner context from the current graph state.
607    ///
608    /// Produces one `GraphContextNode` per node so the planner can see
609    /// in-flight work and avoid scheduling conflicts.
610    pub fn to_graph_context(&self) -> Vec<crate::agents::GraphContextNode> {
611        self.all_issues()
612            .into_iter()
613            .filter_map(|num| {
614                let node = self.nodes.get(&num)?;
615                let depends_on: Vec<u32> = self.edges.get(&num).map_or_else(Vec::new, |deps| {
616                    let mut v: Vec<u32> = deps.iter().copied().collect();
617                    v.sort_unstable();
618                    v
619                });
620                Some(crate::agents::GraphContextNode {
621                    number: num,
622                    title: node.title.clone(),
623                    state: node.state,
624                    area: node.area.clone(),
625                    predicted_files: node.predicted_files.clone(),
626                    has_migration: node.has_migration,
627                    depends_on,
628                    target_repo: node.target_repo.clone(),
629                })
630            })
631            .collect()
632    }
633}
634
635/// Build the horizontal connector row between two layers.
636///
637/// For each column, compute which directions (up/down/left/right) have connections,
638/// then pick the appropriate Unicode box-drawing character.
639fn build_connector_row(
640    connections: &[(usize, usize)],
641    source_cols: &HashSet<usize>,
642    target_cols: &HashSet<usize>,
643    width: usize,
644) -> String {
645    let mut dirs = vec![0u8; width];
646
647    for &col in source_cols {
648        dirs[col + 2] |= DIR_UP;
649    }
650    for &col in target_cols {
651        dirs[col + 2] |= DIR_DOWN;
652    }
653
654    for &(from_x, to_x) in connections {
655        if from_x == to_x {
656            continue;
657        }
658        let (lo, hi) = if from_x < to_x { (from_x, to_x) } else { (to_x, from_x) };
659        dirs[lo + 2] |= DIR_RIGHT;
660        dirs[hi + 2] |= DIR_LEFT;
661        for col in (lo + 1)..hi {
662            dirs[col + 2] |= DIR_LEFT | DIR_RIGHT;
663        }
664    }
665
666    dirs.iter().map(|&d| box_drawing_char(d)).collect()
667}
668
669const DIR_UP: u8 = 0b1000;
670const DIR_DOWN: u8 = 0b0100;
671const DIR_LEFT: u8 = 0b0010;
672const DIR_RIGHT: u8 = 0b0001;
673
674/// Map a direction bitmask to the appropriate Unicode box-drawing character.
675const fn box_drawing_char(dirs: u8) -> char {
676    match dirs {
677        0b1100 => '\u{2502}', // │  up+down
678        0b0011 => '\u{2500}', // ─  left+right
679        0b1001 => '\u{2514}', // └  up+right
680        0b1010 => '\u{2518}', // ┘  up+left
681        0b0101 => '\u{250c}', // ┌  down+right
682        0b0110 => '\u{2510}', // ┐  down+left
683        0b1110 => '\u{2524}', // ┤  up+down+left
684        0b1101 => '\u{251c}', // ├  up+down+right
685        0b0111 => '\u{252c}', // ┬  down+left+right
686        0b1011 => '\u{2534}', // ┴  up+left+right
687        0b1111 => '\u{253c}', // ┼  all
688        0b1000 => '\u{2575}', // ╵  up only
689        0b0100 => '\u{2577}', // ╷  down only
690        0b0010 => '\u{2574}', // ╴  left only
691        0b0001 => '\u{2576}', // ╶  right only
692        _ => ' ',
693    }
694}
695
696fn lines_with_gap(count: usize) -> Vec<String> {
697    vec![String::new(); count]
698}
699
700fn node_from_planned(node: &PlannedNode, issue: Option<&PipelineIssue>) -> GraphNode {
701    GraphNode {
702        issue_number: node.number,
703        title: node.title.clone(),
704        area: node.area.clone(),
705        predicted_files: node.predicted_files.clone(),
706        has_migration: node.has_migration,
707        complexity: node.complexity.to_string(),
708        state: NodeState::Pending,
709        pr_number: None,
710        run_id: None,
711        target_repo: issue.and_then(|i| i.target_repo.clone()),
712        issue: issue.cloned(),
713    }
714}
715
716fn add_planned_edges(graph: &mut DependencyGraph, nodes: &[impl std::borrow::Borrow<PlannedNode>]) {
717    for node in nodes {
718        let node = node.borrow();
719        for &dep in &node.depends_on {
720            if !graph.add_edge(node.number, dep) {
721                warn!(
722                    from = node.number,
723                    to = dep,
724                    "skipping planner edge that would create cycle"
725                );
726            }
727        }
728    }
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734
735    fn make_node(num: u32) -> GraphNode {
736        GraphNode {
737            issue_number: num,
738            title: format!("Issue #{num}"),
739            area: "test".to_string(),
740            predicted_files: vec![],
741            has_migration: false,
742            complexity: "full".to_string(),
743            state: NodeState::Pending,
744            pr_number: None,
745            run_id: None,
746            issue: None,
747            target_repo: None,
748        }
749    }
750
751    #[test]
752    fn add_node_and_check() {
753        let mut g = DependencyGraph::new("test");
754        g.add_node(make_node(1));
755        assert!(g.contains(1));
756        assert!(!g.contains(2));
757        assert_eq!(g.node_count(), 1);
758    }
759
760    #[test]
761    fn add_edge_and_check() {
762        let mut g = DependencyGraph::new("test");
763        g.add_node(make_node(1));
764        g.add_node(make_node(2));
765        assert!(g.add_edge(2, 1)); // 2 depends on 1
766
767        assert_eq!(g.dependencies(2), HashSet::from([1]));
768        assert_eq!(g.dependents(1), HashSet::from([2]));
769    }
770
771    #[test]
772    fn self_edge_rejected() {
773        let mut g = DependencyGraph::new("test");
774        g.add_node(make_node(1));
775        assert!(!g.add_edge(1, 1));
776    }
777
778    #[test]
779    fn direct_cycle_detected() {
780        let mut g = DependencyGraph::new("test");
781        g.add_node(make_node(1));
782        g.add_node(make_node(2));
783        assert!(g.add_edge(2, 1)); // 2 depends on 1
784        assert!(!g.add_edge(1, 2)); // would create cycle
785    }
786
787    #[test]
788    fn indirect_cycle_detected() {
789        let mut g = DependencyGraph::new("test");
790        g.add_node(make_node(1));
791        g.add_node(make_node(2));
792        g.add_node(make_node(3));
793        assert!(g.add_edge(2, 1)); // 2 depends on 1
794        assert!(g.add_edge(3, 2)); // 3 depends on 2
795        assert!(!g.add_edge(1, 3)); // would create 1 -> 3 -> 2 -> 1 cycle
796    }
797
798    #[test]
799    fn valid_dag_no_false_cycle() {
800        let mut g = DependencyGraph::new("test");
801        g.add_node(make_node(1));
802        g.add_node(make_node(2));
803        g.add_node(make_node(3));
804        assert!(g.add_edge(2, 1));
805        assert!(g.add_edge(3, 1)); // diamond top, both depend on 1
806        assert!(g.add_edge(3, 2)); // 3 also depends on 2
807    }
808
809    #[test]
810    fn ready_issues_returns_pending_with_merged_deps() {
811        let mut g = DependencyGraph::new("test");
812        g.add_node(make_node(1));
813        g.add_node(make_node(2));
814        g.add_edge(2, 1);
815
816        // 1 is pending with no deps, so it's ready
817        assert_eq!(g.ready_issues(), vec![1]);
818
819        // Merge node 1, now node 2 should be ready
820        g.transition(1, NodeState::Merged);
821        let ready = g.ready_issues();
822        assert_eq!(ready, vec![2]);
823    }
824
825    #[test]
826    fn ready_issues_empty_when_deps_in_flight() {
827        let mut g = DependencyGraph::new("test");
828        g.add_node(make_node(1));
829        g.add_node(make_node(2));
830        g.add_edge(2, 1);
831        g.transition(1, NodeState::InFlight);
832        assert!(g.ready_issues().is_empty());
833    }
834
835    #[test]
836    fn ready_issues_empty_when_deps_awaiting_merge() {
837        let mut g = DependencyGraph::new("test");
838        g.add_node(make_node(1));
839        g.add_node(make_node(2));
840        g.add_edge(2, 1);
841        g.transition(1, NodeState::AwaitingMerge);
842        assert!(g.ready_issues().is_empty());
843    }
844
845    #[test]
846    fn awaiting_merge_returns_correct_nodes() {
847        let mut g = DependencyGraph::new("test");
848        g.add_node(make_node(1));
849        g.add_node(make_node(2));
850        g.transition(1, NodeState::AwaitingMerge);
851        let awaiting = g.awaiting_merge();
852        assert_eq!(awaiting, vec![1]);
853    }
854
855    #[test]
856    fn all_terminal_checks_all_nodes() {
857        let mut g = DependencyGraph::new("test");
858        g.add_node(make_node(1));
859        g.add_node(make_node(2));
860        assert!(!g.all_terminal());
861
862        g.transition(1, NodeState::Merged);
863        assert!(!g.all_terminal());
864
865        g.transition(2, NodeState::Failed);
866        assert!(g.all_terminal());
867    }
868
869    #[test]
870    fn is_blocked_when_dep_failed() {
871        let mut g = DependencyGraph::new("test");
872        g.add_node(make_node(1));
873        g.add_node(make_node(2));
874        g.add_edge(2, 1);
875        g.transition(1, NodeState::Failed);
876        assert!(g.is_blocked(2));
877        assert!(!g.is_blocked(1));
878    }
879
880    #[test]
881    fn remove_node_cleans_edges() {
882        let mut g = DependencyGraph::new("test");
883        g.add_node(make_node(1));
884        g.add_node(make_node(2));
885        g.add_node(make_node(3));
886        g.add_edge(2, 1);
887        g.add_edge(3, 2);
888
889        g.remove_node(2);
890        assert!(!g.contains(2));
891        // Edge from 3 to 2 should be gone
892        assert!(g.dependencies(3).is_empty());
893        // Reverse edge from 1 to 2 should be gone
894        assert!(g.dependents(1).is_empty());
895    }
896
897    #[test]
898    fn db_roundtrip() {
899        let conn = crate::db::open_in_memory().unwrap();
900        let mut g = DependencyGraph::new("test-session");
901        g.add_node(make_node(1));
902        g.add_node(make_node(2));
903        g.add_node(make_node(3));
904        g.add_edge(2, 1);
905        g.add_edge(3, 1);
906        g.add_edge(3, 2);
907        g.transition(1, NodeState::Merged);
908        g.set_pr_number(1, 99);
909        g.set_run_id(1, "abc");
910
911        g.save_to_db(&conn).unwrap();
912
913        let loaded = DependencyGraph::from_db(&conn, "test-session").unwrap();
914        assert_eq!(loaded.node_count(), 3);
915        assert_eq!(loaded.dependencies(2), HashSet::from([1]));
916        assert_eq!(loaded.dependencies(3), HashSet::from([1, 2]));
917        assert_eq!(loaded.node(1).unwrap().state, NodeState::Merged);
918        assert_eq!(loaded.node(1).unwrap().pr_number, Some(99));
919        assert_eq!(loaded.node(1).unwrap().run_id.as_deref(), Some("abc"));
920    }
921
922    #[test]
923    fn diamond_graph_ready_ordering() {
924        // A -> B, A -> C, B -> D, C -> D (D is the root)
925        let mut g = DependencyGraph::new("test");
926        g.add_node(make_node(1)); // D
927        g.add_node(make_node(2)); // B
928        g.add_node(make_node(3)); // C
929        g.add_node(make_node(4)); // A
930
931        g.add_edge(2, 1); // B depends on D
932        g.add_edge(3, 1); // C depends on D
933        g.add_edge(4, 2); // A depends on B
934        g.add_edge(4, 3); // A depends on C
935
936        // Only D is ready initially
937        assert_eq!(g.ready_issues(), vec![1]);
938
939        // Merge D, B and C become ready
940        g.transition(1, NodeState::Merged);
941        let mut ready = g.ready_issues();
942        ready.sort_unstable();
943        assert_eq!(ready, vec![2, 3]);
944
945        // Merge B, A still waiting on C
946        g.transition(2, NodeState::Merged);
947        assert_eq!(g.ready_issues(), vec![3]);
948
949        // Merge C, now A is ready
950        g.transition(3, NodeState::Merged);
951        assert_eq!(g.ready_issues(), vec![4]);
952    }
953
954    #[test]
955    fn empty_graph_is_all_terminal() {
956        let g = DependencyGraph::new("test");
957        assert!(g.all_terminal());
958    }
959
960    #[test]
961    fn independent_nodes_all_ready() {
962        let mut g = DependencyGraph::new("test");
963        g.add_node(make_node(1));
964        g.add_node(make_node(2));
965        g.add_node(make_node(3));
966
967        let mut ready = g.ready_issues();
968        ready.sort_unstable();
969        assert_eq!(ready, vec![1, 2, 3]);
970    }
971
972    fn make_planned(number: u32, depends_on: Vec<u32>) -> crate::agents::PlannedNode {
973        crate::agents::PlannedNode {
974            number,
975            title: format!("Issue #{number}"),
976            area: "test".to_string(),
977            predicted_files: vec![],
978            has_migration: false,
979            complexity: crate::agents::Complexity::Full,
980            depends_on,
981            reasoning: String::new(),
982        }
983    }
984
985    fn make_issue(number: u32) -> PipelineIssue {
986        PipelineIssue {
987            number,
988            title: format!("Issue #{number}"),
989            body: String::new(),
990            source: crate::issues::IssueOrigin::Github,
991            target_repo: None,
992            author: None,
993        }
994    }
995
996    #[test]
997    fn from_planner_output_basic() {
998        let plan = crate::agents::PlannerGraphOutput {
999            nodes: vec![
1000                make_planned(1, vec![]),
1001                make_planned(2, vec![]),
1002                make_planned(3, vec![1, 2]),
1003            ],
1004            total_issues: 3,
1005            parallel_capacity: 2,
1006        };
1007        let issues = vec![make_issue(1), make_issue(2), make_issue(3)];
1008
1009        let g = DependencyGraph::from_planner_output("sess", &plan, &issues);
1010        assert_eq!(g.node_count(), 3);
1011        assert_eq!(g.dependencies(3), HashSet::from([1, 2]));
1012        assert!(g.dependencies(1).is_empty());
1013        // Issues should be attached
1014        assert!(g.node(1).unwrap().issue.is_some());
1015        assert!(g.node(2).unwrap().issue.is_some());
1016    }
1017
1018    #[test]
1019    fn from_planner_output_skips_cycle() {
1020        let plan = crate::agents::PlannerGraphOutput {
1021            nodes: vec![make_planned(1, vec![2]), make_planned(2, vec![1])],
1022            total_issues: 2,
1023            parallel_capacity: 1,
1024        };
1025
1026        let g = DependencyGraph::from_planner_output("sess", &plan, &[]);
1027        // One edge should succeed, the other should be skipped (cycle)
1028        assert_eq!(g.node_count(), 2);
1029        let total_edges: usize = [1, 2].iter().map(|n| g.dependencies(*n).len()).sum();
1030        assert_eq!(total_edges, 1);
1031    }
1032
1033    #[test]
1034    fn merge_planner_output_adds_new_only() {
1035        let plan1 = crate::agents::PlannerGraphOutput {
1036            nodes: vec![make_planned(1, vec![])],
1037            total_issues: 1,
1038            parallel_capacity: 1,
1039        };
1040        let mut g = DependencyGraph::from_planner_output("sess", &plan1, &[make_issue(1)]);
1041        g.transition(1, NodeState::InFlight);
1042
1043        // Merge a plan that includes node 1 again and adds node 2
1044        let plan2 = crate::agents::PlannerGraphOutput {
1045            nodes: vec![make_planned(1, vec![]), make_planned(2, vec![1])],
1046            total_issues: 2,
1047            parallel_capacity: 1,
1048        };
1049        g.merge_planner_output(&plan2, &[make_issue(2)]);
1050
1051        assert_eq!(g.node_count(), 2);
1052        // Node 1 should still be InFlight (not overwritten)
1053        assert_eq!(g.node(1).unwrap().state, NodeState::InFlight);
1054        // Node 2 should be Pending with edge to 1
1055        assert_eq!(g.node(2).unwrap().state, NodeState::Pending);
1056        assert_eq!(g.dependencies(2), HashSet::from([1]));
1057    }
1058
1059    #[test]
1060    fn merge_planner_output_cross_edges() {
1061        let mut g = DependencyGraph::new("sess");
1062        g.add_node(make_node(1));
1063        g.transition(1, NodeState::Merged);
1064
1065        let plan = crate::agents::PlannerGraphOutput {
1066            nodes: vec![make_planned(2, vec![1])],
1067            total_issues: 1,
1068            parallel_capacity: 1,
1069        };
1070        g.merge_planner_output(&plan, &[make_issue(2)]);
1071
1072        assert_eq!(g.dependencies(2), HashSet::from([1]));
1073        // Node 2 should be ready since node 1 is merged
1074        assert_eq!(g.ready_issues(), vec![2]);
1075    }
1076
1077    #[test]
1078    fn propagate_failure_linear_chain() {
1079        let mut g = DependencyGraph::new("test");
1080        g.add_node(make_node(1));
1081        g.add_node(make_node(2));
1082        g.add_node(make_node(3));
1083        g.add_edge(2, 1);
1084        g.add_edge(3, 2);
1085
1086        g.transition(1, NodeState::Failed);
1087        let mut failed = g.propagate_failure(1);
1088        failed.sort_unstable();
1089        assert_eq!(failed, vec![2, 3]);
1090        assert_eq!(g.node(2).unwrap().state, NodeState::Failed);
1091        assert_eq!(g.node(3).unwrap().state, NodeState::Failed);
1092    }
1093
1094    #[test]
1095    fn propagate_failure_diamond() {
1096        // 1 is root, 2 and 3 depend on 1, 4 depends on 2 and 3
1097        let mut g = DependencyGraph::new("test");
1098        for i in 1..=4 {
1099            g.add_node(make_node(i));
1100        }
1101        g.add_edge(2, 1);
1102        g.add_edge(3, 1);
1103        g.add_edge(4, 2);
1104        g.add_edge(4, 3);
1105
1106        g.transition(1, NodeState::Failed);
1107        let mut failed = g.propagate_failure(1);
1108        failed.sort_unstable();
1109        assert_eq!(failed, vec![2, 3, 4]);
1110    }
1111
1112    #[test]
1113    fn propagate_failure_partial_branch() {
1114        // 1 and 2 are roots, 3 depends on 1, 4 depends on 2
1115        let mut g = DependencyGraph::new("test");
1116        for i in 1..=4 {
1117            g.add_node(make_node(i));
1118        }
1119        g.add_edge(3, 1);
1120        g.add_edge(4, 2);
1121
1122        g.transition(1, NodeState::Failed);
1123        let failed = g.propagate_failure(1);
1124        assert_eq!(failed, vec![3]);
1125        // Node 4 should still be Pending (unrelated branch)
1126        assert_eq!(g.node(4).unwrap().state, NodeState::Pending);
1127    }
1128
1129    #[test]
1130    fn propagate_failure_skips_merged() {
1131        let mut g = DependencyGraph::new("test");
1132        g.add_node(make_node(1));
1133        g.add_node(make_node(2));
1134        g.add_node(make_node(3));
1135        g.add_edge(2, 1);
1136        g.add_edge(3, 2);
1137        // Node 2 already merged before 1 fails (unusual but possible)
1138        g.transition(2, NodeState::Merged);
1139
1140        g.transition(1, NodeState::Failed);
1141        let failed = g.propagate_failure(1);
1142        // Node 2 is merged, skip. Node 3 depends on 2 (merged), not directly on 1.
1143        assert!(failed.is_empty());
1144        assert_eq!(g.node(2).unwrap().state, NodeState::Merged);
1145        assert_eq!(g.node(3).unwrap().state, NodeState::Pending);
1146    }
1147
1148    #[test]
1149    fn propagate_failure_returns_newly_failed() {
1150        let mut g = DependencyGraph::new("test");
1151        g.add_node(make_node(1));
1152        g.add_node(make_node(2));
1153        g.add_node(make_node(3));
1154        g.add_edge(2, 1);
1155        g.add_edge(3, 1);
1156
1157        g.transition(1, NodeState::Failed);
1158        let mut failed = g.propagate_failure(1);
1159        failed.sort_unstable();
1160        assert_eq!(failed, vec![2, 3]);
1161        // Calling again should return empty (already failed)
1162        let failed2 = g.propagate_failure(1);
1163        assert!(failed2.is_empty());
1164    }
1165
1166    #[test]
1167    fn to_graph_context_includes_all_nodes() {
1168        let mut g = DependencyGraph::new("test");
1169        g.add_node(make_node(1));
1170        g.add_node(make_node(2));
1171        g.add_node(make_node(3));
1172        g.add_edge(2, 1);
1173        g.add_edge(3, 1);
1174        g.add_edge(3, 2);
1175        g.transition(1, NodeState::InFlight);
1176
1177        let ctx = g.to_graph_context();
1178        assert_eq!(ctx.len(), 3);
1179
1180        let ctx_map: HashMap<u32, &crate::agents::GraphContextNode> =
1181            ctx.iter().map(|c| (c.number, c)).collect();
1182
1183        let c1 = ctx_map[&1];
1184        assert_eq!(c1.state, NodeState::InFlight);
1185        assert!(c1.depends_on.is_empty());
1186
1187        let c2 = ctx_map[&2];
1188        assert_eq!(c2.state, NodeState::Pending);
1189        assert_eq!(c2.depends_on, vec![1]);
1190
1191        let c3 = ctx_map[&3];
1192        assert_eq!(c3.state, NodeState::Pending);
1193        assert_eq!(c3.depends_on, vec![1, 2]);
1194    }
1195
1196    #[test]
1197    fn save_to_db_is_atomic_on_success() {
1198        let conn = crate::db::open_in_memory().unwrap();
1199        let mut g = DependencyGraph::new("atomic-test");
1200        g.add_node(make_node(1));
1201        g.add_node(make_node(2));
1202        g.add_edge(2, 1);
1203
1204        g.save_to_db(&conn).unwrap();
1205
1206        // Overwrite with a different graph to verify the delete+insert is atomic
1207        let mut g2 = DependencyGraph::new("atomic-test");
1208        g2.add_node(make_node(10));
1209        g2.save_to_db(&conn).unwrap();
1210
1211        let loaded = DependencyGraph::from_db(&conn, "atomic-test").unwrap();
1212        // Old nodes should be gone, only node 10 remains
1213        assert_eq!(loaded.node_count(), 1);
1214        assert!(loaded.contains(10));
1215        assert!(!loaded.contains(1));
1216        assert!(!loaded.contains(2));
1217    }
1218
1219    fn make_named_node(num: u32, title: &str, area: &str) -> GraphNode {
1220        GraphNode {
1221            issue_number: num,
1222            title: title.to_string(),
1223            area: area.to_string(),
1224            predicted_files: vec![],
1225            has_migration: false,
1226            complexity: "full".to_string(),
1227            state: NodeState::Pending,
1228            pr_number: None,
1229            run_id: None,
1230            issue: None,
1231            target_repo: None,
1232        }
1233    }
1234
1235    // --- display_layered tests ---
1236
1237    #[test]
1238    fn display_layered_empty_graph() {
1239        let g = DependencyGraph::new("test");
1240        let lines = g.display_layered();
1241        assert_eq!(lines.len(), 1);
1242        assert!(lines[0].contains("empty graph"));
1243    }
1244
1245    #[test]
1246    fn display_layered_single_node() {
1247        let mut g = DependencyGraph::new("test");
1248        g.add_node(make_named_node(1, "Add auth", "backend"));
1249        let lines = g.display_layered();
1250        let text = lines.join("\n");
1251        assert!(text.contains("1 issues"));
1252        assert!(text.contains("Layer 0"));
1253        assert!(text.contains("#1"));
1254        assert!(text.contains("Add auth"));
1255        assert!(text.contains("Legend"));
1256    }
1257
1258    #[test]
1259    fn display_layered_linear_chain() {
1260        let mut g = DependencyGraph::new("test");
1261        g.add_node(make_named_node(1, "Database schema", "db"));
1262        g.add_node(make_named_node(2, "API endpoints", "backend"));
1263        g.add_node(make_named_node(3, "Frontend views", "frontend"));
1264        g.add_edge(2, 1);
1265        g.add_edge(3, 2);
1266
1267        let lines = g.display_layered();
1268        let text = lines.join("\n");
1269        // Should have 3 layers
1270        assert!(text.contains("Layer 0"));
1271        assert!(text.contains("Layer 1"));
1272        assert!(text.contains("Layer 2"));
1273        // All issues present
1274        assert!(text.contains("#1"));
1275        assert!(text.contains("#2"));
1276        assert!(text.contains("#3"));
1277    }
1278
1279    #[test]
1280    fn display_layered_diamond_dag() {
1281        // 1 is root, 2 and 3 depend on 1, 4 depends on 2 and 3
1282        let mut g = DependencyGraph::new("test");
1283        g.add_node(make_named_node(1, "Core lib", "core"));
1284        g.add_node(make_named_node(2, "Auth module", "auth"));
1285        g.add_node(make_named_node(3, "Logging module", "infra"));
1286        g.add_node(make_named_node(4, "Integration", "all"));
1287        g.add_edge(2, 1);
1288        g.add_edge(3, 1);
1289        g.add_edge(4, 2);
1290        g.add_edge(4, 3);
1291
1292        let lines = g.display_layered();
1293        let text = lines.join("\n");
1294        assert!(text.contains("Layer 0"));
1295        assert!(text.contains("Layer 1"));
1296        assert!(text.contains("Layer 2"));
1297        assert!(text.contains("4 issues"));
1298        // Layer 1 should have both #2 and #3 side by side
1299        assert!(text.contains("#2"));
1300        assert!(text.contains("#3"));
1301    }
1302
1303    #[test]
1304    fn display_layered_independent_nodes() {
1305        let mut g = DependencyGraph::new("test");
1306        g.add_node(make_named_node(1, "Fix typo", "docs"));
1307        g.add_node(make_named_node(2, "Add lint", "ci"));
1308        g.add_node(make_named_node(3, "Bump deps", "deps"));
1309
1310        let lines = g.display_layered();
1311        let text = lines.join("\n");
1312        // All in layer 0, no other layers
1313        assert!(text.contains("Layer 0 (no deps)"));
1314        assert!(!text.contains("Layer 1"));
1315        // All three boxes rendered
1316        assert!(text.contains("#1"));
1317        assert!(text.contains("#2"));
1318        assert!(text.contains("#3"));
1319    }
1320
1321    #[test]
1322    fn display_layered_shows_state_indicators() {
1323        let mut g = DependencyGraph::new("test");
1324        g.add_node(make_named_node(1, "Done thing", "core"));
1325        g.add_node(make_named_node(2, "Running thing", "core"));
1326        g.add_node(make_named_node(3, "Broken thing", "core"));
1327        g.add_node(make_named_node(4, "Waiting thing", "core"));
1328        g.add_node(make_named_node(5, "Pending thing", "core"));
1329        g.transition(1, NodeState::Merged);
1330        g.transition(2, NodeState::InFlight);
1331        g.transition(3, NodeState::Failed);
1332        g.transition(4, NodeState::AwaitingMerge);
1333
1334        let lines = g.display_layered();
1335        let text = lines.join("\n");
1336        assert!(text.contains("[*]")); // merged
1337        assert!(text.contains("[~]")); // in_flight
1338        assert!(text.contains("[!]")); // failed
1339        assert!(text.contains("[?]")); // awaiting_merge
1340        assert!(text.contains("[ ]")); // pending
1341    }
1342
1343    #[test]
1344    fn display_layered_shows_pr_number() {
1345        let mut g = DependencyGraph::new("test");
1346        g.add_node(make_named_node(1, "Has PR", "core"));
1347        g.set_pr_number(1, 42);
1348
1349        let lines = g.display_layered();
1350        let text = lines.join("\n");
1351        assert!(text.contains("PR #42"));
1352    }
1353
1354    #[test]
1355    fn display_layered_summary_counts() {
1356        let mut g = DependencyGraph::new("test");
1357        g.add_node(make_named_node(1, "A", "core"));
1358        g.add_node(make_named_node(2, "B", "core"));
1359        g.add_node(make_named_node(3, "C", "core"));
1360        g.transition(1, NodeState::Merged);
1361        g.transition(2, NodeState::InFlight);
1362        g.transition(3, NodeState::Failed);
1363
1364        let lines = g.display_layered();
1365        let text = lines.join("\n");
1366        assert!(text.contains("3 issues"));
1367        assert!(text.contains("1 merged"));
1368        assert!(text.contains("1 in flight"));
1369        assert!(text.contains("1 failed"));
1370    }
1371
1372    #[test]
1373    fn display_layered_shows_blocked() {
1374        let mut g = DependencyGraph::new("test");
1375        g.add_node(make_named_node(1, "Root", "core"));
1376        g.add_node(make_named_node(2, "Blocked child", "core"));
1377        g.add_edge(2, 1);
1378        g.transition(1, NodeState::Failed);
1379
1380        let lines = g.display_layered();
1381        let text = lines.join("\n");
1382        assert!(text.contains("BLOCKED"));
1383    }
1384
1385    #[test]
1386    fn display_layered_has_connectors_between_layers() {
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, "Child", "core"));
1390        g.add_edge(2, 1);
1391
1392        let lines = g.display_layered();
1393        let text = lines.join("\n");
1394        // Should contain connector characters between layers
1395        assert!(text.contains('\u{25bc}')); // ▼ arrow
1396    }
1397
1398    #[test]
1399    fn display_layered_box_drawing_chars() {
1400        let mut g = DependencyGraph::new("test");
1401        g.add_node(make_named_node(1, "Test node", "core"));
1402
1403        let lines = g.display_layered();
1404        let text = lines.join("\n");
1405        // Box corners
1406        assert!(text.contains('\u{250c}')); // ┌
1407        assert!(text.contains('\u{2510}')); // ┐
1408        assert!(text.contains('\u{2514}')); // └
1409        assert!(text.contains('\u{2518}')); // ┘
1410        assert!(text.contains('\u{2500}')); // ─
1411        assert!(text.contains('\u{2502}')); // │
1412    }
1413
1414    #[test]
1415    fn compute_layers_linear() {
1416        let mut g = DependencyGraph::new("test");
1417        g.add_node(make_node(1));
1418        g.add_node(make_node(2));
1419        g.add_node(make_node(3));
1420        g.add_edge(2, 1);
1421        g.add_edge(3, 2);
1422
1423        let layers = g.compute_layers();
1424        assert_eq!(layers.len(), 3);
1425        assert_eq!(layers[0], vec![1]);
1426        assert_eq!(layers[1], vec![2]);
1427        assert_eq!(layers[2], vec![3]);
1428    }
1429
1430    #[test]
1431    fn compute_layers_diamond() {
1432        let mut g = DependencyGraph::new("test");
1433        g.add_node(make_node(1));
1434        g.add_node(make_node(2));
1435        g.add_node(make_node(3));
1436        g.add_node(make_node(4));
1437        g.add_edge(2, 1);
1438        g.add_edge(3, 1);
1439        g.add_edge(4, 2);
1440        g.add_edge(4, 3);
1441
1442        let layers = g.compute_layers();
1443        assert_eq!(layers.len(), 3);
1444        assert_eq!(layers[0], vec![1]);
1445        assert_eq!(layers[1], vec![2, 3]);
1446        assert_eq!(layers[2], vec![4]);
1447    }
1448
1449    #[test]
1450    fn compute_layers_independent() {
1451        let mut g = DependencyGraph::new("test");
1452        g.add_node(make_node(1));
1453        g.add_node(make_node(2));
1454        g.add_node(make_node(3));
1455
1456        let layers = g.compute_layers();
1457        assert_eq!(layers.len(), 1);
1458        assert_eq!(layers[0], vec![1, 2, 3]);
1459    }
1460}