1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use anyhow::Result;
5
6use crate::blocking::check_blocked;
7use crate::index::{Index, IndexEntry};
8use crate::unit::Status;
9use crate::util::natural_cmp;
10
11pub fn cmd_graph(mana_dir: &Path, format: &str) -> Result<()> {
16 let index = Index::load_or_rebuild(mana_dir)?;
17
18 match format {
19 "mermaid" => output_mermaid_graph(&index)?,
20 "dot" => output_dot_graph(&index)?,
21 _ => output_ascii_graph(&index)?,
22 }
23
24 Ok(())
25}
26
27fn output_mermaid_graph(index: &Index) -> Result<()> {
28 println!("graph TD");
29
30 let mut nodes = std::collections::HashSet::new();
32
33 for entry in &index.units {
35 for dep_id in &entry.dependencies {
36 println!(
37 " {}[{}] --> {}[{}]",
38 format_node_id(&entry.id),
39 escape_for_mermaid(&entry.title),
40 format_node_id(dep_id),
41 escape_for_mermaid(
42 index
43 .units
44 .iter()
45 .find(|e| &e.id == dep_id)
46 .map(|e| e.title.as_str())
47 .unwrap_or(dep_id)
48 )
49 );
50 nodes.insert(entry.id.clone());
51 nodes.insert(dep_id.clone());
52 }
53 }
54
55 for entry in &index.units {
57 if entry.dependencies.is_empty()
58 && !index
59 .units
60 .iter()
61 .any(|e| e.dependencies.contains(&entry.id))
62 && !nodes.contains(&entry.id)
63 {
64 println!(
65 " {}[{}]",
66 format_node_id(&entry.id),
67 escape_for_mermaid(&entry.title)
68 );
69 }
70 }
71
72 Ok(())
73}
74
75fn output_ascii_graph(index: &Index) -> Result<()> {
76 if index.units.is_empty() {
77 println!("Empty graph");
78 println!("\n→ 0 units, 0 dependencies");
79 return Ok(());
80 }
81
82 let cycles = crate::graph::find_all_cycles(index)?;
84 if !cycles.is_empty() {
85 eprintln!(
86 "⚠ Warning: {} dependency cycle(s). Run 'mana dep cycles' for details.",
87 cycles.len()
88 );
89 }
90
91 let id_map: HashMap<&str, &IndexEntry> =
93 index.units.iter().map(|e| (e.id.as_str(), e)).collect();
94
95 let mut children_map: HashMap<&str, Vec<&IndexEntry>> = HashMap::new();
97 for entry in &index.units {
98 if let Some(ref parent_id) = entry.parent {
99 children_map
100 .entry(parent_id.as_str())
101 .or_default()
102 .push(entry);
103 }
104 }
105
106 for children in children_map.values_mut() {
108 children.sort_by(|a, b| natural_cmp(&a.id, &b.id));
109 }
110
111 let mut blocked_by: HashMap<&str, Vec<&str>> = HashMap::new();
113 for entry in &index.units {
114 for dep_id in &entry.dependencies {
115 blocked_by
116 .entry(entry.id.as_str())
117 .or_default()
118 .push(dep_id.as_str());
119 }
120 }
121
122 let mut blocks: HashMap<&str, Vec<&str>> = HashMap::new();
124 for entry in &index.units {
125 for dep_id in &entry.dependencies {
126 blocks
127 .entry(dep_id.as_str())
128 .or_default()
129 .push(entry.id.as_str());
130 }
131 }
132
133 let mut roots: Vec<&IndexEntry> = index.units.iter().filter(|e| e.parent.is_none()).collect();
135 roots.sort_by(|a, b| natural_cmp(&a.id, &b.id));
136
137 let mut printed: HashSet<&str> = HashSet::new();
139
140 for (i, root) in roots.iter().enumerate() {
142 if i > 0 {
143 println!();
144 }
145 render_tree(
146 root,
147 &children_map,
148 &blocked_by,
149 &blocks,
150 &id_map,
151 index,
152 &mut printed,
153 "",
154 true,
155 true, );
157 }
158
159 let orphans: Vec<&IndexEntry> = index
161 .units
162 .iter()
163 .filter(|e| {
164 e.parent.is_some()
165 && !id_map.contains_key(e.parent.as_ref().unwrap().as_str())
166 && !printed.contains(e.id.as_str())
167 })
168 .collect();
169
170 if !orphans.is_empty() {
171 println!("\n┌─ Orphans (missing parent)");
172 for orphan in orphans {
173 println!("│ {}", format_node(orphan, index));
174 printed.insert(&orphan.id);
175 }
176 println!("└─");
177 }
178
179 let dep_count: usize = index.units.iter().map(|e| e.dependencies.len()).sum();
181 println!(
182 "\n→ {} units, {} dependencies",
183 index.units.len(),
184 dep_count
185 );
186
187 Ok(())
188}
189
190#[allow(clippy::too_many_arguments)]
191fn render_tree<'a>(
192 entry: &'a IndexEntry,
193 children_map: &HashMap<&str, Vec<&'a IndexEntry>>,
194 blocked_by: &HashMap<&str, Vec<&str>>,
195 blocks: &HashMap<&str, Vec<&str>>,
196 id_map: &HashMap<&str, &IndexEntry>,
197 index: &Index,
198 printed: &mut HashSet<&'a str>,
199 prefix: &str,
200 is_last: bool,
201 is_root: bool,
202) {
203 printed.insert(&entry.id);
204
205 let connector = if is_root {
207 ""
208 } else if is_last {
209 "└── "
210 } else {
211 "├── "
212 };
213
214 let node_str = format_node(entry, index);
215
216 let deps_annotation = if let Some(deps) = blocked_by.get(entry.id.as_str()) {
218 if deps.is_empty() {
219 String::new()
220 } else {
221 let dep_list: Vec<&str> = deps
222 .iter()
223 .filter(|d| {
224 entry.parent.as_deref() != Some(**d)
226 })
227 .copied()
228 .collect();
229 if dep_list.is_empty() {
230 String::new()
231 } else {
232 format!(" ◄── {}", dep_list.join(", "))
233 }
234 }
235 } else {
236 String::new()
237 };
238
239 println!("{}{}{}{}", prefix, connector, node_str, deps_annotation);
240
241 let children = children_map.get(entry.id.as_str());
243
244 if let Some(blocked_list) = blocks.get(entry.id.as_str()) {
246 let non_child_blocks: Vec<&str> = blocked_list
247 .iter()
248 .filter(|b| {
249 if let Some(blocked_entry) = id_map.get(*b) {
251 blocked_entry.parent.as_deref() != Some(&entry.id)
252 } else {
253 true
254 }
255 })
256 .copied()
257 .collect();
258
259 if !non_child_blocks.is_empty() {
260 let child_prefix = if is_root {
261 if children.is_some() && !children.unwrap().is_empty() {
262 "│ "
263 } else {
264 " "
265 }
266 } else if is_last {
267 &format!("{} ", prefix)
268 } else {
269 &format!("{}│ ", prefix)
270 };
271
272 let blocks_str = non_child_blocks.join(", ");
273 println!("{}──► blocks {}", child_prefix, blocks_str);
274 }
275 }
276
277 if let Some(children) = children {
279 let new_prefix = if is_root {
280 String::new() } else if is_last {
282 format!("{} ", prefix)
283 } else {
284 format!("{}│ ", prefix)
285 };
286
287 for (i, child) in children.iter().enumerate() {
288 let child_is_last = i == children.len() - 1;
289 render_tree(
290 child,
291 children_map,
292 blocked_by,
293 blocks,
294 id_map,
295 index,
296 printed,
297 &new_prefix,
298 child_is_last,
299 false, );
301 }
302 }
303}
304
305fn format_node(entry: &IndexEntry, index: &Index) -> String {
306 let status_icon = match entry.status {
307 Status::Closed => "[✓]",
308 Status::InProgress | Status::AwaitingVerify => "[●]",
309 Status::Open => {
310 if check_blocked(entry, index).is_some() {
311 "[!]"
312 } else {
313 "[ ]"
314 }
315 }
316 };
317
318 let suffix = match check_blocked(entry, index) {
319 Some(reason) => format!(" ({})", reason),
320 None => {
321 crate::blocking::check_scope_warning(entry)
323 .map(|w| format!(" (⚠ {})", w))
324 .unwrap_or_default()
325 }
326 };
327
328 format!("{} {} {}{}", status_icon, entry.id, entry.title, suffix)
329}
330
331fn output_dot_graph(index: &Index) -> Result<()> {
332 println!("digraph {{");
333 println!(" rankdir=LR;");
334
335 for entry in &index.units {
337 println!(
338 " \"{}\" [label=\"{}\"];",
339 entry.id,
340 entry.title.replace("\"", "\\\"")
341 );
342 }
343
344 for entry in &index.units {
346 for dep_id in &entry.dependencies {
347 println!(" \"{}\" -> \"{}\";", entry.id, dep_id);
348 }
349 }
350
351 println!("}}");
352
353 Ok(())
354}
355
356fn format_node_id(id: &str) -> String {
358 format!("N{}", id.replace('.', "_"))
359}
360
361fn escape_for_mermaid(text: &str) -> String {
363 text.replace("\"", """)
364 .replace("[", "[")
365 .replace("]", "]")
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use crate::unit::Unit;
372 use std::fs;
373 use tempfile::TempDir;
374
375 fn setup_test_units() -> (TempDir, std::path::PathBuf) {
376 let dir = TempDir::new().unwrap();
377 let mana_dir = dir.path().join(".mana");
378 fs::create_dir(&mana_dir).unwrap();
379
380 let unit1 = Unit::new("1", "Task one");
381 let unit2 = Unit::new("2", "Task two");
382 let mut unit3 = Unit::new("3", "Task three");
383 unit3.dependencies = vec!["1".to_string(), "2".to_string()];
384
385 unit1.to_file(mana_dir.join("1.yaml")).unwrap();
386 unit2.to_file(mana_dir.join("2.yaml")).unwrap();
387 unit3.to_file(mana_dir.join("3.yaml")).unwrap();
388
389 (dir, mana_dir)
390 }
391
392 #[test]
393 fn mermaid_output_valid() {
394 let (_dir, mana_dir) = setup_test_units();
395 let result = cmd_graph(&mana_dir, "mermaid");
396 assert!(result.is_ok());
397 }
398
399 #[test]
400 fn dot_output_valid() {
401 let (_dir, mana_dir) = setup_test_units();
402 let result = cmd_graph(&mana_dir, "dot");
403 assert!(result.is_ok());
404 }
405
406 #[test]
407 fn ascii_output_valid() {
408 let (_dir, mana_dir) = setup_test_units();
409 let result = cmd_graph(&mana_dir, "ascii");
410 assert!(result.is_ok());
411 }
412
413 #[test]
414 fn default_format_is_ascii() {
415 let (_dir, mana_dir) = setup_test_units();
416 let result = cmd_graph(&mana_dir, "");
417 assert!(result.is_ok());
418 }
419
420 #[test]
421 fn escaping_special_chars() {
422 let id = "test.id";
423 let formatted = format_node_id(id);
424 assert_eq!(formatted, "Ntest_id");
425 }
426
427 #[test]
428 fn mermaid_escape() {
429 let text = "Task [with] brackets";
430 let escaped = escape_for_mermaid(text);
431 assert!(escaped.contains("["));
432 assert!(escaped.contains("]"));
433 }
434
435 #[test]
438 fn ascii_with_empty_graph() {
439 let dir = TempDir::new().unwrap();
440 let mana_dir = dir.path().join(".mana");
441 fs::create_dir(&mana_dir).unwrap();
442
443 let result = cmd_graph(&mana_dir, "ascii");
444 assert!(result.is_ok());
445 }
446
447 #[test]
448 fn ascii_with_single_isolated_unit() {
449 let dir = TempDir::new().unwrap();
450 let mana_dir = dir.path().join(".mana");
451 fs::create_dir(&mana_dir).unwrap();
452
453 let unit = Unit::new("1", "Single task");
454 unit.to_file(mana_dir.join("1.yaml")).unwrap();
455
456 let result = cmd_graph(&mana_dir, "ascii");
457 assert!(result.is_ok());
458 }
459
460 #[test]
461 fn ascii_with_multiple_isolated_units() {
462 let dir = TempDir::new().unwrap();
463 let mana_dir = dir.path().join(".mana");
464 fs::create_dir(&mana_dir).unwrap();
465
466 let unit1 = Unit::new("1", "Task one");
467 let unit2 = Unit::new("2", "Task two");
468 let unit3 = Unit::new("3", "Task three");
469
470 unit1.to_file(mana_dir.join("1.yaml")).unwrap();
471 unit2.to_file(mana_dir.join("2.yaml")).unwrap();
472 unit3.to_file(mana_dir.join("3.yaml")).unwrap();
473
474 let result = cmd_graph(&mana_dir, "ascii");
475 assert!(result.is_ok());
476 }
477
478 #[test]
479 fn ascii_with_diamond_dependencies() {
480 let dir = TempDir::new().unwrap();
481 let mana_dir = dir.path().join(".mana");
482 fs::create_dir(&mana_dir).unwrap();
483
484 let unit1 = Unit::new("1", "Root");
485 let mut unit2 = Unit::new("2", "Left branch");
486 let mut unit3 = Unit::new("3", "Right branch");
487 let mut unit4 = Unit::new("4", "Merge");
488
489 unit2.dependencies = vec!["1".to_string()];
490 unit3.dependencies = vec!["1".to_string()];
491 unit4.dependencies = vec!["2".to_string(), "3".to_string()];
492
493 unit1.to_file(mana_dir.join("1.yaml")).unwrap();
494 unit2.to_file(mana_dir.join("2.yaml")).unwrap();
495 unit3.to_file(mana_dir.join("3.yaml")).unwrap();
496 unit4.to_file(mana_dir.join("4.yaml")).unwrap();
497
498 let result = cmd_graph(&mana_dir, "ascii");
499 assert!(result.is_ok());
500 }
501
502 #[test]
503 fn ascii_with_cycle_warning() {
504 let dir = TempDir::new().unwrap();
505 let mana_dir = dir.path().join(".mana");
506 fs::create_dir(&mana_dir).unwrap();
507
508 let mut unit1 = Unit::new("1", "Task one");
509 let mut unit2 = Unit::new("2", "Task two");
510 let mut unit3 = Unit::new("3", "Task three");
511
512 unit1.dependencies = vec!["2".to_string()];
513 unit2.dependencies = vec!["3".to_string()];
514 unit3.dependencies = vec!["1".to_string()];
515
516 unit1.to_file(mana_dir.join("1.yaml")).unwrap();
517 unit2.to_file(mana_dir.join("2.yaml")).unwrap();
518 unit3.to_file(mana_dir.join("3.yaml")).unwrap();
519
520 let result = cmd_graph(&mana_dir, "ascii");
521 assert!(result.is_ok());
522 }
523
524 #[test]
525 fn ascii_long_title_not_truncated() {
526 let dir = TempDir::new().unwrap();
527 let mana_dir = dir.path().join(".mana");
528 fs::create_dir(&mana_dir).unwrap();
529
530 let long_title = "This is a very long title that should not be truncated";
531 let unit = Unit::new("1", long_title);
532 unit.to_file(mana_dir.join("1.yaml")).unwrap();
533
534 let index = Index::load_or_rebuild(&mana_dir).unwrap();
536 let node = format_node(&index.units[0], &index);
537 assert!(
538 node.contains(long_title),
539 "Full title should appear in graph node"
540 );
541 }
542
543 #[test]
544 fn ascii_status_badges() {
545 let dir = TempDir::new().unwrap();
546 let mana_dir = dir.path().join(".mana");
547 fs::create_dir(&mana_dir).unwrap();
548
549 let unit1 = Unit::new("1", "Open task");
550 let mut unit2 = Unit::new("2", "In progress task");
551 let mut unit3 = Unit::new("3", "Closed task");
552
553 unit2.status = Status::InProgress;
554 unit3.status = Status::Closed;
555
556 unit1.to_file(mana_dir.join("1.yaml")).unwrap();
557 unit2.to_file(mana_dir.join("2.yaml")).unwrap();
558 unit3.to_file(mana_dir.join("3.yaml")).unwrap();
559
560 let result = cmd_graph(&mana_dir, "ascii");
561 assert!(result.is_ok());
562 }
563}