1use colored::Colorize;
4
5pub struct DagTask {
7 pub id: String,
8 pub verb: String,
9 pub status: DagTaskStatus,
10 pub meta: Option<String>,
12 pub tags: Vec<String>,
14}
15
16#[derive(Clone, Copy, PartialEq)]
18pub enum DagTaskStatus {
19 Pending,
20 Success,
21 Failed,
22 Skipped,
23}
24
25const BOX_PAD: usize = 1;
27
28pub 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 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 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 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 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 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); format!("╔═{}═{}╗", badge, "═".repeat(fill_w.max(1)))
150 };
151 top.push_str(&colorize(&border, task.status));
152 }
153 println!("{}", top);
154
155 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 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 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 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 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 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 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 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 let mut conn_line = vec![' '; width];
317
318 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 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 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 for seg in &segs {
367 if seg.src != seg.tgt && seg.src < conn_line.len() {
368 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
380fn display_width(s: &str) -> usize {
383 use unicode_width::UnicodeWidthStr;
384 UnicodeWidthStr::width(s)
385}
386
387fn 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; 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 let icon = crate::display::icons::verb_plain("infer");
442 assert_eq!(icon, "\u{2727}"); let icon = crate::display::icons::verb_plain("exec");
444 assert_eq!(icon, "\u{2388}"); let icon = crate::display::icons::verb_plain("fetch");
446 assert_eq!(icon, "\u{2604}"); let icon = crate::display::icons::verb_plain("invoke");
448 assert_eq!(icon, "\u{229B}"); let icon = crate::display::icons::verb_plain("agent");
450 assert_eq!(icon, "\u{274B}"); }
452
453 #[test]
454 fn display_width_uses_unicode_width() {
455 assert_eq!(display_width("hello"), 5);
457 assert_eq!(display_width("\u{2727}"), 1);
459 assert_eq!(display_width("\u{2388}"), 1);
460 }
461}