1use std::collections::{HashMap, HashSet, VecDeque};
7
8use crate::error::{Error, Result};
9use crate::models::task::{TaskFile, TaskStatus};
10
11#[derive(Debug, Clone)]
16pub struct DependencyGraph {
17 deps: HashMap<String, HashSet<String>>,
19 rdeps: HashMap<String, HashSet<String>>,
21}
22
23impl DependencyGraph {
24 pub fn from_tasks(tasks: &[TaskFile]) -> Self {
26 let mut deps: HashMap<String, HashSet<String>> = HashMap::new();
27 let mut rdeps: HashMap<String, HashSet<String>> = HashMap::new();
28
29 for task in tasks {
30 deps.entry(task.id.clone()).or_default();
32 rdeps.entry(task.id.clone()).or_default();
33
34 for dep in &task.blocked_by {
35 deps.entry(task.id.clone()).or_default().insert(dep.clone());
36 rdeps.entry(dep.clone()).or_default().insert(task.id.clone());
37 }
38 }
39
40 Self { deps, rdeps }
41 }
42
43 pub fn would_create_cycle(&self, task_id: &str, depends_on: &str) -> bool {
49 if task_id == depends_on {
50 return true;
51 }
52
53 let mut visited = HashSet::new();
55 let mut queue = VecDeque::new();
56 queue.push_back(depends_on.to_string());
57 visited.insert(depends_on.to_string());
58
59 while let Some(current) = queue.pop_front() {
60 if let Some(current_deps) = self.deps.get(¤t) {
61 for dep in current_deps {
62 if dep == task_id {
63 return true;
64 }
65 if visited.insert(dep.clone()) {
66 queue.push_back(dep.clone());
67 }
68 }
69 }
70 }
71
72 false
73 }
74
75 pub fn add_dependency(&mut self, task_id: &str, depends_on: &str) -> Result<()> {
79 if self.would_create_cycle(task_id, depends_on) {
80 return Err(Error::CycleDetected {
81 from: task_id.to_string(),
82 to: depends_on.to_string(),
83 });
84 }
85
86 self.deps
87 .entry(task_id.to_string())
88 .or_default()
89 .insert(depends_on.to_string());
90 self.rdeps
91 .entry(depends_on.to_string())
92 .or_default()
93 .insert(task_id.to_string());
94
95 Ok(())
96 }
97
98 pub fn get_blocked_by(&self, task_id: &str) -> Vec<String> {
100 self.deps
101 .get(task_id)
102 .map(|s| s.iter().cloned().collect())
103 .unwrap_or_default()
104 }
105
106 pub fn get_blocks(&self, task_id: &str) -> Vec<String> {
108 self.rdeps
109 .get(task_id)
110 .map(|s| s.iter().cloned().collect())
111 .unwrap_or_default()
112 }
113
114 pub fn is_ready(&self, task_id: &str, tasks: &[TaskFile]) -> bool {
116 let blocked_by = match self.deps.get(task_id) {
117 Some(deps) if !deps.is_empty() => deps,
118 _ => return true,
119 };
120
121 let status_map: HashMap<&str, TaskStatus> = tasks
122 .iter()
123 .map(|t| (t.id.as_str(), t.status))
124 .collect();
125
126 blocked_by.iter().all(|dep_id| {
127 matches!(
128 status_map.get(dep_id.as_str()),
129 Some(TaskStatus::Completed) | Some(TaskStatus::Deleted)
130 )
131 })
132 }
133
134 pub fn to_mermaid(&self, tasks: &[TaskFile]) -> String {
148 let mut out = String::from("graph TD\n");
149
150 let task_map: HashMap<&str, &TaskFile> = tasks
151 .iter()
152 .map(|t| (t.id.as_str(), t))
153 .collect();
154
155 let mut node_ids: Vec<&str> = self.deps.keys().map(|s| s.as_str()).collect();
157 node_ids.sort();
158
159 for id in &node_ids {
160 if let Some(task) = task_map.get(id) {
161 let escaped_subject = task.subject.replace('"', "#quot;");
162 let color = status_color(task.status);
163 out.push_str(&format!(
164 " {id}[\"{escaped_subject}\"]\n style {id} fill:{color}\n"
165 ));
166 }
167 }
168
169 for id in &node_ids {
171 if let Some(deps) = self.deps.get(*id) {
172 let mut sorted_deps: Vec<&str> = deps.iter().map(|s| s.as_str()).collect();
173 sorted_deps.sort();
174 for dep in sorted_deps {
175 out.push_str(&format!(" {dep} --> {id}\n"));
176 }
177 }
178 }
179
180 out
181 }
182
183 pub fn to_dot(&self, tasks: &[TaskFile]) -> String {
187 let mut out = String::from("digraph tasks {\n rankdir=LR;\n");
188
189 let task_map: HashMap<&str, &TaskFile> = tasks
190 .iter()
191 .map(|t| (t.id.as_str(), t))
192 .collect();
193
194 let mut node_ids: Vec<&str> = self.deps.keys().map(|s| s.as_str()).collect();
195 node_ids.sort();
196
197 for id in &node_ids {
198 if let Some(task) = task_map.get(id) {
199 let escaped_subject = task.subject.replace('"', "\\\"");
200 let color = status_color_dot(task.status);
201 out.push_str(&format!(
202 " {id} [label=\"{escaped_subject}\" style=filled fillcolor=\"{color}\"];\n"
203 ));
204 }
205 }
206
207 for id in &node_ids {
208 if let Some(deps) = self.deps.get(*id) {
209 let mut sorted_deps: Vec<&str> = deps.iter().map(|s| s.as_str()).collect();
210 sorted_deps.sort();
211 for dep in sorted_deps {
212 out.push_str(&format!(" {dep} -> {id};\n"));
213 }
214 }
215 }
216
217 out.push_str("}\n");
218 out
219 }
220
221 pub fn critical_path(&self, tasks: &[TaskFile]) -> Vec<String> {
229 let status_map: HashMap<&str, TaskStatus> = tasks
230 .iter()
231 .map(|t| (t.id.as_str(), t.status))
232 .collect();
233
234 let active_ids: HashSet<&str> = self
236 .deps
237 .keys()
238 .map(|s| s.as_str())
239 .filter(|id| !matches!(status_map.get(id), Some(TaskStatus::Deleted)))
240 .collect();
241
242 if active_ids.is_empty() {
243 return Vec::new();
244 }
245
246 let mut in_degree: HashMap<&str, usize> = HashMap::new();
249 for &id in &active_ids {
250 in_degree.insert(id, 0);
251 }
252 for &id in &active_ids {
253 if let Some(deps) = self.deps.get(id) {
254 let active_dep_count = deps.iter().filter(|d| active_ids.contains(d.as_str())).count();
255 in_degree.insert(id, active_dep_count);
256 }
257 }
258
259 let mut queue: VecDeque<&str> = in_degree
260 .iter()
261 .filter(|&(_, °)| deg == 0)
262 .map(|(&id, _)| id)
263 .collect();
264 let mut sorted_queue: Vec<&str> = queue.drain(..).collect();
265 sorted_queue.sort();
266 queue.extend(sorted_queue);
267
268 let mut topo_order: Vec<&str> = Vec::new();
269 while let Some(node) = queue.pop_front() {
270 topo_order.push(node);
271 if let Some(blocked) = self.rdeps.get(node) {
272 let mut next: Vec<&str> = blocked
273 .iter()
274 .map(|s| s.as_str())
275 .filter(|s| active_ids.contains(s))
276 .collect();
277 next.sort();
278 for dep in next {
279 if let Some(deg) = in_degree.get_mut(dep) {
280 *deg -= 1;
281 if *deg == 0 {
282 queue.push_back(dep);
283 }
284 }
285 }
286 }
287 }
288
289 let mut longest: HashMap<&str, usize> = HashMap::new();
291 let mut predecessor: HashMap<&str, &str> = HashMap::new();
292
293 for &node in &topo_order {
294 longest.insert(node, 1); }
296
297 for &node in &topo_order {
298 if let Some(blocked) = self.rdeps.get(node) {
299 for dependent in blocked.iter().map(|s| s.as_str()) {
300 if !active_ids.contains(dependent) {
301 continue;
302 }
303 let new_len = longest[node] + 1;
304 if new_len > *longest.get(dependent).unwrap_or(&0) {
305 longest.insert(dependent, new_len);
306 predecessor.insert(dependent, node);
307 }
308 }
309 }
310 }
311
312 let end_node = match longest.iter().max_by_key(|&(_, &len)| len) {
314 Some((&node, _)) => node,
315 None => return Vec::new(),
316 };
317
318 let mut path = vec![end_node];
320 let mut current = end_node;
321 while let Some(&prev) = predecessor.get(current) {
322 path.push(prev);
323 current = prev;
324 }
325
326 path.reverse();
327 path.into_iter().map(String::from).collect()
328 }
329
330 pub fn to_terminal(&self, tasks: &[TaskFile]) -> String {
354 let task_map: HashMap<&str, &TaskFile> = tasks
355 .iter()
356 .map(|t| (t.id.as_str(), t))
357 .collect();
358
359 let levels = self.compute_levels();
361
362 if levels.is_empty() {
363 return String::from(" (empty DAG)\n");
364 }
365
366 let max_level = levels.values().copied().max().unwrap_or(0);
368 let mut level_groups: Vec<Vec<&str>> = vec![Vec::new(); max_level + 1];
369 for (id, &level) in &levels {
370 level_groups[level].push(id.as_str());
371 }
372 for group in &mut level_groups {
373 group.sort();
374 }
375
376 let cp: HashSet<String> = self.critical_path(tasks).into_iter().collect();
378
379 let mut out = String::new();
380
381 let total = tasks.len();
383 let completed = tasks.iter().filter(|t| t.status == TaskStatus::Completed).count();
384 let in_progress = tasks.iter().filter(|t| t.status == TaskStatus::InProgress).count();
385 out.push_str(&format!(
386 " Task DAG \x1b[90m({total} tasks: {completed} done, {in_progress} active)\x1b[0m\n"
387 ));
388 out.push_str(" ─────────────────────────────────────────────\n");
389
390 for (level, group) in level_groups.iter().enumerate() {
391 if group.is_empty() {
392 continue;
393 }
394
395 out.push_str(&format!("\n \x1b[1mPhase {level}\x1b[0m\n"));
397
398 for (i, &id) in group.iter().enumerate() {
399 let is_last = i == group.len() - 1;
400 let connector = if is_last { "└──" } else { "├──" };
401
402 if let Some(task) = task_map.get(id) {
403 let (symbol, color) = status_symbol_ansi(task.status);
404 let on_cp = if cp.contains(id) { " \x1b[33m★\x1b[0m" } else { "" };
405
406 let deps_hint = if task.blocked_by.is_empty() {
408 String::new()
409 } else {
410 let dep_ids: Vec<&str> = task.blocked_by.iter().map(|s| s.as_str()).collect();
411 format!(" \x1b[90m← {}\x1b[0m", dep_ids.join(", "))
412 };
413
414 let owner_hint = task.owner.as_deref()
416 .map(|o| format!(" \x1b[90m@{o}\x1b[0m"))
417 .unwrap_or_default();
418
419 out.push_str(&format!(
420 " {connector} {color}{symbol}\x1b[0m \x1b[1m#{id}\x1b[0m {subject}{on_cp}{deps_hint}{owner_hint}\n",
421 subject = task.subject,
422 ));
423 }
424 }
425
426 if level < max_level {
428 out.push_str(" │\n");
429 out.push_str(" ▼\n");
430 }
431 }
432
433 if cp.len() > 1 {
435 let cp_ordered = self.critical_path(tasks);
436 let cp_str: Vec<String> = cp_ordered.iter().map(|id| format!("#{id}")).collect();
437 out.push_str(&format!(
438 "\n \x1b[33m★ Critical path:\x1b[0m {}\n",
439 cp_str.join(" → ")
440 ));
441 }
442
443 out
444 }
445
446 pub fn to_terminal_plain(&self, tasks: &[TaskFile]) -> String {
451 let colored = self.to_terminal(tasks);
453 strip_ansi(&colored)
454 }
455
456 fn compute_levels(&self) -> HashMap<String, usize> {
460 let mut levels: HashMap<String, usize> = HashMap::new();
461
462 if let Ok(topo) = self.topological_sort() {
464 for id in &topo {
465 let level = if let Some(deps) = self.deps.get(id) {
466 deps.iter()
467 .filter_map(|d| levels.get(d))
468 .max()
469 .map(|max_dep| max_dep + 1)
470 .unwrap_or(0)
471 } else {
472 0
473 };
474 levels.insert(id.clone(), level);
475 }
476 }
477
478 levels
479 }
480
481 pub fn topological_sort(&self) -> Result<Vec<String>> {
486 let mut in_degree: HashMap<String, usize> = HashMap::new();
488 for node in self.deps.keys() {
489 in_degree.entry(node.clone()).or_insert(0);
490 }
491 for node in self.rdeps.keys() {
492 in_degree.entry(node.clone()).or_insert(0);
493 }
494 for (node, node_deps) in &self.deps {
496 *in_degree.entry(node.clone()).or_insert(0) = node_deps.len();
497 }
498
499 let mut queue: VecDeque<String> = in_degree
501 .iter()
502 .filter(|&(_, deg)| *deg == 0)
503 .map(|(id, _)| id.clone())
504 .collect();
505
506 let mut sorted_queue: Vec<String> = queue.drain(..).collect();
508 sorted_queue.sort();
509 queue.extend(sorted_queue);
510
511 let mut result = Vec::new();
512
513 while let Some(node) = queue.pop_front() {
514 result.push(node.clone());
515
516 if let Some(blocked) = self.rdeps.get(&node) {
518 let mut next: Vec<&String> = blocked.iter().collect();
519 next.sort(); for dependent in next {
521 if let Some(deg) = in_degree.get_mut(dependent) {
522 *deg -= 1;
523 if *deg == 0 {
524 queue.push_back(dependent.clone());
525 }
526 }
527 }
528 }
529 }
530
531 if result.len() != in_degree.len() {
532 let in_cycle: Vec<_> = in_degree
534 .iter()
535 .filter(|&(_, deg)| *deg > 0)
536 .map(|(id, _)| id.clone())
537 .collect();
538 return Err(Error::CycleDetected {
539 from: in_cycle.first().cloned().unwrap_or_default(),
540 to: "...".into(),
541 });
542 }
543
544 Ok(result)
545 }
546}
547
548fn status_color(status: TaskStatus) -> &'static str {
550 match status {
551 TaskStatus::Pending => "#D3D3D3",
552 TaskStatus::InProgress => "#87CEEB",
553 TaskStatus::Completed => "#90EE90",
554 TaskStatus::Deleted => "#FFB6C1",
555 }
556}
557
558fn status_color_dot(status: TaskStatus) -> &'static str {
560 match status {
561 TaskStatus::Pending => "#D3D3D3",
562 TaskStatus::InProgress => "#87CEEB",
563 TaskStatus::Completed => "#90EE90",
564 TaskStatus::Deleted => "#FFB6C1",
565 }
566}
567
568fn status_symbol_ansi(status: TaskStatus) -> (&'static str, &'static str) {
572 match status {
573 TaskStatus::Pending => ("○", "\x1b[90m"), TaskStatus::InProgress => ("◉", "\x1b[34m"), TaskStatus::Completed => ("●", "\x1b[32m"), TaskStatus::Deleted => ("✗", "\x1b[31m"), }
578}
579
580fn strip_ansi(s: &str) -> String {
582 let mut out = String::with_capacity(s.len());
583 let mut in_escape = false;
584 for ch in s.chars() {
585 if ch == '\x1b' {
586 in_escape = true;
587 continue;
588 }
589 if in_escape {
590 if ch == 'm' {
591 in_escape = false;
592 }
593 continue;
594 }
595 out.push(ch);
596 }
597 out
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use crate::models::task::TaskFile;
604
605 fn make_task(id: &str, blocked_by: Vec<&str>) -> TaskFile {
606 TaskFile {
607 id: id.to_string(),
608 subject: format!("Task {id}"),
609 description: None,
610 active_form: None,
611 status: TaskStatus::Pending,
612 owner: None,
613 blocks: vec![],
614 blocked_by: blocked_by.into_iter().map(String::from).collect(),
615 metadata: None,
616 }
617 }
618
619 fn make_task_with_status(id: &str, status: TaskStatus, blocked_by: Vec<&str>) -> TaskFile {
620 let mut t = make_task(id, blocked_by);
621 t.status = status;
622 t
623 }
624
625 #[test]
626 fn simple_dag() {
627 let tasks = vec![
629 make_task("A", vec![]),
630 make_task("B", vec!["A"]),
631 make_task("C", vec!["B"]),
632 ];
633 let graph = DependencyGraph::from_tasks(&tasks);
634
635 assert_eq!(graph.get_blocked_by("C"), vec!["B".to_string()]);
636 assert_eq!(graph.get_blocked_by("B"), vec!["A".to_string()]);
637 assert!(graph.get_blocked_by("A").is_empty());
638
639 assert!(graph.get_blocks("A").contains(&"B".to_string()));
640 assert!(graph.get_blocks("B").contains(&"C".to_string()));
641 assert!(graph.get_blocks("C").is_empty());
642 }
643
644 #[test]
645 fn cycle_detection_direct() {
646 let tasks = vec![
648 make_task("A", vec![]),
649 make_task("B", vec!["A"]),
650 ];
651 let graph = DependencyGraph::from_tasks(&tasks);
652
653 assert!(graph.would_create_cycle("A", "B"));
655 assert!(!graph.would_create_cycle("C", "A"));
657 }
658
659 #[test]
660 fn cycle_detection_indirect() {
661 let tasks = vec![
663 make_task("A", vec![]),
664 make_task("B", vec!["A"]),
665 make_task("C", vec!["B"]),
666 ];
667 let graph = DependencyGraph::from_tasks(&tasks);
668
669 assert!(graph.would_create_cycle("A", "C"));
671 }
672
673 #[test]
674 fn self_reference_is_cycle() {
675 let tasks = vec![make_task("A", vec![])];
676 let graph = DependencyGraph::from_tasks(&tasks);
677
678 assert!(graph.would_create_cycle("A", "A"));
679 }
680
681 #[test]
682 fn diamond_dependencies() {
683 let tasks = vec![
690 make_task("A", vec![]),
691 make_task("B", vec!["A"]),
692 make_task("C", vec!["A"]),
693 make_task("D", vec!["B", "C"]),
694 ];
695 let graph = DependencyGraph::from_tasks(&tasks);
696
697 let d_deps = graph.get_blocked_by("D");
698 assert!(d_deps.contains(&"B".to_string()));
699 assert!(d_deps.contains(&"C".to_string()));
700 assert_eq!(d_deps.len(), 2);
701
702 assert!(!graph.would_create_cycle("D", "A"));
704 assert!(graph.would_create_cycle("A", "D"));
706 }
707
708 #[test]
709 fn add_dependency_ok() {
710 let tasks = vec![
711 make_task("A", vec![]),
712 make_task("B", vec![]),
713 ];
714 let mut graph = DependencyGraph::from_tasks(&tasks);
715
716 graph.add_dependency("B", "A").unwrap();
717 assert!(graph.get_blocked_by("B").contains(&"A".to_string()));
718 }
719
720 #[test]
721 fn add_dependency_cycle_rejected() {
722 let tasks = vec![
723 make_task("A", vec![]),
724 make_task("B", vec!["A"]),
725 ];
726 let mut graph = DependencyGraph::from_tasks(&tasks);
727
728 let err = graph.add_dependency("A", "B").unwrap_err();
729 assert!(err.to_string().contains("cycle"));
730 }
731
732 #[test]
733 fn is_ready_no_deps() {
734 let tasks = vec![make_task("A", vec![])];
735 let graph = DependencyGraph::from_tasks(&tasks);
736
737 assert!(graph.is_ready("A", &tasks));
738 }
739
740 #[test]
741 fn is_ready_deps_completed() {
742 let tasks = vec![
743 make_task_with_status("A", TaskStatus::Completed, vec![]),
744 make_task("B", vec!["A"]),
745 ];
746 let graph = DependencyGraph::from_tasks(&tasks);
747
748 assert!(graph.is_ready("B", &tasks));
749 }
750
751 #[test]
752 fn is_ready_deps_pending() {
753 let tasks = vec![
754 make_task("A", vec![]),
755 make_task("B", vec!["A"]),
756 ];
757 let graph = DependencyGraph::from_tasks(&tasks);
758
759 assert!(!graph.is_ready("B", &tasks));
760 }
761
762 #[test]
763 fn is_ready_deps_deleted() {
764 let tasks = vec![
765 make_task_with_status("A", TaskStatus::Deleted, vec![]),
766 make_task("B", vec!["A"]),
767 ];
768 let graph = DependencyGraph::from_tasks(&tasks);
769
770 assert!(graph.is_ready("B", &tasks));
771 }
772
773 #[test]
774 fn topological_sort_linear() {
775 let tasks = vec![
776 make_task("A", vec![]),
777 make_task("B", vec!["A"]),
778 make_task("C", vec!["B"]),
779 ];
780 let graph = DependencyGraph::from_tasks(&tasks);
781
782 let order = graph.topological_sort().unwrap();
783 assert_eq!(order, vec!["A", "B", "C"]);
784 }
785
786 #[test]
787 fn topological_sort_diamond() {
788 let tasks = vec![
789 make_task("A", vec![]),
790 make_task("B", vec!["A"]),
791 make_task("C", vec!["A"]),
792 make_task("D", vec!["B", "C"]),
793 ];
794 let graph = DependencyGraph::from_tasks(&tasks);
795
796 let order = graph.topological_sort().unwrap();
797 assert_eq!(order[0], "A");
799 assert_eq!(order[3], "D");
800 assert!(order[1] == "B" || order[1] == "C");
801 assert!(order[2] == "B" || order[2] == "C");
802 }
803
804 #[test]
805 fn topological_sort_independent() {
806 let tasks = vec![
807 make_task("A", vec![]),
808 make_task("B", vec![]),
809 make_task("C", vec![]),
810 ];
811 let graph = DependencyGraph::from_tasks(&tasks);
812
813 let order = graph.topological_sort().unwrap();
814 assert_eq!(order, vec!["A", "B", "C"]);
816 }
817
818 #[test]
823 fn mermaid_simple_chain() {
824 let tasks = vec![
825 make_task("A", vec![]),
826 make_task("B", vec!["A"]),
827 make_task("C", vec!["B"]),
828 ];
829 let graph = DependencyGraph::from_tasks(&tasks);
830 let mermaid = graph.to_mermaid(&tasks);
831
832 assert!(mermaid.starts_with("graph TD\n"));
833 assert!(mermaid.contains("A[\"Task A\"]"));
834 assert!(mermaid.contains("B[\"Task B\"]"));
835 assert!(mermaid.contains("C[\"Task C\"]"));
836 assert!(mermaid.contains("A --> B"));
837 assert!(mermaid.contains("B --> C"));
838 }
839
840 #[test]
841 fn mermaid_with_status_colors() {
842 let tasks = vec![
843 make_task_with_status("A", TaskStatus::Completed, vec![]),
844 make_task_with_status("B", TaskStatus::InProgress, vec!["A"]),
845 make_task_with_status("C", TaskStatus::Pending, vec!["B"]),
846 ];
847 let graph = DependencyGraph::from_tasks(&tasks);
848 let mermaid = graph.to_mermaid(&tasks);
849
850 assert!(mermaid.contains("fill:#90EE90"), "Completed should be green");
851 assert!(mermaid.contains("fill:#87CEEB"), "InProgress should be blue");
852 assert!(mermaid.contains("fill:#D3D3D3"), "Pending should be gray");
853 }
854
855 #[test]
856 fn mermaid_empty_graph() {
857 let tasks: Vec<TaskFile> = vec![];
858 let graph = DependencyGraph::from_tasks(&tasks);
859 let mermaid = graph.to_mermaid(&tasks);
860
861 assert_eq!(mermaid, "graph TD\n");
862 }
863
864 #[test]
865 fn mermaid_escapes_quotes() {
866 let mut task = make_task("A", vec![]);
867 task.subject = r#"Task with "quotes""#.to_string();
868 let tasks = vec![task];
869 let graph = DependencyGraph::from_tasks(&tasks);
870 let mermaid = graph.to_mermaid(&tasks);
871
872 assert!(mermaid.contains("#quot;"), "Quotes should be escaped");
874 assert!(!mermaid.contains(r#"[""#) || mermaid.contains("#quot;"));
875 }
876
877 #[test]
882 fn dot_simple_chain() {
883 let tasks = vec![
884 make_task("A", vec![]),
885 make_task("B", vec!["A"]),
886 make_task("C", vec!["B"]),
887 ];
888 let graph = DependencyGraph::from_tasks(&tasks);
889 let dot = graph.to_dot(&tasks);
890
891 assert!(dot.starts_with("digraph tasks {"));
892 assert!(dot.contains("rankdir=LR"));
893 assert!(dot.contains("A [label=\"Task A\""));
894 assert!(dot.contains("A -> B;"));
895 assert!(dot.contains("B -> C;"));
896 assert!(dot.ends_with("}\n"));
897 }
898
899 #[test]
904 fn critical_path_linear() {
905 let tasks = vec![
906 make_task("A", vec![]),
907 make_task("B", vec!["A"]),
908 make_task("C", vec!["B"]),
909 ];
910 let graph = DependencyGraph::from_tasks(&tasks);
911 let path = graph.critical_path(&tasks);
912
913 assert_eq!(path, vec!["A", "B", "C"]);
914 }
915
916 #[test]
917 fn critical_path_diamond() {
918 let tasks = vec![
926 make_task("A", vec![]),
927 make_task("B", vec!["A"]),
928 make_task("C", vec!["A"]),
929 make_task("D", vec!["B", "C"]),
930 ];
931 let graph = DependencyGraph::from_tasks(&tasks);
932 let path = graph.critical_path(&tasks);
933
934 assert_eq!(path.len(), 3);
935 assert_eq!(path[0], "A");
936 assert_eq!(path[2], "D");
937 assert!(path[1] == "B" || path[1] == "C");
939 }
940
941 #[test]
942 fn critical_path_independent() {
943 let tasks = vec![
944 make_task("A", vec![]),
945 make_task("B", vec![]),
946 make_task("C", vec![]),
947 ];
948 let graph = DependencyGraph::from_tasks(&tasks);
949 let path = graph.critical_path(&tasks);
950
951 assert_eq!(path.len(), 1);
953 }
954
955 #[test]
956 fn critical_path_skips_deleted() {
957 let tasks = vec![
960 make_task("A", vec![]),
961 make_task_with_status("B", TaskStatus::Deleted, vec!["A"]),
962 make_task("C", vec!["B"]),
963 ];
964 let graph = DependencyGraph::from_tasks(&tasks);
965 let path = graph.critical_path(&tasks);
966
967 assert!(!path.contains(&"B".to_string()));
969 assert!(path.len() <= 2);
971 }
972
973 #[test]
978 fn terminal_diamond_dag() {
979 let tasks = vec![
980 make_task_with_status("1", TaskStatus::Completed, vec![]),
981 make_task_with_status("2", TaskStatus::Completed, vec![]),
982 make_task_with_status("3", TaskStatus::Completed, vec![]),
983 make_task_with_status("4", TaskStatus::InProgress, vec!["1", "2"]),
984 make_task_with_status("5", TaskStatus::InProgress, vec!["2", "3"]),
985 make_task("6", vec!["4", "5"]),
986 ];
987 let mut tasks = tasks;
989 tasks[0].subject = "CC: Concurrency".into();
990 tasks[1].subject = "Codex: Security".into();
991 tasks[2].subject = "Gemini: API".into();
992 tasks[3].subject = "Codex: Fixes".into();
993 tasks[4].subject = "Gemini: Refactor".into();
994 tasks[5].subject = "CC: Synthesis".into();
995
996 let graph = DependencyGraph::from_tasks(&tasks);
997 let output = graph.to_terminal(&tasks);
998
999 assert!(output.contains("Phase 0"));
1001 assert!(output.contains("Phase 1"));
1002 assert!(output.contains("Phase 2"));
1003
1004 assert!(output.contains("●"), "Completed should show ●");
1006 assert!(output.contains("◉"), "InProgress should show ◉");
1007 assert!(output.contains("○"), "Pending should show ○");
1008
1009 assert!(output.contains("CC: Concurrency"));
1011 assert!(output.contains("CC: Synthesis"));
1012
1013 assert!(output.contains("← 1, 2"));
1015 assert!(output.contains("← 4, 5"));
1016
1017 assert!(output.contains("Critical path"));
1019
1020 assert!(output.contains("▼"));
1022 }
1023
1024 #[test]
1025 fn terminal_plain_strips_ansi() {
1026 let tasks = vec![
1027 make_task("A", vec![]),
1028 make_task("B", vec!["A"]),
1029 ];
1030 let graph = DependencyGraph::from_tasks(&tasks);
1031 let plain = graph.to_terminal_plain(&tasks);
1032
1033 assert!(!plain.contains("\x1b"), "Plain output should have no ANSI escapes");
1035 assert!(plain.contains("Phase 0"));
1037 assert!(plain.contains("Task A"));
1038 }
1039
1040 #[test]
1041 fn terminal_empty_graph() {
1042 let tasks: Vec<TaskFile> = vec![];
1043 let graph = DependencyGraph::from_tasks(&tasks);
1044 let output = graph.to_terminal(&tasks);
1045
1046 assert!(output.contains("empty DAG"));
1047 }
1048
1049 #[test]
1050 fn terminal_single_task() {
1051 let tasks = vec![make_task("1", vec![])];
1052 let graph = DependencyGraph::from_tasks(&tasks);
1053 let output = graph.to_terminal(&tasks);
1054
1055 assert!(output.contains("Phase 0"));
1056 assert!(output.contains("#1"));
1057 assert!(!output.contains("Critical path"));
1059 }
1060
1061 #[test]
1062 fn terminal_with_owners() {
1063 let mut tasks = vec![make_task("1", vec![])];
1064 tasks[0].owner = Some("alice".into());
1065 let graph = DependencyGraph::from_tasks(&tasks);
1066 let plain = graph.to_terminal_plain(&tasks);
1067
1068 assert!(plain.contains("@alice"));
1069 }
1070
1071 #[test]
1072 fn strip_ansi_works() {
1073 let input = "\x1b[32m●\x1b[0m hello \x1b[1mbold\x1b[0m";
1074 let stripped = strip_ansi(input);
1075 assert_eq!(stripped, "● hello bold");
1076 }
1077}