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        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            // Layer header
416            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            // Render boxes for this layer, wrapping into rows
425            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            // Draw connectors to next layer if there is one
447            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        // Legend
456        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    /// Compute a consistent box width based on the longest content.
466    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                // State indicator (3) + space + issue label + 2 spaces + title
475                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    /// Render a single node as a Unicode box.
483    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        // Second line: area + PR + blocked
507        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    /// Render connector lines between two adjacent layers.
530    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        // For each node in to_layer, find which nodes in from_layer it depends on.
538        // We draw simple vertical/horizontal pipe connectors.
539        let full_box_width = box_width + 2; // include border chars
540        let spacing = 2usize;
541
542        // Compute center x position for each node, wrapping by max_per_row
543        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        // Collect all edges between these two layers (dedup for wrapped columns)
563        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        // Track which columns are sources and targets
585        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        // Row 1: vertical pipes from source boxes
593        let mut row1 = vec![' '; width];
594        for &col in &source_cols {
595            row1[col + 2] = '\u{2502}'; // │
596        }
597
598        // Row 2: build direction flags per column, then convert to box-drawing
599        let row2 = build_connector_row(&connections, &source_cols, &target_cols, width);
600
601        // Row 3: arrows into target boxes
602        let mut row3 = vec![' '; width];
603        for &col in &target_cols {
604            row3[col + 2] = '\u{25bc}'; // ▼
605        }
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    /// Build planner context from the current graph state.
616    ///
617    /// Produces one `GraphContextNode` per node so the planner can see
618    /// in-flight work and avoid scheduling conflicts.
619    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
644/// Build the horizontal connector row between two layers.
645///
646/// For each column, compute which directions (up/down/left/right) have connections,
647/// then pick the appropriate Unicode box-drawing character.
648fn 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
683/// Map a direction bitmask to the appropriate Unicode box-drawing character.
684const fn box_drawing_char(dirs: u8) -> char {
685    match dirs {
686        0b1100 => '\u{2502}', // │  up+down
687        0b0011 => '\u{2500}', // ─  left+right
688        0b1001 => '\u{2514}', // └  up+right
689        0b1010 => '\u{2518}', // ┘  up+left
690        0b0101 => '\u{250c}', // ┌  down+right
691        0b0110 => '\u{2510}', // ┐  down+left
692        0b1110 => '\u{2524}', // ┤  up+down+left
693        0b1101 => '\u{251c}', // ├  up+down+right
694        0b0111 => '\u{252c}', // ┬  down+left+right
695        0b1011 => '\u{2534}', // ┴  up+left+right
696        0b1111 => '\u{253c}', // ┼  all
697        0b1000 => '\u{2575}', // ╵  up only
698        0b0100 => '\u{2577}', // ╷  down only
699        0b0010 => '\u{2574}', // ╴  left only
700        0b0001 => '\u{2576}', // ╶  right only
701        _ => ' ',
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)); // 2 depends on 1
779
780        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)); // 2 depends on 1
797        assert!(!g.add_edge(1, 2)); // would create cycle
798    }
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)); // 2 depends on 1
807        assert!(g.add_edge(3, 2)); // 3 depends on 2
808        assert!(!g.add_edge(1, 3)); // would create 1 -> 3 -> 2 -> 1 cycle
809    }
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)); // diamond top, both depend on 1
819        assert!(g.add_edge(3, 2)); // 3 also depends on 2
820    }
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        // 1 is pending with no deps, so it's ready
830        assert_eq!(g.ready_issues(), vec![1]);
831
832        // Merge node 1, now node 2 should be ready
833        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        // Edge from 3 to 2 should be gone
905        assert!(g.dependencies(3).is_empty());
906        // Reverse edge from 1 to 2 should be gone
907        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        // A -> B, A -> C, B -> D, C -> D (D is the root)
938        let mut g = DependencyGraph::new("test");
939        g.add_node(make_node(1)); // D
940        g.add_node(make_node(2)); // B
941        g.add_node(make_node(3)); // C
942        g.add_node(make_node(4)); // A
943
944        g.add_edge(2, 1); // B depends on D
945        g.add_edge(3, 1); // C depends on D
946        g.add_edge(4, 2); // A depends on B
947        g.add_edge(4, 3); // A depends on C
948
949        // Only D is ready initially
950        assert_eq!(g.ready_issues(), vec![1]);
951
952        // Merge D, B and C become ready
953        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        // Merge B, A still waiting on C
959        g.transition(2, NodeState::Merged);
960        assert_eq!(g.ready_issues(), vec![3]);
961
962        // Merge C, now A is ready
963        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        // Issues should be attached
1027        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        // One edge should succeed, the other should be skipped (cycle)
1041        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        // Merge a plan that includes node 1 again and adds node 2
1057        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        // Node 1 should still be InFlight (not overwritten)
1066        assert_eq!(g.node(1).unwrap().state, NodeState::InFlight);
1067        // Node 2 should be Pending with edge to 1
1068        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        // Node 2 should be ready since node 1 is merged
1087        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        // 1 is root, 2 and 3 depend on 1, 4 depends on 2 and 3
1110        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        // 1 and 2 are roots, 3 depends on 1, 4 depends on 2
1128        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        // Node 4 should still be Pending (unrelated branch)
1139        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        // Node 2 already merged before 1 fails (unusual but possible)
1151        g.transition(2, NodeState::Merged);
1152
1153        g.transition(1, NodeState::Failed);
1154        let failed = g.propagate_failure(1);
1155        // Node 2 is merged, skip. Node 3 depends on 2 (merged), not directly on 1.
1156        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        // Calling again should return empty (already failed)
1175        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        // Overwrite with a different graph to verify the delete+insert is atomic
1220        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        // Old nodes should be gone, only node 10 remains
1226        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    // --- display_layered tests ---
1249
1250    #[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        // Should have 3 layers
1283        assert!(text.contains("Layer 0"));
1284        assert!(text.contains("Layer 1"));
1285        assert!(text.contains("Layer 2"));
1286        // All issues present
1287        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        // 1 is root, 2 and 3 depend on 1, 4 depends on 2 and 3
1295        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        // Layer 1 should have both #2 and #3 side by side
1312        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        // All in layer 0, no other layers
1326        assert!(text.contains("Layer 0 (no deps)"));
1327        assert!(!text.contains("Layer 1"));
1328        // All three boxes rendered
1329        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("[*]")); // merged
1350        assert!(text.contains("[~]")); // in_flight
1351        assert!(text.contains("[!]")); // failed
1352        assert!(text.contains("[?]")); // awaiting_merge
1353        assert!(text.contains("[ ]")); // pending
1354    }
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        // Should contain connector characters between layers
1408        assert!(text.contains('\u{25bc}')); // ▼ arrow
1409    }
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        // Box corners
1419        assert!(text.contains('\u{250c}')); // ┌
1420        assert!(text.contains('\u{2510}')); // ┐
1421        assert!(text.contains('\u{2514}')); // └
1422        assert!(text.contains('\u{2518}')); // ┘
1423        assert!(text.contains('\u{2500}')); // ─
1424        assert!(text.contains('\u{2502}')); // │
1425    }
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        // Build a graph with many independent nodes so wrapping kicks in
1477        // regardless of terminal width.
1478        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        // All nodes should appear
1487        for i in 1..=12 {
1488            assert!(text.contains(&format!("#{i}")), "missing #{i}");
1489        }
1490
1491        // No single line should exceed the detected terminal width.
1492        // Use chars().count() since box-drawing chars are multi-byte UTF-8.
1493        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        // Layer 0: 6 independent nodes (will wrap at default 120 cols).
1501        // Layer 1: 1 node that depends on node 1.
1502        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        // Connector arrow should still be present
1513        assert!(text.contains('\u{25bc}'), "missing connector arrow");
1514        // Both layers should appear
1515        assert!(text.contains("Layer 0"));
1516        assert!(text.contains("Layer 1"));
1517    }
1518}