utf8proj_solver/
dag.rs

1//! Dependency graph construction and validation
2//!
3//! This module handles the critical transformation from hierarchical WBS
4//! to flat DAG suitable for CPM scheduling.
5//!
6//! Key principle: The WBS (Work Breakdown Structure) is for PRESENTATION.
7//! The DAG (Directed Acyclic Graph) is for SCHEDULING.
8//! These must be completely separated.
9
10use std::collections::{HashMap, VecDeque};
11use utf8proj_core::{DependencyType, Duration, Task, TaskId};
12
13/// Errors during graph construction
14#[derive(Debug, Clone, PartialEq)]
15pub enum GraphError {
16    /// Cycle detected in dependencies
17    CycleDetected { tasks: Vec<TaskId> },
18    /// Referenced task doesn't exist
19    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/// Dependency declaration from original task
42#[derive(Debug, Clone)]
43pub struct LeafDependency {
44    /// Predecessor reference (may be qualified or simple)
45    pub predecessor: String,
46    /// Dependency type
47    pub dep_type: DependencyType,
48    /// Lag in working days
49    pub lag_days: i64,
50}
51
52/// A leaf task extracted from the WBS
53#[derive(Debug, Clone)]
54pub struct LeafTask {
55    /// Task identifier
56    pub id: TaskId,
57    /// Display name
58    pub name: String,
59    /// Duration in working days (computed from effort if needed)
60    pub duration_days: i64,
61    /// Original effort (person-time)
62    pub effort: Option<Duration>,
63    /// Assigned resource IDs with units
64    pub assigned: Vec<(String, f32)>,
65    /// Path from root (for WBS reconstruction)
66    pub wbs_path: Vec<TaskId>,
67    /// Original task reference path (qualified ID)
68    pub qualified_id: String,
69    /// Whether this is a milestone
70    pub is_milestone: bool,
71    /// Completion percentage
72    pub complete: Option<f32>,
73    /// Dependencies from original task
74    pub dependencies: Vec<LeafDependency>,
75}
76
77/// An edge in the dependency graph
78#[derive(Debug, Clone)]
79pub struct DependencyEdge {
80    /// Predecessor task ID
81    pub from: TaskId,
82    /// Successor task ID
83    pub to: TaskId,
84    /// Dependency type
85    pub dep_type: DependencyType,
86    /// Lag in working days (can be negative for lead time)
87    pub lag_days: i64,
88}
89
90/// A flattened, schedulable graph of leaf tasks only
91#[derive(Debug)]
92pub struct SchedulingGraph {
93    /// All leaf tasks (no containers)
94    pub tasks: Vec<LeafTask>,
95    /// Task lookup by ID
96    pub task_map: HashMap<TaskId, usize>,
97    /// Adjacency list: task_id -> list of successor task_ids with edges
98    pub successors: HashMap<TaskId, Vec<DependencyEdge>>,
99    /// Reverse adjacency: task_id -> list of predecessor task_ids with edges
100    pub predecessors: HashMap<TaskId, Vec<DependencyEdge>>,
101    /// Topological order (computed once, reused)
102    pub topo_order: Vec<TaskId>,
103    /// Map from qualified ID to simple ID (for container date derivation)
104    pub qualified_to_simple: HashMap<String, TaskId>,
105}
106
107impl SchedulingGraph {
108    /// Flatten a WBS tree into a scheduling graph
109    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        // 1. Collect all leaf tasks
114        collect_leaves(tasks, &mut leaf_tasks, &mut qualified_to_simple, vec![], "");
115
116        // Build task lookup
117        let task_map: HashMap<TaskId, usize> = leaf_tasks
118            .iter()
119            .enumerate()
120            .map(|(i, t)| (t.id.clone(), i))
121            .collect();
122
123        // 2. Build a map of all task IDs (for container expansion)
124        let mut all_tasks_map: HashMap<String, Vec<TaskId>> = HashMap::new();
125        build_container_map(tasks, &mut all_tasks_map, "");
126
127        // 3. Resolve all dependencies to leaf-to-leaf edges
128        let mut successors: HashMap<TaskId, Vec<DependencyEdge>> = HashMap::new();
129        let mut predecessors: HashMap<TaskId, Vec<DependencyEdge>> = HashMap::new();
130
131        // Initialize empty lists
132        for task in &leaf_tasks {
133            successors.insert(task.id.clone(), Vec::new());
134            predecessors.insert(task.id.clone(), Vec::new());
135        }
136
137        // Build edges
138        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        // 4. Compute topological order (also validates acyclicity)
156        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    /// Get a leaf task by ID
169    pub fn get_task(&self, id: &str) -> Option<&LeafTask> {
170        self.task_map.get(id).map(|&i| &self.tasks[i])
171    }
172}
173
174/// Collect all leaf tasks from the WBS hierarchy
175fn 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            // Leaf task
194            let duration_days = compute_duration_days(task);
195
196            // Collect dependencies with lag
197            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            // Container - recurse into children
227            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
239/// Build a map from container qualified ID to all leaf task IDs under it
240fn 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            // Leaf task - add to all parent containers
250            map.entry(qualified_id.clone())
251                .or_default()
252                .push(task.id.clone());
253
254            // Also add to parent containers
255            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            // Container - recurse
266            build_container_map(&task.children, map, &qualified_id);
267
268            // Collect all leaves under this container
269            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
276/// Helper to collect all leaf IDs under a container
277fn 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
287/// Compute duration in working days for a task
288fn compute_duration_days(task: &Task) -> i64 {
289    // If explicit duration is set, use that
290    if let Some(dur) = task.duration {
291        return dur.as_days().ceil() as i64;
292    }
293
294    // Effort-driven: Duration = Effort / Total_Resource_Units
295    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    // Milestone or summary
307    0
308}
309
310/// Resolve dependencies for a leaf task
311///
312/// Handles:
313/// - Simple IDs: "task_a" -> resolved to leaf task
314/// - Qualified paths: "phase1.task_a" -> resolved to leaf task
315/// - Container references: "phase1" -> expanded to all leaves under phase1
316fn 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        // Try to resolve the dependency
328        // 1. Check if it's a simple task ID (leaf task)
329        if task_map.contains_key(pred_ref) {
330            resolved.push((pred_ref.clone(), dep.dep_type, dep.lag_days));
331            continue;
332        }
333
334        // 2. Check if it's a qualified path that maps to a leaf
335        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        // 3. Check if it's a container - expand to all leaves under it
343        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        // 4. Try relative resolution (sibling in same container)
353        // Build the qualified path by prepending the task's container prefix
354        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            // Check if qualified path is a leaf
364            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            // Check if qualified path is a container
372            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        // Dependency couldn't be resolved - this might be an error
383        // For now, we skip it (the original code did this too)
384        // TODO: Return error for missing dependencies
385    }
386
387    Ok(resolved)
388}
389
390/// Kahn's algorithm for topological sort
391fn 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    // Initialize in-degrees to 0
398    for task in tasks {
399        in_degree.insert(task.id.clone(), 0);
400    }
401
402    // Count incoming edges
403    for edges in successors.values() {
404        for edge in edges {
405            *in_degree.get_mut(&edge.to).unwrap() += 1;
406        }
407    }
408
409    // Start with zero in-degree nodes
410    let mut queue: VecDeque<TaskId> = in_degree
411        .iter()
412        .filter(|(_, &deg)| 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    // Check for cycle
433    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        // Only leaf tasks
479        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        // Containers are NOT in the graph
485        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        // No dependencies - both should be in topo order
510        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        // 10 days effort at 50% = 20 days duration
520        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        // A -> B -> C -> A (cycle)
553        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        // Task in one container depends on another container's task
571        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        // Should have 3 leaf tasks
585        assert_eq!(graph.tasks.len(), 3);
586
587        // c should depend on b
588        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        // Only the leaf should be in the graph
602        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        // Effort with no resources = assume 100% allocation
620        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        // 10 days effort with 2 resources at 100% = 5 days
629        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        // Sibling dependencies within same container
641        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        // b should depend on a
648        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        // One container depends on another - should expand to leaf dependencies
656        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        // b should depend on a (the leaf under phase1)
668        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        // Empty WBS should produce an error or empty graph
678        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        // Tests lines 344-354: relative sibling resolution using container prefix
692        // Task b depends on "a" by simple ID - resolved via qualified path
693        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        // The dependency "a" should be resolved to the sibling "a" via container.a
701        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        // Tests lines 360-363: relative sibling container resolution
708        // Task in one sub-container depends on sibling sub-container
709        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        // b should depend on a (the leaf under sub1)
722        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        // Tests line 297: task with neither duration nor effort returns 0
732        let task = Task::new("summary");
733
734        let duration = compute_duration_days(&task);
735        assert_eq!(duration, 0);
736    }
737}