Skip to main content

bn/commands/
graph.rs

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