Skip to main content

nika_engine/display/
dag_render.rs

1//! DAG visualization v3 — double-line borders, arrows, status badges.
2
3use colored::Colorize;
4
5/// Task info for DAG rendering.
6pub struct DagTask {
7    pub id: String,
8    pub verb: String,
9    pub status: DagTaskStatus,
10    /// Optional metadata (duration, tokens, error) — line 1
11    pub meta: Option<String>,
12    /// Additional property tags for check mode (structured, mcp, guardrails, etc.)
13    pub tags: Vec<String>,
14}
15
16/// Task status for coloring.
17#[derive(Clone, Copy, PartialEq)]
18pub enum DagTaskStatus {
19    Pending,
20    Success,
21    Failed,
22    Skipped,
23}
24
25/// Padding inside each box (each side).
26const BOX_PAD: usize = 1;
27
28/// Render a DAG visualization with double-line borders, arrows, and status badges.
29pub fn render_dag(tasks: &[DagTask], deps: &std::collections::HashMap<String, Vec<String>>) {
30    if tasks.is_empty() {
31        return;
32    }
33
34    let layers = compute_layers(tasks, deps);
35    let edge_count: usize = deps.values().map(|v| v.len()).sum();
36
37    // Header
38    println!();
39    let task_word = if tasks.len() == 1 { "task" } else { "tasks" };
40    let layer_word = if layers.len() == 1 { "layer" } else { "layers" };
41    let edge_word = if edge_count == 1 { "edge" } else { "edges" };
42    println!(
43        "  {} {} {} {} {} {} {} {}",
44        "DAG".cyan().bold(),
45        tasks.len().to_string().white().bold(),
46        task_word,
47        "·".dimmed(),
48        layers.len().to_string().white().bold(),
49        layer_word,
50        "·".dimmed(),
51        format!("{} {}", edge_count, edge_word).white().bold(),
52    );
53    println!();
54
55    // Render layers with edges
56    for (i, layer) in layers.iter().enumerate() {
57        if i > 0 {
58            render_v3_edges(&layers[i - 1], layer, tasks, deps);
59        }
60        render_v3_boxes(layer, tasks);
61    }
62
63    println!();
64}
65
66fn compute_layers(
67    tasks: &[DagTask],
68    deps: &std::collections::HashMap<String, Vec<String>>,
69) -> Vec<Vec<String>> {
70    use std::collections::HashMap;
71
72    let mut depth: HashMap<&str, usize> = HashMap::new();
73    for t in tasks {
74        depth.insert(&t.id, 0);
75    }
76
77    // Iteratively compute max-depth
78    let mut changed = true;
79    let mut iterations = 0;
80    while changed && iterations < 100 {
81        changed = false;
82        iterations += 1;
83        for t in tasks {
84            if let Some(task_deps) = deps.get(&t.id) {
85                for dep in task_deps {
86                    if let Some(&dep_depth) = depth.get(dep.as_str()) {
87                        let new_depth = dep_depth + 1;
88                        if new_depth > depth[t.id.as_str()] {
89                            depth.insert(&t.id, new_depth);
90                            changed = true;
91                        }
92                    }
93                }
94            }
95        }
96    }
97
98    let max_depth = depth.values().copied().max().unwrap_or(0);
99    let mut layers: Vec<Vec<String>> = vec![Vec::new(); max_depth + 1];
100    for t in tasks {
101        layers[depth[t.id.as_str()]].push(t.id.clone());
102    }
103    layers
104}
105
106fn status_badge(status: DagTaskStatus) -> &'static str {
107    match status {
108        DagTaskStatus::Success => "✓",
109        DagTaskStatus::Failed => "✗",
110        DagTaskStatus::Skipped => "⊘",
111        DagTaskStatus::Pending => " ",
112    }
113}
114
115fn colorize(s: &str, status: DagTaskStatus) -> String {
116    match status {
117        DagTaskStatus::Success => s.green().to_string(),
118        DagTaskStatus::Failed => s.red().bold().to_string(),
119        DagTaskStatus::Skipped => s.yellow().dimmed().to_string(),
120        DagTaskStatus::Pending => s.dimmed().to_string(),
121    }
122}
123
124fn render_v3_boxes(layer: &[String], tasks: &[DagTask]) {
125    // Build box data
126    let boxes: Vec<(&DagTask, String, usize)> = layer
127        .iter()
128        .filter_map(|id| {
129            let task = tasks.iter().find(|t| t.id == *id)?;
130            let icon = crate::display::icons::verb_plain(&task.verb);
131            let label = format!("{} {}", icon, task.id);
132            let dw = display_width(&label);
133            Some((task, label, dw))
134        })
135        .collect();
136
137    // Top border: ╔═✓═══════════╗ (or ╔══════════════╗ for pending)
138    let mut top = String::from("    ");
139    for (i, (task, _, dw)) in boxes.iter().enumerate() {
140        if i > 0 {
141            top.push_str("  ");
142        }
143        let w = dw + BOX_PAD * 2;
144        let border = if task.status == DagTaskStatus::Pending {
145            format!("╔{}╗", "═".repeat(w))
146        } else {
147            let badge = status_badge(task.status);
148            let fill_w = w.saturating_sub(2); // -2 for badge + surrounding ═
149            format!("╔═{}═{}╗", badge, "═".repeat(fill_w.max(1)))
150        };
151        top.push_str(&colorize(&border, task.status));
152    }
153    println!("{}", top);
154
155    // Content: ║  ⚡ task_name  ║
156    let mut mid = String::from("    ");
157    for (i, (task, label, dw)) in boxes.iter().enumerate() {
158        if i > 0 {
159            mid.push_str("  ");
160        }
161        let w = dw + BOX_PAD * 2;
162        let pad_l = " ".repeat(BOX_PAD);
163        let pad_r = " ".repeat(w.saturating_sub(dw + BOX_PAD));
164        let content = format!("║{}{}{}║", pad_l, label, pad_r);
165        mid.push_str(&colorize(&content, task.status));
166    }
167    println!("{}", mid);
168
169    // Metadata line (if any task has meta)
170    let has_meta = boxes.iter().any(|(t, _, _)| t.meta.is_some());
171    if has_meta {
172        let mut meta_line = String::from("    ");
173        for (i, (task, _, dw)) in boxes.iter().enumerate() {
174            if i > 0 {
175                meta_line.push_str("  ");
176            }
177            let w = dw + BOX_PAD * 2;
178            let meta_text = task.meta.as_deref().unwrap_or("");
179            let meta_display = if meta_text.is_empty() {
180                " ".repeat(w)
181            } else {
182                let mw = display_width(meta_text);
183                let pad = w.saturating_sub(mw + BOX_PAD);
184                format!("{}{}{}", " ".repeat(BOX_PAD), meta_text, " ".repeat(pad))
185            };
186            let content = format!("║{}║", meta_display);
187            meta_line.push_str(&colorize(&content, task.status));
188        }
189        println!("{}", meta_line);
190    }
191
192    // Tag lines (if any task has tags)
193    let max_tag_count = boxes
194        .iter()
195        .map(|(t, _, _)| t.tags.len())
196        .max()
197        .unwrap_or(0);
198    for tag_idx in 0..max_tag_count {
199        let mut tag_line = String::from("    ");
200        for (i, (task, _, dw)) in boxes.iter().enumerate() {
201            if i > 0 {
202                tag_line.push_str("  ");
203            }
204            let w = dw + BOX_PAD * 2;
205            let tag_display = if let Some(tag) = task.tags.get(tag_idx) {
206                let tw = display_width(tag);
207                let pad = w.saturating_sub(tw + BOX_PAD);
208                format!("{}{}{}", " ".repeat(BOX_PAD), tag, " ".repeat(pad))
209            } else {
210                " ".repeat(w)
211            };
212            let content = format!("║{}║", tag_display);
213            tag_line.push_str(&colorize(&content, task.status));
214        }
215        println!("{}", tag_line);
216    }
217
218    // Bottom border: ╚════════╤═════╝ (with ╤ at center for edge drop)
219    let mut bottom = String::from("    ");
220    for (i, (task, _, dw)) in boxes.iter().enumerate() {
221        if i > 0 {
222            bottom.push_str("  ");
223        }
224        let w = dw + BOX_PAD * 2;
225        let border = format!("╚{}╝", "═".repeat(w));
226        bottom.push_str(&colorize(&border, task.status));
227    }
228    println!("{}", bottom);
229}
230
231fn render_v3_edges(
232    prev_layer: &[String],
233    next_layer: &[String],
234    tasks: &[DagTask],
235    deps: &std::collections::HashMap<String, Vec<String>>,
236) {
237    let prev_centers = compute_box_centers(prev_layer, tasks);
238    let next_centers = compute_box_centers(next_layer, tasks);
239
240    let max_pos = prev_centers
241        .iter()
242        .chain(next_centers.iter())
243        .map(|&(_, c, w)| c + w / 2 + 2)
244        .max()
245        .unwrap_or(40);
246
247    // Collect edges
248    let mut edges: Vec<(usize, usize)> = Vec::new();
249    for (ni, next_id) in next_layer.iter().enumerate() {
250        if let Some(task_deps) = deps.get(next_id) {
251            for dep in task_deps {
252                if let Some(pi) = prev_layer.iter().position(|p| p == dep) {
253                    edges.push((prev_centers[pi].1, next_centers[ni].1));
254                }
255            }
256        }
257    }
258
259    if edges.is_empty() {
260        println!();
261        return;
262    }
263
264    let width = max_pos + 4;
265
266    // Nearly straight down? (within 3 columns = close enough to snap)
267    let all_straight = edges.iter().all(|(f, t)| {
268        let diff = if *f > *t { f - t } else { t - f };
269        diff <= 3
270    });
271    if all_straight {
272        // Use the average of source and target for pipe position
273        let mut pipe_cols: Vec<usize> = Vec::new();
274        for &(from, to) in &edges {
275            let col = (from + to) / 2;
276            if !pipe_cols.contains(&col) {
277                pipe_cols.push(col);
278            }
279        }
280
281        let mut line = vec![' '; width];
282        for &col in &pipe_cols {
283            if col < line.len() {
284                line[col] = '│';
285            }
286        }
287        let s: String = line.iter().collect();
288        println!("    {}", s.dimmed());
289
290        let mut arrow_line = vec![' '; width];
291        for &col in &pipe_cols {
292            if col < arrow_line.len() {
293                arrow_line[col] = '▼';
294            }
295        }
296        let s2: String = arrow_line.iter().collect();
297        println!("    {}", s2.dimmed());
298        return;
299    }
300
301    // Line 1: vertical drops with downward arrows
302    let mut drop_line = vec![' '; width];
303    for &(from, _) in &edges {
304        if from < drop_line.len() {
305            drop_line[from] = '│';
306        }
307    }
308    let s1: String = drop_line.iter().collect();
309    println!("    {}", s1.dimmed());
310
311    // Line 2: horizontal connections with arrows
312    // Three-pass rendering to avoid character conflicts:
313    // Pass 1: lay down horizontal lines ─
314    // Pass 2: place arrows ▼ at targets
315    // Pass 3: place corners └ ┘ at sources
316    let mut conn_line = vec![' '; width];
317
318    // Collect all edge segments
319    struct EdgeSeg {
320        src: usize,
321        tgt: usize,
322    }
323    let mut segs: Vec<EdgeSeg> = Vec::new();
324
325    for (ni, next_id) in next_layer.iter().enumerate() {
326        let target_col = next_centers[ni].1;
327        if let Some(task_deps) = deps.get(next_id) {
328            for dep in task_deps {
329                if let Some(pi) = prev_layer.iter().position(|p| p == dep) {
330                    segs.push(EdgeSeg {
331                        src: prev_centers[pi].1,
332                        tgt: target_col,
333                    });
334                }
335            }
336        }
337    }
338
339    // Pass 1: horizontal fills
340    for seg in &segs {
341        if seg.src != seg.tgt {
342            let (lo, hi) = if seg.src < seg.tgt {
343                (seg.src, seg.tgt)
344            } else {
345                (seg.tgt, seg.src)
346            };
347            for col in lo..=hi {
348                if col < conn_line.len() && conn_line[col] == ' ' {
349                    conn_line[col] = '─';
350                }
351            }
352        }
353    }
354
355    // Pass 2: arrows at target columns
356    let mut target_cols: Vec<usize> = segs.iter().map(|s| s.tgt).collect();
357    target_cols.sort();
358    target_cols.dedup();
359    for &col in &target_cols {
360        if col < conn_line.len() {
361            conn_line[col] = '▼';
362        }
363    }
364
365    // Pass 3: corners at source columns (only if src != tgt)
366    for seg in &segs {
367        if seg.src != seg.tgt && seg.src < conn_line.len() {
368            // Don't overwrite arrows or already-placed corners
369            let existing = conn_line[seg.src];
370            if existing != '▼' && existing != '└' && existing != '┘' {
371                conn_line[seg.src] = if seg.src < seg.tgt { '└' } else { '┘' };
372            }
373        }
374    }
375
376    let s2: String = conn_line.iter().collect();
377    println!("    {}", s2.dimmed());
378}
379
380/// Calculate terminal display width of a string.
381/// Emojis are 2 columns, ASCII is 1 column.
382fn display_width(s: &str) -> usize {
383    use unicode_width::UnicodeWidthStr;
384    UnicodeWidthStr::width(s)
385}
386
387/// Compute center column position for each box in a layer.
388fn compute_box_centers(layer: &[String], tasks: &[DagTask]) -> Vec<(usize, usize, usize)> {
389    let indent = 4;
390    let gap = 2;
391    let mut positions = Vec::new();
392    let mut col = indent;
393
394    for (i, task_id) in layer.iter().enumerate() {
395        let task = tasks.iter().find(|t| t.id == *task_id);
396        let verb = task.map(|t| t.verb.as_str()).unwrap_or("exec");
397        let icon = crate::display::icons::verb_plain(verb);
398        let label = format!("{} {}", icon, task_id);
399        let dw = display_width(&label) + BOX_PAD * 2 + 2; // +2 for ║ borders
400        let center = col + dw / 2;
401        positions.push((i, center, dw));
402        col += dw + gap;
403    }
404
405    positions
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn dag_task_has_tags_field() {
414        let task = DagTask {
415            id: "test".to_string(),
416            verb: "infer".to_string(),
417            status: DagTaskStatus::Pending,
418            meta: None,
419            tags: vec!["structured".to_string(), "mcp:novanet".to_string()],
420        };
421        assert_eq!(task.tags.len(), 2);
422        assert_eq!(task.tags[0], "structured");
423        assert_eq!(task.tags[1], "mcp:novanet");
424    }
425
426    #[test]
427    fn dag_task_empty_tags() {
428        let task = DagTask {
429            id: "test".to_string(),
430            verb: "exec".to_string(),
431            status: DagTaskStatus::Success,
432            meta: Some("0.3s".to_string()),
433            tags: Vec::new(),
434        };
435        assert!(task.tags.is_empty());
436    }
437
438    #[test]
439    fn verb_plain_icons_used_in_boxes() {
440        // Verify verb_plain returns single-width characters (not multi-byte emoji)
441        let icon = crate::display::icons::verb_plain("infer");
442        assert_eq!(icon, "\u{2727}"); // checkmark-like
443        let icon = crate::display::icons::verb_plain("exec");
444        assert_eq!(icon, "\u{2388}"); // helm
445        let icon = crate::display::icons::verb_plain("fetch");
446        assert_eq!(icon, "\u{2604}"); // comet
447        let icon = crate::display::icons::verb_plain("invoke");
448        assert_eq!(icon, "\u{229B}"); // circled asterisk
449        let icon = crate::display::icons::verb_plain("agent");
450        assert_eq!(icon, "\u{274B}"); // heavy teardrop
451    }
452
453    #[test]
454    fn display_width_uses_unicode_width() {
455        // ASCII: 1 column per char
456        assert_eq!(display_width("hello"), 5);
457        // Cosmic icons are single-width
458        assert_eq!(display_width("\u{2727}"), 1);
459        assert_eq!(display_width("\u{2388}"), 1);
460    }
461}