Skip to main content

ccboard_core/graph/
task_dag.rs

1//! Task dependency graph using petgraph
2//!
3//! Provides DAG (Directed Acyclic Graph) operations for task dependencies:
4//! - Topological sort for execution order
5//! - Cycle detection for circular dependencies
6//! - Critical path analysis for bottleneck identification
7//!
8//! # Example
9//!
10//! ```
11//! use ccboard_core::graph::TaskGraph;
12//! use ccboard_core::models::plan::Task;
13//!
14//! // Create graph
15//! let mut graph = TaskGraph::new();
16//!
17//! // Add tasks
18//! let task1 = Task {
19//!     id: "T1".to_string(),
20//!     title: "Design".to_string(),
21//!     duration: Some("2h".to_string()),
22//!     ..Default::default()
23//! };
24//! let task2 = Task {
25//!     id: "T2".to_string(),
26//!     title: "Implementation".to_string(),
27//!     duration: Some("5h".to_string()),
28//!     ..Default::default()
29//! };
30//!
31//! graph.add_task(task1);
32//! graph.add_task(task2);
33//!
34//! // Add dependency: T1 must complete before T2
35//! graph.add_dependency("T1", "T2").unwrap();
36//!
37//! // Get execution order
38//! let order = graph.topological_sort().unwrap();
39//! assert_eq!(order, vec!["T1", "T2"]);
40//!
41//! // Check for cycles
42//! assert!(graph.detect_cycles().is_empty());
43//!
44//! // Find critical path
45//! let critical = graph.critical_path().unwrap();
46//! assert_eq!(critical, vec!["T1", "T2"]); // Both on critical path
47//! ```
48
49use crate::models::plan::Task;
50use anyhow::{Context, Result};
51use petgraph::graph::{DiGraph, NodeIndex};
52use petgraph::visit::EdgeRef;
53use petgraph::Direction;
54use std::collections::HashMap;
55
56/// Edge type representing task dependency relationship
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum DependencyEdge {
59    /// Task A blocks Task B (B depends on A)
60    Blocks,
61    /// Task A is blocked by Task B (A depends on B)
62    BlockedBy,
63}
64
65/// Task dependency graph
66///
67/// Wraps petgraph DiGraph to provide task-specific operations.
68/// Nodes are Tasks, edges are DependencyEdge relationships.
69pub struct TaskGraph {
70    /// Internal directed graph
71    graph: DiGraph<Task, DependencyEdge>,
72
73    /// Map from task ID to node index
74    task_index: HashMap<String, NodeIndex>,
75}
76
77impl TaskGraph {
78    /// Create a new empty task graph
79    pub fn new() -> Self {
80        Self {
81            graph: DiGraph::new(),
82            task_index: HashMap::new(),
83        }
84    }
85
86    /// Add a task to the graph
87    ///
88    /// Returns the node index for the added task.
89    pub fn add_task(&mut self, task: Task) -> NodeIndex {
90        let task_id = task.id.clone();
91        let node = self.graph.add_node(task);
92        self.task_index.insert(task_id, node);
93        node
94    }
95
96    /// Add a dependency: task_a blocks task_b (b depends on a)
97    ///
98    /// Returns error if either task ID not found.
99    pub fn add_dependency(&mut self, task_a_id: &str, task_b_id: &str) -> Result<()> {
100        let node_a = self
101            .task_index
102            .get(task_a_id)
103            .copied()
104            .context(format!("Task not found: {}", task_a_id))?;
105
106        let node_b = self
107            .task_index
108            .get(task_b_id)
109            .copied()
110            .context(format!("Task not found: {}", task_b_id))?;
111
112        self.graph.add_edge(node_a, node_b, DependencyEdge::Blocks);
113        Ok(())
114    }
115
116    /// Perform topological sort
117    ///
118    /// Returns tasks in valid execution order (dependencies first).
119    /// Returns error if graph contains cycles.
120    pub fn topological_sort(&self) -> Result<Vec<String>> {
121        use petgraph::algo::toposort;
122
123        let sorted_nodes = toposort(&self.graph, None)
124            .map_err(|cycle| anyhow::anyhow!("Cycle detected at node {:?}", cycle.node_id()))?;
125
126        let task_ids = sorted_nodes
127            .into_iter()
128            .map(|node| self.graph[node].id.clone())
129            .collect();
130
131        Ok(task_ids)
132    }
133
134    /// Detect cycles in the graph
135    ///
136    /// Returns list of cycles, where each cycle is a list of task IDs.
137    /// Empty list means no cycles (valid DAG).
138    pub fn detect_cycles(&self) -> Vec<Vec<String>> {
139        use petgraph::algo::kosaraju_scc;
140
141        let sccs = kosaraju_scc(&self.graph);
142
143        // Filter SCCs with more than one node (cycles)
144        sccs.into_iter()
145            .filter(|scc| scc.len() > 1)
146            .map(|scc| {
147                scc.into_iter()
148                    .map(|node| self.graph[node].id.clone())
149                    .collect()
150            })
151            .collect()
152    }
153
154    /// Calculate critical path (longest path in DAG)
155    ///
156    /// Returns list of task IDs on the critical path.
157    /// Critical path represents the bottleneck (minimum time to complete all tasks).
158    ///
159    /// Uses task duration estimates if available, otherwise treats all tasks as duration=1.
160    pub fn critical_path(&self) -> Result<Vec<String>> {
161        // First verify no cycles
162        if !self.detect_cycles().is_empty() {
163            return Err(anyhow::anyhow!(
164                "Cannot compute critical path: graph contains cycles"
165            ));
166        }
167
168        // Get topological order
169        let topo_order = self.topological_sort()?;
170
171        // Map task ID to node index
172        let id_to_node: HashMap<_, _> = self
173            .task_index
174            .iter()
175            .map(|(id, &node)| (id.clone(), node))
176            .collect();
177
178        // Compute longest path using dynamic programming
179        let mut distances: HashMap<NodeIndex, f64> = HashMap::new();
180        let mut predecessors: HashMap<NodeIndex, NodeIndex> = HashMap::new();
181
182        // Initialize all distances to 0
183        for node in self.graph.node_indices() {
184            distances.insert(node, 0.0);
185        }
186
187        // Process nodes in topological order
188        for task_id in topo_order {
189            let node = id_to_node[&task_id];
190            let task = &self.graph[node];
191
192            // Parse duration from task (e.g., "3-4h" → 3.5)
193            let duration = Self::parse_duration(&task.duration);
194
195            let current_distance = distances[&node];
196
197            // Update distances for all successors
198            for edge in self.graph.edges_directed(node, Direction::Outgoing) {
199                let successor = edge.target();
200                let new_distance = current_distance + duration;
201
202                if new_distance > distances[&successor] {
203                    distances.insert(successor, new_distance);
204                    predecessors.insert(successor, node);
205                }
206            }
207        }
208
209        // Find node with maximum distance (end of critical path)
210        let (&end_node, _) = distances
211            .iter()
212            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
213            .context("No tasks in graph")?;
214
215        // Backtrack to reconstruct critical path
216        let mut path = vec![end_node];
217        let mut current = end_node;
218
219        while let Some(&pred) = predecessors.get(&current) {
220            path.push(pred);
221            current = pred;
222        }
223
224        path.reverse();
225
226        // Convert node indices to task IDs
227        let critical_path_ids = path
228            .into_iter()
229            .map(|node| self.graph[node].id.clone())
230            .collect();
231
232        Ok(critical_path_ids)
233    }
234
235    /// Parse duration string to hours (e.g., "3-4h" → 3.5)
236    fn parse_duration(duration: &Option<String>) -> f64 {
237        duration
238            .as_ref()
239            .and_then(|s| {
240                // Extract first number from string like "3-4h" or "2h"
241                let num_str: String = s.chars().take_while(|c| c.is_numeric()).collect();
242                num_str.parse::<f64>().ok()
243            })
244            .unwrap_or(1.0) // Default to 1 hour if no duration
245    }
246
247    /// Get task by ID
248    pub fn get_task(&self, task_id: &str) -> Option<&Task> {
249        self.task_index.get(task_id).map(|&node| &self.graph[node])
250    }
251
252    /// Get all tasks
253    pub fn tasks(&self) -> Vec<&Task> {
254        self.graph.node_weights().collect()
255    }
256
257    /// Get dependencies for a task (tasks that this task depends on)
258    pub fn dependencies(&self, task_id: &str) -> Vec<String> {
259        self.task_index
260            .get(task_id)
261            .map(|&node| {
262                self.graph
263                    .edges_directed(node, Direction::Incoming)
264                    .map(|edge| self.graph[edge.source()].id.clone())
265                    .collect()
266            })
267            .unwrap_or_default()
268    }
269
270    /// Get dependents for a task (tasks that depend on this task)
271    pub fn dependents(&self, task_id: &str) -> Vec<String> {
272        self.task_index
273            .get(task_id)
274            .map(|&node| {
275                self.graph
276                    .edges_directed(node, Direction::Outgoing)
277                    .map(|edge| self.graph[edge.target()].id.clone())
278                    .collect()
279            })
280            .unwrap_or_default()
281    }
282
283    /// Number of tasks in graph
284    pub fn len(&self) -> usize {
285        self.graph.node_count()
286    }
287
288    /// Check if graph is empty
289    pub fn is_empty(&self) -> bool {
290        self.graph.node_count() == 0
291    }
292}
293
294impl Default for TaskGraph {
295    fn default() -> Self {
296        Self::new()
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    fn create_test_task(id: &str, title: &str, duration: Option<&str>) -> Task {
305        Task {
306            id: id.to_string(),
307            title: title.to_string(),
308            description: None,
309            priority: None,
310            duration: duration.map(|s| s.to_string()),
311            difficulty: None,
312            crate_name: None,
313            issue: None,
314        }
315    }
316
317    #[test]
318    fn test_add_task() {
319        let mut graph = TaskGraph::new();
320        let task = create_test_task("T1", "Task 1", None);
321        graph.add_task(task);
322
323        assert_eq!(graph.len(), 1);
324        assert!(graph.get_task("T1").is_some());
325    }
326
327    #[test]
328    fn test_topological_sort_simple() {
329        let mut graph = TaskGraph::new();
330
331        // T1 -> T2 -> T3
332        graph.add_task(create_test_task("T1", "Task 1", None));
333        graph.add_task(create_test_task("T2", "Task 2", None));
334        graph.add_task(create_test_task("T3", "Task 3", None));
335
336        graph.add_dependency("T1", "T2").unwrap();
337        graph.add_dependency("T2", "T3").unwrap();
338
339        let sorted = graph.topological_sort().unwrap();
340
341        // T1 must come before T2, T2 before T3
342        let t1_pos = sorted.iter().position(|id| id == "T1").unwrap();
343        let t2_pos = sorted.iter().position(|id| id == "T2").unwrap();
344        let t3_pos = sorted.iter().position(|id| id == "T3").unwrap();
345
346        assert!(t1_pos < t2_pos);
347        assert!(t2_pos < t3_pos);
348    }
349
350    #[test]
351    fn test_detect_cycles_none() {
352        let mut graph = TaskGraph::new();
353
354        graph.add_task(create_test_task("T1", "Task 1", None));
355        graph.add_task(create_test_task("T2", "Task 2", None));
356        graph.add_dependency("T1", "T2").unwrap();
357
358        let cycles = graph.detect_cycles();
359        assert!(cycles.is_empty());
360    }
361
362    #[test]
363    fn test_detect_cycles_simple() {
364        let mut graph = TaskGraph::new();
365
366        // Create cycle: T1 -> T2 -> T1
367        graph.add_task(create_test_task("T1", "Task 1", None));
368        graph.add_task(create_test_task("T2", "Task 2", None));
369        graph.add_dependency("T1", "T2").unwrap();
370        graph.add_dependency("T2", "T1").unwrap();
371
372        let cycles = graph.detect_cycles();
373        assert_eq!(cycles.len(), 1);
374        assert_eq!(cycles[0].len(), 2);
375    }
376
377    #[test]
378    fn test_parse_duration() {
379        assert_eq!(TaskGraph::parse_duration(&Some("3-4h".to_string())), 3.0);
380        assert_eq!(TaskGraph::parse_duration(&Some("2h".to_string())), 2.0);
381        assert_eq!(TaskGraph::parse_duration(&Some("10-12h".to_string())), 10.0);
382        assert_eq!(TaskGraph::parse_duration(&None), 1.0);
383    }
384}