1use crate::dag::{DependencyEdge, SchedulingGraph};
18use std::collections::HashMap;
19use utf8proj_core::{DependencyType, TaskId};
20
21#[derive(Debug, Clone, PartialEq)]
23pub enum CpmError {
24 NegativeSlack { task: TaskId, slack: i64 },
26 EmptyGraph,
28}
29
30impl std::fmt::Display for CpmError {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 match self {
33 CpmError::NegativeSlack { task, slack } => {
34 write!(
35 f,
36 "CPM invariant violated: task '{}' has negative slack ({})",
37 task, slack
38 )
39 }
40 CpmError::EmptyGraph => write!(f, "Cannot schedule empty graph"),
41 }
42 }
43}
44
45impl std::error::Error for CpmError {}
46
47#[derive(Debug, Clone)]
49pub struct CpmResult {
50 pub task_id: TaskId,
51 pub es: i64,
53 pub ef: i64,
55 pub ls: i64,
57 pub lf: i64,
59 pub total_slack: i64,
61 pub free_slack: i64,
63 pub is_critical: bool,
65 pub duration: i64,
67}
68
69#[derive(Debug)]
71pub struct CpmSchedule {
72 pub results: HashMap<TaskId, CpmResult>,
74 pub critical_path: Vec<TaskId>,
76 pub project_start: i64,
78 pub project_end: i64,
80}
81
82pub struct CpmScheduler;
84
85impl CpmScheduler {
86 pub fn new() -> Self {
87 Self
88 }
89
90 pub fn schedule(&self, graph: &SchedulingGraph) -> Result<CpmSchedule, CpmError> {
94 if graph.tasks.is_empty() {
95 return Err(CpmError::EmptyGraph);
96 }
97
98 let mut es: HashMap<TaskId, i64> = HashMap::new();
99 let mut ef: HashMap<TaskId, i64> = HashMap::new();
100 let mut ls: HashMap<TaskId, i64> = HashMap::new();
101 let mut lf: HashMap<TaskId, i64> = HashMap::new();
102
103 let duration: HashMap<TaskId, i64> = graph
105 .tasks
106 .iter()
107 .map(|t| (t.id.clone(), t.duration_days))
108 .collect();
109
110 for task_id in &graph.topo_order {
119 let task_duration = duration[task_id];
120
121 let early_start = if let Some(edges) = graph.predecessors.get(task_id) {
122 if edges.is_empty() {
123 0 } else {
125 edges
126 .iter()
127 .map(|edge| {
128 compute_successor_es(
129 edge,
130 ef[&edge.from],
131 es[&edge.from],
132 task_duration,
133 )
134 })
135 .max()
136 .unwrap_or(0)
137 }
138 } else {
139 0
140 };
141
142 let early_finish = early_start + task_duration;
143
144 es.insert(task_id.clone(), early_start);
145 ef.insert(task_id.clone(), early_finish);
146 }
147
148 let project_end = ef.values().cloned().max().unwrap_or(0);
150
151 for task_id in graph.topo_order.iter().rev() {
160 let task_duration = duration[task_id];
161
162 let late_finish = if let Some(edges) = graph.successors.get(task_id) {
163 if edges.is_empty() {
164 project_end
165 } else {
166 edges
167 .iter()
168 .map(|edge| {
169 compute_predecessor_lf(edge, ls[&edge.to], lf[&edge.to], task_duration)
170 })
171 .min()
172 .unwrap_or(project_end)
173 }
174 } else {
175 project_end
176 };
177
178 let late_start = late_finish - task_duration;
179
180 lf.insert(task_id.clone(), late_finish);
181 ls.insert(task_id.clone(), late_start);
182 }
183
184 let mut results: HashMap<TaskId, CpmResult> = HashMap::new();
193 let mut critical_path: Vec<TaskId> = Vec::new();
194
195 for task_id in &graph.topo_order {
196 let task_duration = duration[task_id];
197 let task_es = es[task_id];
198 let task_ef = ef[task_id];
199 let task_ls = ls[task_id];
200 let task_lf = lf[task_id];
201
202 let total_slack = task_ls - task_es;
203
204 if total_slack < 0 {
206 return Err(CpmError::NegativeSlack {
207 task: task_id.clone(),
208 slack: total_slack,
209 });
210 }
211
212 let free_slack = if let Some(edges) = graph.successors.get(task_id) {
214 if edges.is_empty() {
215 total_slack
216 } else {
217 edges
218 .iter()
219 .map(|edge| es[&edge.to])
220 .min()
221 .map(|min_succ_es| min_succ_es - task_ef)
222 .unwrap_or(total_slack)
223 .max(0)
224 }
225 } else {
226 total_slack
227 };
228
229 let is_critical = total_slack == 0;
230 if is_critical && task_duration > 0 {
231 critical_path.push(task_id.clone());
232 }
233
234 results.insert(
235 task_id.clone(),
236 CpmResult {
237 task_id: task_id.clone(),
238 es: task_es,
239 ef: task_ef,
240 ls: task_ls,
241 lf: task_lf,
242 total_slack,
243 free_slack,
244 is_critical,
245 duration: task_duration,
246 },
247 );
248 }
249
250 Ok(CpmSchedule {
251 results,
252 critical_path,
253 project_start: 0,
254 project_end,
255 })
256 }
257}
258
259impl Default for CpmScheduler {
260 fn default() -> Self {
261 Self::new()
262 }
263}
264
265fn compute_successor_es(
270 edge: &DependencyEdge,
271 pred_ef: i64,
272 pred_es: i64,
273 succ_duration: i64,
274) -> i64 {
275 let lag = edge.lag_days;
276
277 match edge.dep_type {
278 DependencyType::FinishToStart => {
279 pred_ef + lag
282 }
283 DependencyType::StartToStart => {
284 pred_es + lag
287 }
288 DependencyType::FinishToFinish => {
289 pred_ef + lag - succ_duration
294 }
295 DependencyType::StartToFinish => {
296 pred_es + lag - succ_duration
301 }
302 }
303}
304
305fn compute_predecessor_lf(
310 edge: &DependencyEdge,
311 succ_ls: i64,
312 succ_lf: i64,
313 pred_duration: i64,
314) -> i64 {
315 let lag = edge.lag_days;
316
317 match edge.dep_type {
318 DependencyType::FinishToStart => {
319 succ_ls - lag
322 }
323 DependencyType::StartToStart => {
324 succ_ls - lag + pred_duration
329 }
330 DependencyType::FinishToFinish => {
331 succ_lf - lag
334 }
335 DependencyType::StartToFinish => {
336 succ_lf - lag + pred_duration
341 }
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::dag::SchedulingGraph;
349 use utf8proj_core::{Duration, Task};
350
351 fn make_tasks_with_deps(tasks: &[(&str, i64, &[&str])]) -> Vec<Task> {
352 tasks
353 .iter()
354 .map(|(id, dur, deps)| {
355 let mut task = Task::new(*id).duration(Duration::days(*dur));
356 for dep in *deps {
357 task = task.depends_on(*dep);
358 }
359 task
360 })
361 .collect()
362 }
363
364 #[test]
365 fn test_single_task() {
366 let tasks = vec![Task::new("a").duration(Duration::days(5))];
367 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
368 let schedule = CpmScheduler::new().schedule(&graph).unwrap();
369
370 let a = &schedule.results["a"];
371 assert_eq!(a.es, 0);
372 assert_eq!(a.ef, 5);
373 assert_eq!(a.ls, 0);
374 assert_eq!(a.lf, 5);
375 assert_eq!(a.total_slack, 0);
376 assert!(a.is_critical);
377 assert_eq!(schedule.project_end, 5);
378 }
379
380 #[test]
381 fn test_sequential_chain() {
382 let tasks = make_tasks_with_deps(&[("a", 5, &[]), ("b", 3, &["a"]), ("c", 2, &["b"])]);
384
385 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
386 let schedule = CpmScheduler::new().schedule(&graph).unwrap();
387
388 assert_eq!(schedule.project_end, 10);
389
390 assert!(schedule.results["a"].is_critical);
392 assert!(schedule.results["b"].is_critical);
393 assert!(schedule.results["c"].is_critical);
394
395 assert_eq!(schedule.results["a"].es, 0);
397 assert_eq!(schedule.results["a"].ef, 5);
398 assert_eq!(schedule.results["b"].es, 5);
399 assert_eq!(schedule.results["b"].ef, 8);
400 assert_eq!(schedule.results["c"].es, 8);
401 assert_eq!(schedule.results["c"].ef, 10);
402 }
403
404 #[test]
405 fn test_slack_never_negative() {
406 let tasks = make_tasks_with_deps(&[
408 ("start", 0, &[]),
409 ("a", 5, &["start"]),
410 ("b", 8, &["start"]),
411 ("c", 3, &["a"]),
412 ("d", 4, &["b"]),
413 ("e", 6, &["c", "d"]),
414 ("f", 2, &["a"]),
415 ("end", 0, &["e", "f"]),
416 ]);
417
418 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
419 let schedule = CpmScheduler::new().schedule(&graph).unwrap();
420
421 for (id, result) in &schedule.results {
422 assert!(
423 result.total_slack >= 0,
424 "Task {} has negative slack: {}",
425 id,
426 result.total_slack
427 );
428 }
429 }
430
431 #[test]
432 fn test_parallel_paths_with_slack() {
433 let tasks = make_tasks_with_deps(&[("a", 5, &[]), ("b", 3, &[]), ("c", 2, &["a", "b"])]);
441
442 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
443 let schedule = CpmScheduler::new().schedule(&graph).unwrap();
444
445 assert_eq!(schedule.project_end, 7);
446
447 assert!(schedule.results["a"].is_critical);
449 assert_eq!(schedule.results["a"].total_slack, 0);
450
451 assert!(!schedule.results["b"].is_critical);
453 assert_eq!(schedule.results["b"].total_slack, 2);
454 assert_eq!(schedule.results["b"].ls, 2);
455
456 assert!(schedule.results["c"].is_critical);
458 }
459
460 #[test]
461 fn test_critical_path_has_zero_slack() {
462 let tasks = make_tasks_with_deps(&[("a", 5, &[]), ("b", 3, &[]), ("c", 2, &["a", "b"])]);
463
464 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
465 let schedule = CpmScheduler::new().schedule(&graph).unwrap();
466
467 for task_id in &schedule.critical_path {
468 let result = &schedule.results[task_id];
469 assert_eq!(
470 result.total_slack, 0,
471 "Critical task {} has non-zero slack: {}",
472 task_id, result.total_slack
473 );
474 }
475 }
476
477 #[test]
478 fn test_empty_graph_error() {
479 let tasks: Vec<Task> = vec![];
480 let graph = SchedulingGraph::from_wbs(&tasks);
481
482 assert!(graph.is_err() || graph.as_ref().unwrap().tasks.is_empty());
484
485 if let Ok(g) = graph {
486 let result = CpmScheduler::new().schedule(&g);
487 assert!(matches!(result, Err(CpmError::EmptyGraph)));
488 }
489 }
490
491 #[test]
492 fn test_cpm_error_display() {
493 let err = CpmError::NegativeSlack {
494 task: "test_task".to_string(),
495 slack: -5,
496 };
497 let msg = format!("{}", err);
498 assert!(msg.contains("test_task"));
499 assert!(msg.contains("-5"));
500
501 let err2 = CpmError::EmptyGraph;
502 let msg2 = format!("{}", err2);
503 assert!(msg2.contains("empty"));
504 }
505
506 #[test]
507 fn test_cpm_scheduler_default() {
508 let scheduler = CpmScheduler::default();
509 let tasks = vec![Task::new("a").duration(Duration::days(1))];
511 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
512 let schedule = scheduler.schedule(&graph);
513 assert!(schedule.is_ok());
514 }
515
516 #[test]
517 fn test_dependency_type_coverage() {
518 use utf8proj_core::Dependency;
519
520 let tasks_ss = vec![
525 Task::new("a").duration(Duration::days(5)),
526 Task::new("b").duration(Duration::days(3)),
527 Task::new("c")
528 .duration(Duration::days(2))
529 .depends_on("a") .with_dependency(Dependency {
531 predecessor: "b".to_string(),
532 dep_type: DependencyType::StartToStart,
533 lag: None,
534 }),
535 ];
536 let graph = SchedulingGraph::from_wbs(&tasks_ss).unwrap();
537 let schedule = CpmScheduler::new().schedule(&graph).unwrap();
538 assert!(schedule.results.contains_key("c"));
539
540 let tasks_ff = vec![
542 Task::new("a").duration(Duration::days(5)),
543 Task::new("b").duration(Duration::days(3)),
544 Task::new("c")
545 .duration(Duration::days(2))
546 .depends_on("a") .with_dependency(Dependency {
548 predecessor: "b".to_string(),
549 dep_type: DependencyType::FinishToFinish,
550 lag: None,
551 }),
552 ];
553 let graph = SchedulingGraph::from_wbs(&tasks_ff).unwrap();
554 let schedule = CpmScheduler::new().schedule(&graph).unwrap();
555 assert!(schedule.results.contains_key("c"));
556
557 let tasks_sf = vec![
559 Task::new("a").duration(Duration::days(5)),
560 Task::new("b").duration(Duration::days(3)),
561 Task::new("c")
562 .duration(Duration::days(2))
563 .depends_on("a") .with_dependency(Dependency {
565 predecessor: "b".to_string(),
566 dep_type: DependencyType::StartToFinish,
567 lag: None,
568 }),
569 ];
570 let graph = SchedulingGraph::from_wbs(&tasks_sf).unwrap();
571 let schedule = CpmScheduler::new().schedule(&graph).unwrap();
572 assert!(schedule.results.contains_key("c"));
573 }
574
575 #[test]
576 fn test_dependency_with_lag() {
577 use utf8proj_core::Dependency;
578
579 let tasks = vec![
581 Task::new("a").duration(Duration::days(5)),
582 Task::new("b").duration(Duration::days(3)),
583 Task::new("c")
584 .duration(Duration::days(2))
585 .depends_on("a")
586 .with_dependency(Dependency {
587 predecessor: "b".to_string(),
588 dep_type: DependencyType::StartToStart,
589 lag: Some(Duration::days(2)),
590 }),
591 ];
592
593 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
594 let schedule = CpmScheduler::new().schedule(&graph).unwrap();
595
596 assert!(schedule.results.contains_key("c"));
598 assert!(schedule.project_end > 0);
599 }
600
601 #[test]
602 fn test_free_slack_calculation() {
603 let tasks = make_tasks_with_deps(&[("a", 5, &[]), ("b", 3, &[]), ("c", 2, &["a", "b"])]);
607
608 let graph = SchedulingGraph::from_wbs(&tasks).unwrap();
609 let schedule = CpmScheduler::new().schedule(&graph).unwrap();
610
611 assert_eq!(schedule.results["b"].free_slack, 2);
615
616 assert_eq!(schedule.results["a"].free_slack, 0);
618 }
619}