1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
4use std::fmt::Write as _;
5use std::path::Path;
6
7use anyhow::{Result, bail};
8
9use crate::task::{Task, load_tasks_from_dir};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum DepsFormat {
14 Tree,
15 Flat,
16 Dot,
17}
18
19#[derive(Debug)]
21struct TaskNode {
22 id: u32,
23 title: String,
24 status: String,
25 priority: String,
26 depends_on: Vec<u32>,
27}
28
29impl TaskNode {
30 fn from_task(task: &Task) -> Self {
31 Self {
32 id: task.id,
33 title: task.title.clone(),
34 status: task.status.clone(),
35 priority: task.priority.clone(),
36 depends_on: task.depends_on.clone(),
37 }
38 }
39
40 fn truncated_title(&self, max_len: usize) -> String {
41 if self.title.len() <= max_len {
42 self.title.clone()
43 } else {
44 format!("{}...", &self.title[..max_len - 3])
45 }
46 }
47
48 fn status_indicator(&self) -> &str {
49 match self.status.as_str() {
50 "done" => "[x]",
51 "in-progress" => "[>]",
52 "review" => "[R]",
53 "todo" => "[ ]",
54 "backlog" => "[-]",
55 "blocked" => "[!]",
56 _ => "[?]",
57 }
58 }
59
60 fn is_incomplete(&self) -> bool {
61 self.status != "done"
62 }
63}
64
65pub fn render_deps(board_dir: &Path, format: DepsFormat) -> Result<String> {
67 let tasks_dir = board_dir.join("tasks");
68 if !tasks_dir.is_dir() {
69 bail!("no tasks directory found at {}", tasks_dir.display());
70 }
71
72 let tasks = load_tasks_from_dir(&tasks_dir)?;
73 let nodes: BTreeMap<u32, TaskNode> = tasks
74 .iter()
75 .map(|t| (t.id, TaskNode::from_task(t)))
76 .collect();
77
78 if let Some(cycle) = detect_cycle(&nodes) {
80 let cycle_str = cycle
81 .iter()
82 .map(|id| format!("#{id}"))
83 .collect::<Vec<_>>()
84 .join(" -> ");
85 bail!("dependency cycle detected: {cycle_str}");
86 }
87
88 match format {
89 DepsFormat::Tree => render_tree(&nodes),
90 DepsFormat::Flat => render_flat(&nodes),
91 DepsFormat::Dot => render_dot(&nodes),
92 }
93}
94
95fn detect_cycle(nodes: &BTreeMap<u32, TaskNode>) -> Option<Vec<u32>> {
97 let mut visited = HashSet::new();
98 let mut in_stack = HashSet::new();
99 let mut path = Vec::new();
100
101 for &id in nodes.keys() {
102 if !visited.contains(&id) {
103 if let Some(cycle) = dfs_cycle(id, nodes, &mut visited, &mut in_stack, &mut path) {
104 return Some(cycle);
105 }
106 }
107 }
108 None
109}
110
111fn dfs_cycle(
112 id: u32,
113 nodes: &BTreeMap<u32, TaskNode>,
114 visited: &mut HashSet<u32>,
115 in_stack: &mut HashSet<u32>,
116 path: &mut Vec<u32>,
117) -> Option<Vec<u32>> {
118 visited.insert(id);
119 in_stack.insert(id);
120 path.push(id);
121
122 if let Some(node) = nodes.get(&id) {
123 for &dep in &node.depends_on {
124 if !nodes.contains_key(&dep) {
125 continue; }
127 if in_stack.contains(&dep) {
128 let cycle_start = path.iter().position(|&x| x == dep).unwrap();
130 let mut cycle = path[cycle_start..].to_vec();
131 cycle.push(dep);
132 return Some(cycle);
133 }
134 if !visited.contains(&dep) {
135 if let Some(cycle) = dfs_cycle(dep, nodes, visited, in_stack, path) {
136 return Some(cycle);
137 }
138 }
139 }
140 }
141
142 path.pop();
143 in_stack.remove(&id);
144 None
145}
146
147fn render_tree(nodes: &BTreeMap<u32, TaskNode>) -> Result<String> {
149 let mut children: BTreeMap<u32, BTreeSet<u32>> = BTreeMap::new();
151 let mut has_parent = HashSet::new();
152
153 for node in nodes.values() {
154 for &dep in &node.depends_on {
155 if nodes.contains_key(&dep) {
156 children.entry(dep).or_default().insert(node.id);
157 has_parent.insert(node.id);
158 }
159 }
160 }
161
162 let roots: Vec<u32> = nodes
164 .keys()
165 .filter(|id| !has_parent.contains(id))
166 .copied()
167 .collect();
168
169 if roots.is_empty() && !nodes.is_empty() {
170 return Ok("(no root tasks found)\n".to_string());
172 }
173
174 let mut out = String::new();
175
176 let blocked = find_blocked_chains(nodes);
178 if !blocked.is_empty() {
179 writeln!(out, "Blocked chains:").unwrap();
180 for chain in &blocked {
181 let parts: Vec<String> = chain
182 .iter()
183 .map(|&id| {
184 let node = &nodes[&id];
185 format!("#{} {}", id, node.truncated_title(30))
186 })
187 .collect();
188 writeln!(out, " {} (waiting)", parts.join(" -> ")).unwrap();
189 }
190 writeln!(out).unwrap();
191 }
192
193 let critical = find_critical_path(nodes);
195 if critical.len() > 1 {
196 let parts: Vec<String> = critical.iter().map(|&id| format!("#{id}")).collect();
197 writeln!(
198 out,
199 "Critical path ({}): {}",
200 critical.len(),
201 parts.join(" -> ")
202 )
203 .unwrap();
204 writeln!(out).unwrap();
205 }
206
207 writeln!(out, "Dependency tree:").unwrap();
209 for &root in &roots {
210 render_tree_node(root, nodes, &children, &mut out, "", true);
211 }
212
213 Ok(out)
214}
215
216fn render_tree_node(
217 id: u32,
218 nodes: &BTreeMap<u32, TaskNode>,
219 children: &BTreeMap<u32, BTreeSet<u32>>,
220 out: &mut String,
221 prefix: &str,
222 is_last: bool,
223) {
224 let Some(node) = nodes.get(&id) else { return };
225
226 let connector = if prefix.is_empty() {
227 ""
228 } else if is_last {
229 "└── "
230 } else {
231 "├── "
232 };
233
234 let priority_tag = if node.priority.is_empty() {
235 String::new()
236 } else {
237 format!(" ({})", node.priority)
238 };
239
240 writeln!(
241 out,
242 "{prefix}{connector}{} #{} {}{}",
243 node.status_indicator(),
244 node.id,
245 node.truncated_title(50),
246 priority_tag,
247 )
248 .unwrap();
249
250 let child_ids: Vec<u32> = children
251 .get(&id)
252 .map(|s| s.iter().copied().collect())
253 .unwrap_or_default();
254
255 let child_prefix = if prefix.is_empty() {
256 String::new()
257 } else if is_last {
258 format!("{prefix} ")
259 } else {
260 format!("{prefix}│ ")
261 };
262
263 for (i, &child) in child_ids.iter().enumerate() {
264 let child_is_last = i == child_ids.len() - 1;
265 render_tree_node(child, nodes, children, out, &child_prefix, child_is_last);
266 }
267}
268
269fn render_flat(nodes: &BTreeMap<u32, TaskNode>) -> Result<String> {
271 let mut out = String::new();
272 let mut has_deps = false;
273
274 for node in nodes.values() {
275 for &dep in &node.depends_on {
276 if nodes.contains_key(&dep) {
277 writeln!(out, "#{} -> #{}", node.id, dep).unwrap();
278 has_deps = true;
279 }
280 }
281 }
282
283 if !has_deps {
284 writeln!(out, "(no dependencies)").unwrap();
285 }
286 Ok(out)
287}
288
289fn render_dot(nodes: &BTreeMap<u32, TaskNode>) -> Result<String> {
291 let mut out = String::new();
292 writeln!(out, "digraph deps {{").unwrap();
293 writeln!(out, " rankdir=BT;").unwrap();
294 writeln!(out, " node [shape=box];").unwrap();
295
296 for node in nodes.values() {
297 if node.depends_on.is_empty() && !nodes.values().any(|n| n.depends_on.contains(&node.id)) {
298 continue; }
300
301 let color = match node.status.as_str() {
302 "done" => "green",
303 "in-progress" => "yellow",
304 "review" => "orange",
305 "todo" | "backlog" => "white",
306 _ => "gray",
307 };
308
309 writeln!(
310 out,
311 " t{} [label=\"#{} {}\" style=filled fillcolor={}];",
312 node.id,
313 node.id,
314 node.truncated_title(30).replace('"', "\\\""),
315 color,
316 )
317 .unwrap();
318 }
319
320 writeln!(out).unwrap();
321
322 for node in nodes.values() {
323 for &dep in &node.depends_on {
324 if nodes.contains_key(&dep) {
325 writeln!(out, " t{} -> t{};", node.id, dep).unwrap();
326 }
327 }
328 }
329
330 writeln!(out, "}}").unwrap();
331 Ok(out)
332}
333
334fn find_blocked_chains(nodes: &BTreeMap<u32, TaskNode>) -> Vec<Vec<u32>> {
336 let mut chains = Vec::new();
337
338 for node in nodes.values() {
339 if !node.is_incomplete() {
340 continue;
341 }
342 for &dep in &node.depends_on {
343 if let Some(dep_node) = nodes.get(&dep) {
344 if dep_node.is_incomplete() {
345 chains.push(vec![node.id, dep]);
346 }
347 }
348 }
349 }
350
351 chains.sort();
352 chains
353}
354
355fn find_critical_path(nodes: &BTreeMap<u32, TaskNode>) -> Vec<u32> {
357 let mut memo: HashMap<u32, Vec<u32>> = HashMap::new();
358 let mut best = Vec::new();
359
360 for &id in nodes.keys() {
361 let path = longest_path(id, nodes, &mut memo);
362 if path.len() > best.len() {
363 best = path;
364 }
365 }
366
367 best
368}
369
370fn longest_path(
371 id: u32,
372 nodes: &BTreeMap<u32, TaskNode>,
373 memo: &mut HashMap<u32, Vec<u32>>,
374) -> Vec<u32> {
375 if let Some(cached) = memo.get(&id) {
376 return cached.clone();
377 }
378
379 let Some(node) = nodes.get(&id) else {
380 return Vec::new();
381 };
382
383 if !node.is_incomplete() {
384 memo.insert(id, Vec::new());
385 return Vec::new();
386 }
387
388 let mut best_child = Vec::new();
389 for &dep in &node.depends_on {
390 if nodes.contains_key(&dep) {
391 let child_path = longest_path(dep, nodes, memo);
392 if child_path.len() > best_child.len() {
393 best_child = child_path;
394 }
395 }
396 }
397
398 let mut path = vec![id];
399 path.extend(best_child);
400 memo.insert(id, path.clone());
401 path
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use std::fs;
408 use tempfile::TempDir;
409
410 fn write_task(
411 dir: &Path,
412 id: u32,
413 title: &str,
414 status: &str,
415 priority: &str,
416 depends_on: &[u32],
417 ) {
418 let deps = if depends_on.is_empty() {
419 "depends_on: []".to_string()
420 } else {
421 let items: Vec<String> = depends_on.iter().map(|d| format!(" - {d}")).collect();
422 format!("depends_on:\n{}", items.join("\n"))
423 };
424 let content = format!(
425 "---\nid: {id}\ntitle: {title}\nstatus: {status}\npriority: {priority}\n{deps}\nclass: standard\n---\n\nDescription.\n"
426 );
427 fs::write(dir.join(format!("{id:03}-task.md")), content).unwrap();
428 }
429
430 fn setup_board(tasks: &[(u32, &str, &str, &str, &[u32])]) -> TempDir {
431 let tmp = TempDir::new().unwrap();
432 let tasks_dir = tmp.path().join("tasks");
433 fs::create_dir_all(&tasks_dir).unwrap();
434 for &(id, title, status, priority, deps) in tasks {
435 write_task(&tasks_dir, id, title, status, priority, deps);
436 }
437 tmp
438 }
439
440 #[test]
441 fn empty_board() {
442 let tmp = TempDir::new().unwrap();
443 let tasks_dir = tmp.path().join("tasks");
444 fs::create_dir_all(&tasks_dir).unwrap();
445
446 let output = render_deps(tmp.path(), DepsFormat::Tree).unwrap();
447 assert!(output.contains("Dependency tree:"));
448
449 let flat = render_deps(tmp.path(), DepsFormat::Flat).unwrap();
450 assert!(flat.contains("(no dependencies)"));
451 }
452
453 #[test]
454 fn no_dependencies() {
455 let tmp = setup_board(&[
456 (1, "Task A", "todo", "high", &[]),
457 (2, "Task B", "todo", "medium", &[]),
458 ]);
459
460 let flat = render_deps(tmp.path(), DepsFormat::Flat).unwrap();
461 assert!(flat.contains("(no dependencies)"));
462
463 let tree = render_deps(tmp.path(), DepsFormat::Tree).unwrap();
464 assert!(tree.contains("#1"));
465 assert!(tree.contains("#2"));
466 }
467
468 #[test]
469 fn linear_chain() {
470 let tmp = setup_board(&[
471 (1, "Foundation", "done", "high", &[]),
472 (2, "Build on foundation", "in-progress", "high", &[1]),
473 (3, "Final step", "todo", "medium", &[2]),
474 ]);
475
476 let tree = render_deps(tmp.path(), DepsFormat::Tree).unwrap();
477 assert!(tree.contains("#1"));
478 assert!(tree.contains("#2"));
479 assert!(tree.contains("#3"));
480 assert!(tree.contains("Foundation"));
481
482 let flat = render_deps(tmp.path(), DepsFormat::Flat).unwrap();
483 assert!(flat.contains("#2 -> #1"));
484 assert!(flat.contains("#3 -> #2"));
485 }
486
487 #[test]
488 fn diamond_dependencies() {
489 let tmp = setup_board(&[
490 (1, "Root", "done", "high", &[]),
491 (2, "Left", "todo", "medium", &[1]),
492 (3, "Right", "todo", "medium", &[1]),
493 (4, "Join", "todo", "low", &[2, 3]),
494 ]);
495
496 let flat = render_deps(tmp.path(), DepsFormat::Flat).unwrap();
497 assert!(flat.contains("#2 -> #1"));
498 assert!(flat.contains("#3 -> #1"));
499 assert!(flat.contains("#4 -> #2"));
500 assert!(flat.contains("#4 -> #3"));
501 }
502
503 #[test]
504 fn cycle_detection() {
505 let tmp = setup_board(&[
506 (1, "Task A", "todo", "high", &[2]),
507 (2, "Task B", "todo", "high", &[1]),
508 ]);
509
510 let result = render_deps(tmp.path(), DepsFormat::Tree);
511 assert!(result.is_err());
512 let err = result.unwrap_err().to_string();
513 assert!(err.contains("cycle"));
514 }
515
516 #[test]
517 fn blocked_chains_shown() {
518 let tmp = setup_board(&[
519 (1, "Blocker", "todo", "high", &[]),
520 (2, "Blocked task", "todo", "medium", &[1]),
521 ]);
522
523 let tree = render_deps(tmp.path(), DepsFormat::Tree).unwrap();
524 assert!(tree.contains("Blocked chains:"));
525 assert!(tree.contains("#2"));
526 assert!(tree.contains("#1"));
527 }
528
529 #[test]
530 fn done_deps_not_blocked() {
531 let tmp = setup_board(&[
532 (1, "Done task", "done", "high", &[]),
533 (2, "Ready task", "todo", "medium", &[1]),
534 ]);
535
536 let tree = render_deps(tmp.path(), DepsFormat::Tree).unwrap();
537 assert!(!tree.contains("Blocked chains:"));
538 }
539
540 #[test]
541 fn critical_path_shown() {
542 let tmp = setup_board(&[
543 (1, "Step 1", "todo", "high", &[]),
544 (2, "Step 2", "todo", "high", &[1]),
545 (3, "Step 3", "todo", "high", &[2]),
546 ]);
547
548 let tree = render_deps(tmp.path(), DepsFormat::Tree).unwrap();
549 assert!(tree.contains("Critical path (3)"));
550 assert!(tree.contains("#3 -> #2 -> #1"));
551 }
552
553 #[test]
554 fn dot_format() {
555 let tmp = setup_board(&[
556 (1, "Root", "done", "high", &[]),
557 (2, "Child", "todo", "medium", &[1]),
558 ]);
559
560 let dot = render_deps(tmp.path(), DepsFormat::Dot).unwrap();
561 assert!(dot.contains("digraph deps {"));
562 assert!(dot.contains("t2 -> t1;"));
563 assert!(dot.contains("fillcolor=green"));
564 assert!(dot.contains("fillcolor=white"));
565 assert!(dot.contains("}"));
566 }
567
568 #[test]
569 fn title_truncation() {
570 let node = TaskNode {
571 id: 1,
572 title: "A very long task title that should be truncated to fit within limits"
573 .to_string(),
574 status: "todo".to_string(),
575 priority: "high".to_string(),
576 depends_on: vec![],
577 };
578 let truncated = node.truncated_title(50);
579 assert!(truncated.len() <= 50);
580 assert!(truncated.ends_with("..."));
581 }
582
583 #[test]
584 fn status_indicators() {
585 let statuses = vec![
586 ("done", "[x]"),
587 ("in-progress", "[>]"),
588 ("review", "[R]"),
589 ("todo", "[ ]"),
590 ("backlog", "[-]"),
591 ("blocked", "[!]"),
592 ("unknown", "[?]"),
593 ];
594
595 for (status, expected) in statuses {
596 let node = TaskNode {
597 id: 1,
598 title: "Test".to_string(),
599 status: status.to_string(),
600 priority: "".to_string(),
601 depends_on: vec![],
602 };
603 assert_eq!(node.status_indicator(), expected, "status={status}");
604 }
605 }
606
607 #[test]
608 fn missing_tasks_dir_errors() {
609 let tmp = TempDir::new().unwrap();
610 let result = render_deps(tmp.path(), DepsFormat::Tree);
611 assert!(result.is_err());
612 assert!(
613 result
614 .unwrap_err()
615 .to_string()
616 .contains("no tasks directory")
617 );
618 }
619
620 #[test]
621 fn deps_to_nonexistent_tasks_ignored() {
622 let tmp = setup_board(&[(1, "Task", "todo", "high", &[999])]);
623
624 let flat = render_deps(tmp.path(), DepsFormat::Flat).unwrap();
626 assert!(flat.contains("(no dependencies)"));
627 }
628
629 #[test]
630 fn three_node_cycle_detected() {
631 let tmp = setup_board(&[
632 (1, "A", "todo", "high", &[3]),
633 (2, "B", "todo", "high", &[1]),
634 (3, "C", "todo", "high", &[2]),
635 ]);
636
637 let result = render_deps(tmp.path(), DepsFormat::Flat);
638 assert!(result.is_err());
639 assert!(result.unwrap_err().to_string().contains("cycle"));
640 }
641}