ccboard_core/graph/
task_dag.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum DependencyEdge {
59 Blocks,
61 BlockedBy,
63}
64
65pub struct TaskGraph {
70 graph: DiGraph<Task, DependencyEdge>,
72
73 task_index: HashMap<String, NodeIndex>,
75}
76
77impl TaskGraph {
78 pub fn new() -> Self {
80 Self {
81 graph: DiGraph::new(),
82 task_index: HashMap::new(),
83 }
84 }
85
86 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 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 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 pub fn detect_cycles(&self) -> Vec<Vec<String>> {
139 use petgraph::algo::kosaraju_scc;
140
141 let sccs = kosaraju_scc(&self.graph);
142
143 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 pub fn critical_path(&self) -> Result<Vec<String>> {
161 if !self.detect_cycles().is_empty() {
163 return Err(anyhow::anyhow!(
164 "Cannot compute critical path: graph contains cycles"
165 ));
166 }
167
168 let topo_order = self.topological_sort()?;
170
171 let id_to_node: HashMap<_, _> = self
173 .task_index
174 .iter()
175 .map(|(id, &node)| (id.clone(), node))
176 .collect();
177
178 let mut distances: HashMap<NodeIndex, f64> = HashMap::new();
180 let mut predecessors: HashMap<NodeIndex, NodeIndex> = HashMap::new();
181
182 for node in self.graph.node_indices() {
184 distances.insert(node, 0.0);
185 }
186
187 for task_id in topo_order {
189 let node = id_to_node[&task_id];
190 let task = &self.graph[node];
191
192 let duration = Self::parse_duration(&task.duration);
194
195 let current_distance = distances[&node];
196
197 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 let (&end_node, _) = distances
211 .iter()
212 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
213 .context("No tasks in graph")?;
214
215 let mut path = vec![end_node];
217 let mut current = end_node;
218
219 while let Some(&pred) = predecessors.get(¤t) {
220 path.push(pred);
221 current = pred;
222 }
223
224 path.reverse();
225
226 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 fn parse_duration(duration: &Option<String>) -> f64 {
237 duration
238 .as_ref()
239 .and_then(|s| {
240 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) }
246
247 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 pub fn tasks(&self) -> Vec<&Task> {
254 self.graph.node_weights().collect()
255 }
256
257 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 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 pub fn len(&self) -> usize {
285 self.graph.node_count()
286 }
287
288 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 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 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 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}