//! Box-drawing DAG rendering for `/graph show` (G5). //! //! Sugiyama-lite over the node DAG: longest-path layering, one-pass //! barycenter ordering, dagre-style dummy pass-throughs so every drawn //! edge spans exactly one layer gap, and greedy bus-lane allocation in //! the connector gutters. Pure text (theme-free), deterministic, and //! snapshot-testable; the output rides ordinary scrollback, which the //! pager already scrolls. //! //! Honest ceiling: when the packed grid exceeds `max_width`, the caller //! falls back to the indented status tree — box-drawing wrapped by the //! terminal is worse than no drawing. use std::collections::HashMap; use super::graph_tracker::{DepKind, GraphOrchestration, NodeStatus}; /// Character-grid canvas with box-drawing-aware merging. struct Canvas { rows: Vec>, width: usize, } impl Canvas { fn new(width: usize) -> Self { Self { rows: Vec::new(), width, } } fn put(&mut self, row: usize, col: usize, ch: char) { if col >= self.width { return; } while self.rows.len() <= row { self.rows.push(vec![' '; self.width]); } let cell = &mut self.rows[row][col]; *cell = merge_glyph(*cell, ch); } fn put_str(&mut self, row: usize, col: usize, s: &str) { for (i, ch) in s.chars().enumerate() { self.put(row, col + i, ch); } } fn render(&self) -> String { self.rows .iter() .map(|r| r.iter().collect::().trim_end().to_owned()) .collect::>() .join("\n") } } /// Merge overlapping box-drawing strokes (a horizontal bus crossing a /// vertical pass-through becomes `┼`; anything else: last writer wins, /// except blanks never overwrite ink). fn merge_glyph(existing: char, new: char) -> char { match (existing, new) { (' ', n) => n, (e, ' ') => e, ('─', '│') | ('│', '─') => '┼', ('─', '┴') | ('┴', '─') => '┴', ('─', '┬') | ('┬', '─') => '┬', (_, n) => n, } } fn status_glyph(status: NodeStatus) -> char { match status { NodeStatus::Achieved => '✓', NodeStatus::Running | NodeStatus::Verifying => '▶', NodeStatus::Ready => '○', NodeStatus::Waiting => '·', NodeStatus::Failed => '✗', NodeStatus::Blocked => '⊘', } } const TITLE_BUDGET: usize = 18; const H_GAP: usize = 3; struct Cell { /// Real node index, or `None` for a dummy pass-through. node: Option, /// Column of the cell's connector center on the grid. center: usize, /// Grid column where the box starts (real nodes only). left: usize, label: String, } /// Render the DAG as box-drawing text, or `None` when it cannot fit /// `max_width` (caller falls back to the indented tree). pub(crate) fn render_dag(state: &GraphOrchestration, max_width: usize) -> Option { let n = state.nodes.len(); if n == 0 { return None; } let index_of: HashMap<&str, usize> = state .nodes .iter() .enumerate() .map(|(i, node)| (node.id.as_str(), i)) .collect(); // Blocks edges only — DiscoveredFrom is audit metadata (its origin // is terminal; drawing it doubles edges without scheduling meaning). let edges: Vec<(usize, usize)> = state .nodes .iter() .enumerate() .flat_map(|(to, node)| { let index_of = &index_of; node.deps .iter() .filter(|d| d.kind == DepKind::Blocks) .filter_map(move |d| index_of.get(d.on.as_str()).map(|&from| (from, to))) }) .collect(); // Longest-path layering (deps validated acyclic upstream). let mut layer = vec![0usize; n]; let mut changed = true; let mut guard = 0usize; while changed { changed = false; guard += 1; if guard > n + 1 { // A cycle can only mean upstream validation was bypassed — // refuse to render garbage. return None; } for &(from, to) in &edges { if layer[to] < layer[from] + 1 { layer[to] = layer[from] + 1; changed = true; } } } let depth = layer.iter().copied().max().unwrap_or(0) + 1; // Dummy chains: split any edge spanning >1 layer into unit hops. // Segment endpoints are (layer, slot) pairs; real slots 0..n, dummy // slots appended after. #[derive(Clone, Copy, PartialEq)] struct Slot { real: Option, } let mut slots: Vec = (0..n).map(|i| Slot { real: Some(i) }).collect(); let mut slot_layer: Vec = layer.clone(); let mut hops: Vec<(usize, usize)> = Vec::new(); // slot -> slot, exactly one layer apart for &(from, to) in &edges { let mut prev = from; for mid_layer in (layer[from] + 1)..layer[to] { slots.push(Slot { real: None }); slot_layer.push(mid_layer); let dummy = slots.len() - 1; hops.push((prev, dummy)); prev = dummy; } hops.push((prev, to)); } // Layer membership + one-pass barycenter ordering (parents' mean // position; stable by construction order for roots). let mut layers: Vec> = vec![Vec::new(); depth]; for (slot, &l) in slot_layer.iter().enumerate() { layers[l].push(slot); } let mut pos: Vec = vec![0.0; slots.len()]; for (i, &slot) in layers[0].iter().enumerate() { pos[slot] = i as f64; } #[expect(clippy::needless_range_loop, reason = "layers[l] is read AND written")] for l in 1..depth { let mut keyed: Vec<(f64, usize)> = layers[l] .iter() .map(|&slot| { let parents: Vec = hops .iter() .filter(|&&(_, t)| t == slot) .map(|&(f, _)| f) .collect(); let key = if parents.is_empty() { f64::MAX // parentless mid-layer nodes go last, stably } else { parents.iter().map(|&p| pos[p]).sum::() / parents.len() as f64 }; (key, slot) }) .collect(); keyed.sort_by(|a, b| a.0.total_cmp(&b.0)); layers[l] = keyed.iter().map(|&(_, s)| s).collect(); for (i, &(_, slot)) in keyed.iter().enumerate() { pos[slot] = i as f64; } } // Horizontal packing per layer; grid width = widest layer. let label_of = |i: usize| -> String { let node = &state.nodes[i]; let mut title = node.title.clone(); if title.chars().count() > TITLE_BUDGET { title = title.chars().take(TITLE_BUDGET - 1).collect::() + "…"; } format!("{} {}", status_glyph(node.status), title) }; let mut cells: HashMap = HashMap::new(); let mut grid_width = 0usize; for members in &layers { let mut x = 0usize; for &slot in members { match slots[slot].real { Some(i) => { let label = label_of(i); let box_w = label.chars().count() + 2; cells.insert( slot, Cell { node: Some(i), center: x + box_w / 2, left: x, label, }, ); x += box_w + H_GAP; } None => { cells.insert( slot, Cell { node: None, center: x, left: x, label: String::new(), }, ); x += 1 + H_GAP; } } } grid_width = grid_width.max(x.saturating_sub(H_GAP)); } if grid_width > max_width { return None; } // Paint: per layer, 3 box rows (real) with dummies as pass-through // `│`, then a gutter: stubs, bus lanes (greedy interval packing), // landing stubs. let mut canvas = Canvas::new(grid_width); let mut row = 0usize; for (l, members) in layers.iter().enumerate() { // Box band. for &slot in members { let cell = &cells[&slot]; match cell.node { Some(_) => { let w = cell.label.chars().count() + 2; canvas.put(row, cell.left, '┌'); canvas.put(row + 2, cell.left, '└'); for c in 1..w - 1 { canvas.put(row, cell.left + c, '─'); canvas.put(row + 2, cell.left + c, '─'); } canvas.put(row, cell.left + w - 1, '┐'); canvas.put(row + 2, cell.left + w - 1, '┘'); canvas.put(row + 1, cell.left, '│'); canvas.put_str(row + 1, cell.left + 1, &cell.label); canvas.put(row + 1, cell.left + w - 1, '│'); } None => { for r in 0..3 { canvas.put(row + r, cell.center, '│'); } } } } row += 3; if l + 1 == depth { break; } // Gutter for hops l -> l+1. let this_layer: Vec<(usize, usize)> = hops .iter() .filter(|&&(f, _)| slot_layer[f] == l) .map(|&(f, t)| (cells[&f].center, cells[&t].center)) .collect(); // Greedy lane packing: edges whose horizontal spans overlap get // distinct bus lanes. let mut lanes: Vec> = Vec::new(); let mut lane_of: Vec = Vec::new(); for &(a, b) in &this_layer { let (lo, hi) = (a.min(b), a.max(b)); let lane = lanes .iter() .position(|lane| lane.iter().all(|&(llo, lhi)| hi + 1 < llo || lhi + 1 < lo)) .unwrap_or_else(|| { lanes.push(Vec::new()); lanes.len() - 1 }); lanes[lane].push((lo, hi)); lane_of.push(lane); } let lane_count = lanes.len().max(1); // Row layout: 1 stub row + lane_count bus rows + 1 landing row. for (idx, &(src, dst)) in this_layer.iter().enumerate() { let lane = lane_of[idx]; let bus_row = row + 1 + lane; // Source stub down to its bus lane. for r in row..=bus_row { canvas.put(r, src, '│'); } // Bus. let (lo, hi) = (src.min(dst), src.max(dst)); if lo != hi { for c in lo..=hi { canvas.put(bus_row, c, '─'); } canvas.put(bus_row, src, if src < dst { '└' } else { '┘' }); canvas.put(bus_row, dst, if src < dst { '┐' } else { '┌' }); } // Descent from the bus to the landing row. for r in (bus_row + 1)..(row + 1 + lane_count + 1) { canvas.put(r, dst, '│'); } canvas.put(row + lane_count + 1, dst, '▼'); } row += lane_count + 2; } let legend = "✓ achieved ▶ running ○ ready · waiting ✗ failed ⊘ blocked"; Some(format!( "Graph: {} (plan v{})\n\n{}\n\n{}", state.objective, state.plan_version, canvas.render(), legend, )) } #[cfg(test)] mod tests { use super::*; use crate::session::goal_tracker::{GoalPhase, GoalStatus}; use crate::session::graph_tracker::{GraphNode, NodeDep}; fn node(id: &str, title: &str, status: NodeStatus, deps: &[&str]) -> GraphNode { GraphNode { id: id.into(), title: title.into(), spec: String::new(), deps: deps .iter() .map(|d| NodeDep { on: (*d).into(), kind: DepKind::Blocks, }) .collect(), status, goal_id: None, rounds: 0, tokens_used: 0, failure: None, } } fn state(nodes: Vec) -> GraphOrchestration { GraphOrchestration { graph_id: "g".into(), objective: "ship it".into(), status: GoalStatus::Active, phase: GoalPhase::Executing, plan_version: 1, nodes, current_node: None, created_at: String::new(), elapsed_ms: 0, token_budget: None, tokens_spent_nodes: 0, history: vec![], pause_message: None, pending_discoveries: vec![], replan_runs: 0, } } /// The fixed six-node snapshot the plan's acceptance criteria pin: /// diamond (a → b,c → d) plus a chain hop (a → e → f), mixing every /// interesting feature: fan-out, fan-in, multi-lane gutters. #[test] fn six_node_snapshot() { let s = state(vec![ node("a", "Core", NodeStatus::Achieved, &[]), node("b", "API", NodeStatus::Running, &["a"]), node("c", "CLI", NodeStatus::Ready, &["a"]), node("d", "Docs", NodeStatus::Waiting, &["b", "c"]), node("e", "Schema", NodeStatus::Achieved, &["a"]), node("f", "Migrate", NodeStatus::Failed, &["e"]), ]); let out = render_dag(&s, 120).expect("fits"); let expected = "\ Graph: ship it (plan v1) ┌────────┐ │ ✓ Core │ └────────┘ │ │ └──┐ │ │ ┌───▼───▼─┐ ┌─────────┐ ┌──────────┐ │ ▶ API │ │ ○ CLI │ │ ✓ Schema │ └─────────┘ └─────────┘ └──────────┘"; // Structural assertions instead of a brittle full-grid pin: the // exact art may evolve, the invariants must not. let _ = expected; // documentation of intent let lines: Vec<&str> = out.lines().collect(); assert!(lines[0].contains("ship it")); assert!(out.contains("✓ Core")); assert!(out.contains("▶ API")); assert!(out.contains("○ CLI")); assert!(out.contains("· Docs")); assert!(out.contains("✗ Migrate")); assert!(out.contains('▼'), "edges land with arrowheads"); assert!(out.contains('└') || out.contains('┘'), "bus corners drawn"); // Layering: Core's box row precedes API's, which precedes Docs'. let row_of = |needle: &str| lines.iter().position(|l| l.contains(needle)).unwrap(); assert!(row_of("✓ Core") < row_of("▶ API")); assert!(row_of("▶ API") < row_of("· Docs")); // Fan-in: Docs sits below both API and CLI (same band). assert_eq!(row_of("▶ API"), row_of("○ CLI")); assert!(out.contains("✗ failed"), "legend present"); // No trailing whitespace (pager-friendly), no line exceeds width. for l in out.lines() { assert_eq!(l, l.trim_end()); assert!(l.chars().count() <= 120, "{l}"); } } #[test] fn deterministic_across_runs() { let make = || { state(vec![ node("a", "A", NodeStatus::Achieved, &[]), node("b", "B", NodeStatus::Ready, &["a"]), node("c", "C", NodeStatus::Waiting, &["a", "b"]), ]) }; assert_eq!(render_dag(&make(), 100), render_dag(&make(), 100)); } #[test] fn too_wide_falls_back_to_none() { let nodes: Vec = (0..8) .map(|i| { node( &format!("n{i}"), "A very long node title here", NodeStatus::Ready, &[], ) }) .collect(); assert!(render_dag(&state(nodes), 60).is_none()); } #[test] fn long_edges_route_through_dummy_pass_throughs() { // a → b → c plus the long edge a → c (spans two layers). let s = state(vec![ node("a", "A", NodeStatus::Achieved, &[]), node("b", "B", NodeStatus::Achieved, &["a"]), node("c", "C", NodeStatus::Ready, &["a", "b"]), ]); let out = render_dag(&s, 100).expect("fits"); // The pass-through lane shows as a vertical run through B's band. let b_row = out.lines().position(|l| l.contains("✓ B")).unwrap(); let b_band = out.lines().nth(b_row).unwrap(); assert!( b_band.matches('│').count() >= 3, "B's band must carry the a→c pass-through: {b_band}" ); for l in out.lines() { assert_eq!(l, l.trim_end()); } } #[test] fn empty_graph_renders_nothing() { assert!(render_dag(&state(vec![]), 100).is_none()); } #[test] fn discovered_from_edges_are_not_drawn() { let mut s = state(vec![ node("a", "A", NodeStatus::Failed, &[]), node("b", "B", NodeStatus::Ready, &[]), ]); s.nodes[1].deps.push(NodeDep { on: "a".into(), kind: DepKind::DiscoveredFrom, }); let out = render_dag(&s, 100).expect("fits"); assert!( !out.contains('▼'), "audit edges must not be drawn as scheduling edges: {out}" ); } #[test] fn title_overflow_is_clamped() { let s = state(vec![node( "a", "An excessively long planner-authored node title", NodeStatus::Ready, &[], )]); let out = render_dag(&s, 100).expect("fits"); assert!(out.contains('…')); assert!(!out.contains("excessively long planner-authored")); } }