Skip to main content

agent_teams/task/
graph.rs

1//! Dependency graph for tasks.
2//!
3//! Operates on a snapshot of `TaskFile` values to detect cycles,
4//! compute topological order, and query dependency relationships.
5
6use std::collections::{HashMap, HashSet, VecDeque};
7
8use crate::error::{Error, Result};
9use crate::models::task::{TaskFile, TaskStatus};
10
11/// A directed acyclic graph representing task dependencies.
12///
13/// Edges go from a task to the tasks it depends on (its `blocked_by` set).
14/// For example, if task B is blocked by task A, we store `B -> {A}`.
15#[derive(Debug, Clone)]
16pub struct DependencyGraph {
17    /// task_id -> set of task_ids it depends on (blocked_by)
18    deps: HashMap<String, HashSet<String>>,
19    /// Reverse index: task_id -> set of task_ids it blocks
20    rdeps: HashMap<String, HashSet<String>>,
21}
22
23impl DependencyGraph {
24    /// Build a dependency graph from a slice of tasks.
25    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            // Ensure every task has an entry even if it has no deps
31            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    /// Check whether adding an edge `task_id -> depends_on` would create a cycle.
44    ///
45    /// Uses BFS starting from `depends_on`, following existing dependency edges.
46    /// If we can reach `task_id` from `depends_on`, adding this edge would
47    /// create a cycle.
48    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        // BFS from depends_on, following its own dependencies
54        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(&current) {
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    /// Add a dependency edge: `task_id` is now blocked by `depends_on`.
76    ///
77    /// Returns an error if this would create a cycle.
78    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    /// Get the list of task IDs that `task_id` is blocked by.
99    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    /// Get the list of task IDs that `task_id` blocks.
107    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    /// Check whether a task is ready to run (all its dependencies are completed or deleted).
115    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    // -----------------------------------------------------------------------
135    // Visualization & analysis
136    // -----------------------------------------------------------------------
137
138    /// Export the dependency graph as a [Mermaid](https://mermaid.js.org/) diagram.
139    ///
140    /// Nodes are colored by status:
141    /// - Pending: light gray
142    /// - InProgress: light blue
143    /// - Completed: light green
144    /// - Deleted: light pink
145    ///
146    /// Edges represent `blocked_by` relationships: `dep --> task`.
147    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        // Sort node IDs for deterministic output
156        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        // Edges: for each task, draw dep --> task for each blocked_by
170        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    /// Export the dependency graph as a [Graphviz DOT](https://graphviz.org/) diagram.
184    ///
185    /// Same information as [`to_mermaid`](Self::to_mermaid) but in DOT syntax.
186    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    /// Compute the critical path (longest dependency chain) through the graph.
222    ///
223    /// Uses topological sort + dynamic programming:
224    /// `longest[node] = max(longest[dep] + 1)` for each dependency.
225    ///
226    /// Deleted tasks are excluded from the computation.
227    /// Returns task IDs in dependency-first order (root → leaf).
228    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        // Filter out deleted tasks
235        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        // Topological sort on active tasks only
247        // Compute in-degree considering only active tasks
248        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)| 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        // DP: compute longest path ending at each node
290        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); // minimum path length = 1 (the node itself)
295        }
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        // Find the node with the longest path
313        let end_node = match longest.iter().max_by_key(|&(_, &len)| len) {
314            Some((&node, _)) => node,
315            None => return Vec::new(),
316        };
317
318        // Backtrack to reconstruct the path
319        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    /// Render the DAG as a terminal-friendly Unicode diagram with ANSI colors.
331    ///
332    /// Groups tasks by topological level (phase) and draws connectors.
333    /// Status is shown with colored symbols:
334    /// - `○` Pending (gray), `◉` InProgress (blue), `●` Completed (green), `✗` Deleted (red)
335    ///
336    /// ```text
337    /// Task DAG ─────────────────────────────────
338    ///
339    ///   Phase 0
340    ///   ├── ● #1  CC: Concurrency analysis
341    ///   ├── ● #2  Codex: Security audit
342    ///   └── ● #3  Gemini: API design review
343    ///        │
344    ///        ▼
345    ///   Phase 1
346    ///   ├── ◉ #4  Codex: Fix proposals        ← #1, #2
347    ///   └── ○ #5  Gemini: Refactoring         ← #2, #3
348    ///        │
349    ///        ▼
350    ///   Phase 2
351    ///   └── ○ #6  CC: Final synthesis         ← #4, #5
352    /// ```
353    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        // Compute topological levels via BFS
360        let levels = self.compute_levels();
361
362        if levels.is_empty() {
363            return String::from("  (empty DAG)\n");
364        }
365
366        // Group task IDs by level, sorted within each group
367        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        // Find the critical path for highlighting
377        let cp: HashSet<String> = self.critical_path(tasks).into_iter().collect();
378
379        let mut out = String::new();
380
381        // Header
382        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            // Phase header
396            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                    // Show blocked_by as dependency hint
407                    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                    // Owner hint
415                    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            // Draw connector to next phase (if there is one)
427            if level < max_level {
428                out.push_str("       │\n");
429                out.push_str("       ▼\n");
430            }
431        }
432
433        // Critical path summary
434        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    /// Render the DAG as a plain-text terminal diagram (no ANSI colors).
447    ///
448    /// Identical layout to [`to_terminal`](Self::to_terminal) but without
449    /// escape sequences, suitable for logging or file output.
450    pub fn to_terminal_plain(&self, tasks: &[TaskFile]) -> String {
451        // Strip ANSI escape sequences from the colored version
452        let colored = self.to_terminal(tasks);
453        strip_ansi(&colored)
454    }
455
456    /// Compute the topological level of each node.
457    ///
458    /// Level 0 = roots (no dependencies); level N = max(level of deps) + 1.
459    fn compute_levels(&self) -> HashMap<String, usize> {
460        let mut levels: HashMap<String, usize> = HashMap::new();
461
462        // Topologically sorted order ensures deps are processed first
463        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    /// Compute a topological ordering of the tasks (Kahn's algorithm).
482    ///
483    /// Returns task IDs in an order where dependencies come before dependents.
484    /// Returns an error if the graph contains a cycle.
485    pub fn topological_sort(&self) -> Result<Vec<String>> {
486        // Compute in-degree for each node
487        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        // in_degree[x] = number of tasks x depends on
495        for (node, node_deps) in &self.deps {
496            *in_degree.entry(node.clone()).or_insert(0) = node_deps.len();
497        }
498
499        // Start with nodes that have no dependencies (in-degree 0)
500        let mut queue: VecDeque<String> = in_degree
501            .iter()
502            .filter(|&(_, deg)| *deg == 0)
503            .map(|(id, _)| id.clone())
504            .collect();
505
506        // Sort for deterministic output
507        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            // For each task that this node blocks, decrement its in-degree
517            if let Some(blocked) = self.rdeps.get(&node) {
518                let mut next: Vec<&String> = blocked.iter().collect();
519                next.sort(); // deterministic ordering
520                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            // There's a cycle -- find a task involved for a useful error message
533            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
548/// Map a task status to a Mermaid-compatible fill color.
549fn 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
558/// Map a task status to a Graphviz DOT fillcolor.
559fn 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
568/// Map a task status to a Unicode symbol and ANSI color code.
569///
570/// Returns `(symbol, ansi_prefix)` — caller must append `\x1b[0m` to reset.
571fn status_symbol_ansi(status: TaskStatus) -> (&'static str, &'static str) {
572    match status {
573        TaskStatus::Pending => ("○", "\x1b[90m"),     // gray
574        TaskStatus::InProgress => ("◉", "\x1b[34m"),  // blue
575        TaskStatus::Completed => ("●", "\x1b[32m"),   // green
576        TaskStatus::Deleted => ("✗", "\x1b[31m"),     // red
577    }
578}
579
580/// Strip ANSI escape sequences from a string.
581fn 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        // A -> B -> C (C blocked_by B, B blocked_by A)
628        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        // A -> B (B depends on A)
647        let tasks = vec![
648            make_task("A", vec![]),
649            make_task("B", vec!["A"]),
650        ];
651        let graph = DependencyGraph::from_tasks(&tasks);
652
653        // Adding A depends on B would create A -> B -> A
654        assert!(graph.would_create_cycle("A", "B"));
655        // Adding C depends on A is fine (C doesn't exist in graph yet, but that's ok)
656        assert!(!graph.would_create_cycle("C", "A"));
657    }
658
659    #[test]
660    fn cycle_detection_indirect() {
661        // A -> B -> C
662        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        // Adding A depends on C would create A -> B -> C -> A
670        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        //     A
684        //    / \
685        //   B   C
686        //    \ /
687        //     D
688        // D blocked_by B and C; B and C blocked_by A
689        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        // No cycles in a diamond
703        assert!(!graph.would_create_cycle("D", "A"));
704        // But A depending on D would be a cycle
705        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        // A must come first, D must come last, B and C can be in either order
798        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        // All independent -- should be sorted alphabetically for determinism
815        assert_eq!(order, vec!["A", "B", "C"]);
816    }
817
818    // -----------------------------------------------------------------------
819    // Mermaid visualization tests
820    // -----------------------------------------------------------------------
821
822    #[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        // Double quotes should be escaped so Mermaid doesn't break
873        assert!(mermaid.contains("#quot;"), "Quotes should be escaped");
874        assert!(!mermaid.contains(r#"[""#) || mermaid.contains("#quot;"));
875    }
876
877    // -----------------------------------------------------------------------
878    // DOT visualization tests
879    // -----------------------------------------------------------------------
880
881    #[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    // -----------------------------------------------------------------------
900    // Critical path tests
901    // -----------------------------------------------------------------------
902
903    #[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        //     A
919        //    / \
920        //   B   C
921        //    \ /
922        //     D
923        // Both A→B→D and A→C→D are length 3.
924        // The algorithm picks one deterministically.
925        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        // Middle element is either B or C
938        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        // All independent, so critical path is a single task
952        assert_eq!(path.len(), 1);
953    }
954
955    #[test]
956    fn critical_path_skips_deleted() {
957        // A → B(deleted) → C
958        // With B deleted, A and C are independent; critical path = 1
959        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        // B is deleted, so the chain is broken
968        assert!(!path.contains(&"B".to_string()));
969        // Path should be shorter since the chain is broken
970        assert!(path.len() <= 2);
971    }
972
973    // -----------------------------------------------------------------------
974    // Terminal rendering tests
975    // -----------------------------------------------------------------------
976
977    #[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        // Give them real subjects
988        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        // Should contain all three phases
1000        assert!(output.contains("Phase 0"));
1001        assert!(output.contains("Phase 1"));
1002        assert!(output.contains("Phase 2"));
1003
1004        // Should contain status symbols
1005        assert!(output.contains("●"), "Completed should show ●");
1006        assert!(output.contains("◉"), "InProgress should show ◉");
1007        assert!(output.contains("○"), "Pending should show ○");
1008
1009        // Should contain task subjects
1010        assert!(output.contains("CC: Concurrency"));
1011        assert!(output.contains("CC: Synthesis"));
1012
1013        // Should contain dependency hints
1014        assert!(output.contains("← 1, 2"));
1015        assert!(output.contains("← 4, 5"));
1016
1017        // Should contain critical path
1018        assert!(output.contains("Critical path"));
1019
1020        // Should contain phase connectors
1021        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        // Should not contain any ANSI escape sequences
1034        assert!(!plain.contains("\x1b"), "Plain output should have no ANSI escapes");
1035        // But should still contain the content
1036        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        // No critical path for single task
1058        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}