1use std::collections::{HashMap, VecDeque};
11use utf8proj_core::{DependencyType, Duration, Task, TaskId};
12
13#[derive(Debug, Clone, PartialEq)]
15pub enum GraphError {
16 CycleDetected { tasks: Vec<TaskId> },
18 MissingDependency { task: TaskId, missing: TaskId },
20}
21
22impl std::fmt::Display for GraphError {
23 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 match self {
25 GraphError::CycleDetected { tasks } => {
26 write!(f, "Cycle detected involving tasks: {:?}", tasks)
27 }
28 GraphError::MissingDependency { task, missing } => {
29 write!(
30 f,
31 "Task '{}' depends on '{}' which doesn't exist",
32 task, missing
33 )
34 }
35 }
36 }
37}
38
39impl std::error::Error for GraphError {}
40
41#[derive(Debug, Clone)]
43pub struct LeafDependency {
44 pub predecessor: String,
46 pub dep_type: DependencyType,
48 pub lag_days: i64,
50}
51
52#[derive(Debug, Clone)]
54pub struct LeafTask {
55 pub id: TaskId,
57 pub name: String,
59 pub duration_days: i64,
61 pub effort: Option<Duration>,
63 pub assigned: Vec<(String, f32)>,
65 pub wbs_path: Vec<TaskId>,
67 pub qualified_id: String,
69 pub is_milestone: bool,
71 pub complete: Option<f32>,
73 pub dependencies: Vec<LeafDependency>,
75}
76
77#[derive(Debug, Clone)]
79pub struct DependencyEdge {
80 pub from: TaskId,
82 pub to: TaskId,
84 pub dep_type: DependencyType,
86 pub lag_days: i64,
88}
89
90#[derive(Debug)]
92pub struct SchedulingGraph {
93 pub tasks: Vec<LeafTask>,
95 pub task_map: HashMap<TaskId, usize>,
97 pub successors: HashMap<TaskId, Vec<DependencyEdge>>,
99 pub predecessors: HashMap<TaskId, Vec<DependencyEdge>>,
101 pub topo_order: Vec<TaskId>,
103 pub qualified_to_simple: HashMap<String, TaskId>,
105}
106
107impl SchedulingGraph {
108 pub fn from_wbs(tasks: &[Task]) -> Result<Self, GraphError> {
110 let mut leaf_tasks = Vec::new();
111 let mut qualified_to_simple: HashMap<String, TaskId> = HashMap::new();
112
113 collect_leaves(tasks, &mut leaf_tasks, &mut qualified_to_simple, vec![], "");
115
116 let task_map: HashMap<TaskId, usize> = leaf_tasks
118 .iter()
119 .enumerate()
120 .map(|(i, t)| (t.id.clone(), i))
121 .collect();
122
123 let mut all_tasks_map: HashMap<String, Vec<TaskId>> = HashMap::new();
125 build_container_map(tasks, &mut all_tasks_map, "");
126
127 let mut successors: HashMap<TaskId, Vec<DependencyEdge>> = HashMap::new();
129 let mut predecessors: HashMap<TaskId, Vec<DependencyEdge>> = HashMap::new();
130
131 for task in &leaf_tasks {
133 successors.insert(task.id.clone(), Vec::new());
134 predecessors.insert(task.id.clone(), Vec::new());
135 }
136
137 for task in &leaf_tasks {
139 let resolved_deps =
140 resolve_task_dependencies(task, &qualified_to_simple, &all_tasks_map, &task_map)?;
141
142 for (pred_id, dep_type, lag_days) in resolved_deps {
143 let edge = DependencyEdge {
144 from: pred_id.clone(),
145 to: task.id.clone(),
146 dep_type,
147 lag_days,
148 };
149
150 successors.get_mut(&pred_id).unwrap().push(edge.clone());
151 predecessors.get_mut(&task.id).unwrap().push(edge);
152 }
153 }
154
155 let topo_order = topological_sort(&leaf_tasks, &successors)?;
157
158 Ok(Self {
159 tasks: leaf_tasks,
160 task_map,
161 successors,
162 predecessors,
163 topo_order,
164 qualified_to_simple,
165 })
166 }
167
168 pub fn get_task(&self, id: &str) -> Option<&LeafTask> {
170 self.task_map.get(id).map(|&i| &self.tasks[i])
171 }
172}
173
174fn collect_leaves(
176 tasks: &[Task],
177 leaves: &mut Vec<LeafTask>,
178 qualified_map: &mut HashMap<String, TaskId>,
179 path: Vec<TaskId>,
180 prefix: &str,
181) {
182 for task in tasks {
183 let qualified_id = if prefix.is_empty() {
184 task.id.clone()
185 } else {
186 format!("{}.{}", prefix, task.id)
187 };
188
189 let mut current_path = path.clone();
190 current_path.push(task.id.clone());
191
192 if task.children.is_empty() {
193 let duration_days = compute_duration_days(task);
195
196 let dependencies: Vec<LeafDependency> = task
198 .depends
199 .iter()
200 .map(|dep| LeafDependency {
201 predecessor: dep.predecessor.clone(),
202 dep_type: dep.dep_type,
203 lag_days: dep.lag.map(|d| d.as_days() as i64).unwrap_or(0),
204 })
205 .collect();
206
207 leaves.push(LeafTask {
208 id: task.id.clone(),
209 name: task.name.clone(),
210 duration_days,
211 effort: task.effort,
212 assigned: task
213 .assigned
214 .iter()
215 .map(|a| (a.resource_id.clone(), a.units))
216 .collect(),
217 wbs_path: current_path,
218 qualified_id: qualified_id.clone(),
219 is_milestone: task.milestone,
220 complete: task.complete,
221 dependencies,
222 });
223
224 qualified_map.insert(qualified_id, task.id.clone());
225 } else {
226 qualified_map.insert(qualified_id.clone(), task.id.clone());
228 collect_leaves(
229 &task.children,
230 leaves,
231 qualified_map,
232 current_path,
233 &qualified_id,
234 );
235 }
236 }
237}
238
239fn build_container_map(tasks: &[Task], map: &mut HashMap<String, Vec<TaskId>>, prefix: &str) {
241 for task in tasks {
242 let qualified_id = if prefix.is_empty() {
243 task.id.clone()
244 } else {
245 format!("{}.{}", prefix, task.id)
246 };
247
248 if task.children.is_empty() {
249 map.entry(qualified_id.clone())
251 .or_default()
252 .push(task.id.clone());
253
254 let mut container_path = prefix.to_string();
256 for part in prefix.split('.').filter(|s| !s.is_empty()) {
257 if container_path.is_empty() {
258 container_path = part.to_string();
259 }
260 map.entry(container_path.clone())
261 .or_default()
262 .push(task.id.clone());
263 }
264 } else {
265 build_container_map(&task.children, map, &qualified_id);
267
268 let mut all_leaves = Vec::new();
270 collect_container_leaves(&task.children, &mut all_leaves, &qualified_id);
271 map.insert(qualified_id, all_leaves);
272 }
273 }
274}
275
276fn collect_container_leaves(tasks: &[Task], leaves: &mut Vec<TaskId>, _prefix: &str) {
278 for task in tasks {
279 if task.children.is_empty() {
280 leaves.push(task.id.clone());
281 } else {
282 collect_container_leaves(&task.children, leaves, _prefix);
283 }
284 }
285}
286
287fn compute_duration_days(task: &Task) -> i64 {
289 if let Some(dur) = task.duration {
291 return dur.as_days().ceil() as i64;
292 }
293
294 if let Some(effort) = task.effort {
296 let total_units: f64 = if task.assigned.is_empty() {
297 1.0
298 } else {
299 task.assigned.iter().map(|r| r.units as f64).sum()
300 };
301
302 let effective_units = if total_units > 0.0 { total_units } else { 1.0 };
303 return (effort.as_days() / effective_units).ceil() as i64;
304 }
305
306 0
308}
309
310fn resolve_task_dependencies(
317 task: &LeafTask,
318 qualified_map: &HashMap<String, TaskId>,
319 container_map: &HashMap<String, Vec<TaskId>>,
320 task_map: &HashMap<TaskId, usize>,
321) -> Result<Vec<(TaskId, DependencyType, i64)>, GraphError> {
322 let mut resolved = Vec::new();
323
324 for dep in &task.dependencies {
325 let pred_ref = &dep.predecessor;
326
327 if task_map.contains_key(pred_ref) {
330 resolved.push((pred_ref.clone(), dep.dep_type, dep.lag_days));
331 continue;
332 }
333
334 if let Some(simple_id) = qualified_map.get(pred_ref) {
336 if task_map.contains_key(simple_id) {
337 resolved.push((simple_id.clone(), dep.dep_type, dep.lag_days));
338 continue;
339 }
340 }
341
342 if let Some(leaves) = container_map.get(pred_ref) {
344 for leaf_id in leaves {
345 if task_map.contains_key(leaf_id) {
346 resolved.push((leaf_id.clone(), dep.dep_type, dep.lag_days));
347 }
348 }
349 continue;
350 }
351
352 let container_prefix = task
355 .qualified_id
356 .rsplit_once('.')
357 .map(|(prefix, _)| prefix)
358 .unwrap_or("");
359
360 if !container_prefix.is_empty() {
361 let qualified_pred = format!("{}.{}", container_prefix, pred_ref);
362
363 if let Some(simple_id) = qualified_map.get(&qualified_pred) {
365 if task_map.contains_key(simple_id) {
366 resolved.push((simple_id.clone(), dep.dep_type, dep.lag_days));
367 continue;
368 }
369 }
370
371 if let Some(leaves) = container_map.get(&qualified_pred) {
373 for leaf_id in leaves {
374 if task_map.contains_key(leaf_id) {
375 resolved.push((leaf_id.clone(), dep.dep_type, dep.lag_days));
376 }
377 }
378 continue;
379 }
380 }
381
382 }
386
387 Ok(resolved)
388}
389
390fn topological_sort(
392 tasks: &[LeafTask],
393 successors: &HashMap<TaskId, Vec<DependencyEdge>>,
394) -> Result<Vec<TaskId>, GraphError> {
395 let mut in_degree: HashMap<TaskId, usize> = HashMap::new();
396
397 for task in tasks {
399 in_degree.insert(task.id.clone(), 0);
400 }
401
402 for edges in successors.values() {
404 for edge in edges {
405 *in_degree.get_mut(&edge.to).unwrap() += 1;
406 }
407 }
408
409 let mut queue: VecDeque<TaskId> = in_degree
411 .iter()
412 .filter(|(_, °)| deg == 0)
413 .map(|(id, _)| id.clone())
414 .collect();
415
416 let mut result: Vec<TaskId> = Vec::new();
417
418 while let Some(task_id) = queue.pop_front() {
419 result.push(task_id.clone());
420
421 if let Some(edges) = successors.get(&task_id) {
422 for edge in edges {
423 let deg = in_degree.get_mut(&edge.to).unwrap();
424 *deg -= 1;
425 if *deg == 0 {
426 queue.push_back(edge.to.clone());
427 }
428 }
429 }
430 }
431
432 if result.len() != tasks.len() {
434 let remaining: Vec<TaskId> = tasks
435 .iter()
436 .filter(|t| !result.contains(&t.id))
437 .map(|t| t.id.clone())
438 .collect();
439 return Err(GraphError::CycleDetected { tasks: remaining });
440 }
441
442 Ok(result)
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use utf8proj_core::Task;
449
450 #[test]
451 fn test_collect_leaves_flat() {
452 let tasks = vec![
453 Task::new("a").name("Task A").duration(Duration::days(5)),
454 Task::new("b").name("Task B").duration(Duration::days(3)),
455 ];
456
457 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
458
459 assert_eq!(graph.tasks.len(), 2);
460 assert!(graph.get_task("a").is_some());
461 assert!(graph.get_task("b").is_some());
462 }
463
464 #[test]
465 fn test_collect_leaves_nested() {
466 let tasks = vec![
467 Task::new("phase1")
468 .name("Phase 1")
469 .child(Task::new("a").name("Task A").duration(Duration::days(5)))
470 .child(Task::new("b").name("Task B").duration(Duration::days(3))),
471 Task::new("phase2")
472 .name("Phase 2")
473 .child(Task::new("c").name("Task C").duration(Duration::days(2))),
474 ];
475
476 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
477
478 assert_eq!(graph.tasks.len(), 3);
480 assert!(graph.get_task("a").is_some());
481 assert!(graph.get_task("b").is_some());
482 assert!(graph.get_task("c").is_some());
483
484 assert!(graph.get_task("phase1").is_none());
486 assert!(graph.get_task("phase2").is_none());
487 }
488
489 #[test]
490 fn test_qualified_id_mapping() {
491 let tasks = vec![Task::new("phase1").child(Task::new("a").duration(Duration::days(1)))];
492
493 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
494
495 let task_a = graph.get_task("a").unwrap();
496 assert_eq!(task_a.qualified_id, "phase1.a");
497 assert_eq!(task_a.wbs_path, vec!["phase1".to_string(), "a".to_string()]);
498 }
499
500 #[test]
501 fn test_topological_sort_simple() {
502 let tasks = vec![
503 Task::new("a").duration(Duration::days(1)),
504 Task::new("b").duration(Duration::days(1)),
505 ];
506
507 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
508
509 assert_eq!(graph.topo_order.len(), 2);
511 }
512
513 #[test]
514 fn test_duration_calculation_effort() {
515 let task = Task::new("work")
516 .effort(Duration::days(10))
517 .assign_with_units("dev", 0.5);
518
519 let duration = compute_duration_days(&task);
521 assert_eq!(duration, 20);
522 }
523
524 #[test]
525 fn test_duration_calculation_explicit() {
526 let task = Task::new("work").duration(Duration::days(5));
527
528 let duration = compute_duration_days(&task);
529 assert_eq!(duration, 5);
530 }
531
532 #[test]
533 fn test_graph_error_display() {
534 let err = GraphError::CycleDetected {
535 tasks: vec!["a".to_string(), "b".to_string()],
536 };
537 let msg = format!("{}", err);
538 assert!(msg.contains("Cycle"));
539 assert!(msg.contains("a"));
540
541 let err2 = GraphError::MissingDependency {
542 task: "child".to_string(),
543 missing: "parent".to_string(),
544 };
545 let msg2 = format!("{}", err2);
546 assert!(msg2.contains("child"));
547 assert!(msg2.contains("parent"));
548 }
549
550 #[test]
551 fn test_cycle_detection() {
552 let tasks = vec![
554 Task::new("a").duration(Duration::days(1)).depends_on("c"),
555 Task::new("b").duration(Duration::days(1)).depends_on("a"),
556 Task::new("c").duration(Duration::days(1)).depends_on("b"),
557 ];
558
559 let result = SchedulingGraph::from_wbs(&tasks);
560 assert!(result.is_err());
561 if let Err(GraphError::CycleDetected { tasks }) = result {
562 assert!(!tasks.is_empty());
563 } else {
564 panic!("Expected CycleDetected error");
565 }
566 }
567
568 #[test]
569 fn test_container_dependency_resolution() {
570 let tasks = vec![
572 Task::new("phase1")
573 .child(Task::new("a").duration(Duration::days(3)))
574 .child(Task::new("b").duration(Duration::days(2)).depends_on("a")),
575 Task::new("phase2").child(
576 Task::new("c")
577 .duration(Duration::days(2))
578 .depends_on("phase1.b"),
579 ),
580 ];
581
582 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
583
584 assert_eq!(graph.tasks.len(), 3);
586
587 let c = graph.get_task("c").unwrap();
589 assert!(!c.dependencies.is_empty());
590 }
591
592 #[test]
593 fn test_deeply_nested_container() {
594 let tasks = vec![Task::new("level1").child(
595 Task::new("level2")
596 .child(Task::new("level3").child(Task::new("leaf").duration(Duration::days(1)))),
597 )];
598
599 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
600
601 assert_eq!(graph.tasks.len(), 1);
603
604 let leaf = graph.get_task("leaf").unwrap();
605 assert_eq!(leaf.qualified_id, "level1.level2.level3.leaf");
606 assert_eq!(leaf.wbs_path, vec!["level1", "level2", "level3", "leaf"]);
607 }
608
609 #[test]
610 fn test_milestone_task() {
611 let task = Task::new("milestone").milestone();
612
613 let duration = compute_duration_days(&task);
614 assert_eq!(duration, 0);
615 }
616
617 #[test]
618 fn test_effort_with_no_resources() {
619 let task = Task::new("work").effort(Duration::days(5));
621
622 let duration = compute_duration_days(&task);
623 assert_eq!(duration, 5);
624 }
625
626 #[test]
627 fn test_effort_with_multiple_resources() {
628 let task = Task::new("work")
630 .effort(Duration::days(10))
631 .assign("dev1")
632 .assign("dev2");
633
634 let duration = compute_duration_days(&task);
635 assert_eq!(duration, 5);
636 }
637
638 #[test]
639 fn test_relative_dependency_resolution() {
640 let tasks = vec![Task::new("phase")
642 .child(Task::new("a").duration(Duration::days(2)))
643 .child(Task::new("b").duration(Duration::days(3)).depends_on("a"))];
644
645 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
646
647 let b = graph.get_task("b").unwrap();
649 assert_eq!(b.dependencies.len(), 1);
650 assert_eq!(b.dependencies[0].predecessor, "a");
651 }
652
653 #[test]
654 fn test_container_to_container_dependency() {
655 let tasks = vec![
657 Task::new("phase1").child(Task::new("a").duration(Duration::days(2))),
658 Task::new("phase2").child(
659 Task::new("b")
660 .duration(Duration::days(2))
661 .depends_on("phase1"),
662 ),
663 ];
664
665 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
666
667 let b = graph.get_task("b").unwrap();
669 assert!(!b.dependencies.is_empty());
670 }
671
672 #[test]
673 fn test_empty_wbs() {
674 let tasks: Vec<Task> = vec![];
675 let result = SchedulingGraph::from_wbs(&tasks);
676
677 assert!(result.is_err() || result.unwrap().tasks.is_empty());
679 }
680
681 #[test]
682 fn test_get_task_not_found() {
683 let tasks = vec![Task::new("a").duration(Duration::days(1))];
684 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
685
686 assert!(graph.get_task("nonexistent").is_none());
687 }
688
689 #[test]
690 fn test_relative_sibling_leaf_resolution() {
691 let tasks = vec![Task::new("container")
694 .child(Task::new("a").duration(Duration::days(1)))
695 .child(Task::new("b").duration(Duration::days(1)).depends_on("a"))];
696
697 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
698
699 let b = graph.get_task("b").unwrap();
700 assert_eq!(b.dependencies.len(), 1);
702 assert_eq!(b.dependencies[0].predecessor, "a");
703 }
704
705 #[test]
706 fn test_relative_sibling_container_resolution() {
707 let tasks = vec![Task::new("parent")
710 .child(Task::new("sub1").child(Task::new("a").duration(Duration::days(1))))
711 .child(
712 Task::new("sub2").child(
713 Task::new("b")
714 .duration(Duration::days(1))
715 .depends_on("sub1"),
716 ),
717 )];
718
719 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
720
721 let b = graph.get_task("b").unwrap();
723 assert!(
724 !b.dependencies.is_empty(),
725 "b should have dependencies on sub1's leaves"
726 );
727 }
728
729 #[test]
730 fn test_no_duration_no_effort_task() {
731 let task = Task::new("summary");
733
734 let duration = compute_duration_days(&task);
735 assert_eq!(duration, 0);
736 }
737}