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
10pub 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 let mut nodes = std::collections::HashSet::new();
31
32 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 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 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 let id_map: HashMap<&str, &IndexEntry> =
92 index.beans.iter().map(|e| (e.id.as_str(), e)).collect();
93
94 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 for children in children_map.values_mut() {
107 children.sort_by(|a, b| natural_cmp(&a.id, &b.id));
108 }
109
110 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 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 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 let mut printed: HashSet<&str> = HashSet::new();
138
139 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, );
155 }
156
157 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 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 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 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 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 let children = children_map.get(entry.id.as_str());
240
241 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 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 if let Some(children) = children {
276 let new_prefix = if is_root {
277 String::new() } 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, );
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 for entry in &index.beans {
317 println!(
318 " \"{}\" [label=\"{}\"];",
319 entry.id,
320 entry.title.replace("\"", "\\\"")
321 );
322 }
323
324 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
336fn format_node_id(id: &str) -> String {
338 format!("N{}", id.replace('.', "_"))
339}
340
341fn escape_for_mermaid(text: &str) -> String {
343 text.replace("\"", """)
344 .replace("[", "[")
345 .replace("]", "]")
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("["));
412 assert!(escaped.contains("]"));
413 }
414
415 #[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 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}