Skip to main content

mana/commands/
graph.rs

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
11/// Display dependency graph in ASCII, Mermaid, or DOT format
12/// Default format is ASCII (terminal-friendly visualization)
13/// Use --format mermaid for Mermaid graph TD syntax
14/// Use --format dot for Graphviz DOT format
15pub 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    // Create a set of all nodes we'll reference
31    let mut nodes = std::collections::HashSet::new();
32
33    // Output edges (dependencies)
34    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    // Add isolated nodes (units with no dependencies and no dependents)
56    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    // Check for cycles and warn
83    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    // Build lookup maps
92    let id_map: HashMap<&str, &IndexEntry> =
93        index.units.iter().map(|e| (e.id.as_str(), e)).collect();
94
95    // Build parent → children map
96    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    // Sort children by ID
107    for children in children_map.values_mut() {
108        children.sort_by(|a, b| natural_cmp(&a.id, &b.id));
109    }
110
111    // Build reverse dependency map: who depends on this unit (blockers)
112    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    // Build forward dependency map: what does this unit block
123    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    // Find root units (no parent)
134    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    // Track what we've printed to avoid duplicates
138    let mut printed: HashSet<&str> = HashSet::new();
139
140    // Render each root tree
141    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, // is_root
156        );
157    }
158
159    // Print orphan units (have parent that doesn't exist)
160    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    // Summary
180    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    // Build the node line
206    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    // Add dependency annotations
217    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                    // Only show non-parent deps (cross-cutting)
225                    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    // Get children
242    let children = children_map.get(entry.id.as_str());
243
244    // Show what this unit blocks (non-child dependencies)
245    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                // Only show if not a child of this unit
250                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    // Render children
278    if let Some(children) = children {
279        let new_prefix = if is_root {
280            String::new() // Children of root get empty prefix
281        } 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, // children are not roots
300            );
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            // Show scope warning as annotation, not blocking indicator
322            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    // Node declarations
336    for entry in &index.units {
337        println!(
338            "    \"{}\" [label=\"{}\"];",
339            entry.id,
340            entry.title.replace("\"", "\\\"")
341        );
342    }
343
344    // Edge declarations
345    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
356/// Format node ID for Mermaid (replace dots with underscores)
357fn format_node_id(id: &str) -> String {
358    format!("N{}", id.replace('.', "_"))
359}
360
361/// Escape text for Mermaid graph labels
362fn escape_for_mermaid(text: &str) -> String {
363    text.replace("\"", "&quot;")
364        .replace("[", "&lsqb;")
365        .replace("]", "&rsqb;")
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("&lsqb;"));
432        assert!(escaped.contains("&rsqb;"));
433    }
434
435    // ASCII graph tests
436
437    #[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        // Verify the full title appears in format_node output
535        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}