1use chrono::NaiveDate;
14use std::collections::{BinaryHeap, HashMap};
15use utf8proj_core::{
16 Calendar, Diagnostic, DiagnosticCode, Duration, Project, ResourceId, Schedule, ScheduledTask,
17 Severity, TaskId,
18};
19
20#[derive(Debug, Clone)]
26pub struct LevelingOptions {
27 pub strategy: LevelingStrategy,
29 pub max_project_delay_factor: Option<f64>,
31}
32
33impl Default for LevelingOptions {
34 fn default() -> Self {
35 Self {
36 strategy: LevelingStrategy::CriticalPathFirst,
37 max_project_delay_factor: None,
38 }
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum LevelingStrategy {
45 CriticalPathFirst,
47 }
51
52#[derive(Debug, Clone)]
54pub enum LevelingReason {
55 ResourceOverallocated {
57 resource: ResourceId,
58 peak_demand: f32,
59 capacity: f32,
60 dates: Vec<NaiveDate>,
61 },
62 DependencyChain {
64 predecessor: TaskId,
65 predecessor_delay: i64,
66 },
67}
68
69impl std::fmt::Display for LevelingReason {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 match self {
72 LevelingReason::ResourceOverallocated {
73 resource,
74 peak_demand,
75 capacity,
76 dates,
77 } => {
78 write!(
79 f,
80 "Resource '{}' overallocated (demand={:.0}%, capacity={:.0}%) on {} day(s)",
81 resource,
82 peak_demand * 100.0,
83 capacity * 100.0,
84 dates.len()
85 )
86 }
87 LevelingReason::DependencyChain {
88 predecessor,
89 predecessor_delay,
90 } => {
91 write!(
92 f,
93 "Predecessor '{}' was delayed by {} days",
94 predecessor, predecessor_delay
95 )
96 }
97 }
98 }
99}
100
101#[derive(Debug, Clone)]
103pub struct LevelingMetrics {
104 pub project_duration_increase: i64,
106 pub peak_utilization_before: f32,
108 pub peak_utilization_after: f32,
110 pub tasks_delayed: usize,
112 pub total_delay_days: i64,
114}
115
116#[derive(Debug, Clone)]
118pub struct DayUsage {
119 pub total_units: f32,
121 pub tasks: Vec<(TaskId, f32)>,
123}
124
125#[derive(Debug, Clone)]
127pub struct ResourceTimeline {
128 pub resource_id: ResourceId,
129 pub capacity: f32,
130 pub usage: HashMap<NaiveDate, DayUsage>,
132}
133
134impl ResourceTimeline {
135 pub fn new(resource_id: ResourceId, capacity: f32) -> Self {
136 Self {
137 resource_id,
138 capacity,
139 usage: HashMap::new(),
140 }
141 }
142
143 pub fn add_usage(&mut self, task_id: &TaskId, start: NaiveDate, finish: NaiveDate, units: f32) {
145 let mut date = start;
146 while date <= finish {
147 let day = self.usage.entry(date).or_insert(DayUsage {
148 total_units: 0.0,
149 tasks: Vec::new(),
150 });
151 day.total_units += units;
152 day.tasks.push((task_id.clone(), units));
153 date = date.succ_opt().unwrap_or(date);
154 }
155 }
156
157 pub fn remove_usage(&mut self, task_id: &TaskId) {
159 for day in self.usage.values_mut() {
160 day.tasks.retain(|(id, units)| {
161 if id == task_id {
162 day.total_units -= units;
163 false
164 } else {
165 true
166 }
167 });
168 }
169 self.usage.retain(|_, day| day.total_units > 0.0);
171 }
172
173 pub fn is_overallocated(&self, date: NaiveDate) -> bool {
175 self.usage
176 .get(&date)
177 .map(|day| day.total_units > self.capacity)
178 .unwrap_or(false)
179 }
180
181 pub fn overallocated_periods(&self) -> Vec<OverallocationPeriod> {
183 let mut periods = Vec::new();
184 let mut dates: Vec<_> = self.usage.keys().cloned().collect();
185 dates.sort();
186
187 let mut current_period: Option<OverallocationPeriod> = None;
188
189 for date in dates {
190 if let Some(day) = self.usage.get(&date) {
191 if day.total_units > self.capacity {
192 match &mut current_period {
193 Some(period) if period.end.succ_opt() == Some(date) => {
194 period.end = date;
195 period.peak_usage = period.peak_usage.max(day.total_units);
196 for (task_id, _) in &day.tasks {
197 if !period.involved_tasks.contains(task_id) {
198 period.involved_tasks.push(task_id.clone());
199 }
200 }
201 }
202 _ => {
203 if let Some(period) = current_period.take() {
204 periods.push(period);
205 }
206 current_period = Some(OverallocationPeriod {
207 start: date,
208 end: date,
209 peak_usage: day.total_units,
210 involved_tasks: day
211 .tasks
212 .iter()
213 .map(|(id, _)| id.clone())
214 .collect(),
215 });
216 }
217 }
218 } else if let Some(period) = current_period.take() {
219 periods.push(period);
220 }
221 }
222 }
223
224 if let Some(period) = current_period {
225 periods.push(period);
226 }
227
228 periods
229 }
230
231 pub fn find_available_slot(
233 &self,
234 duration_days: i64,
235 units: f32,
236 earliest_start: NaiveDate,
237 calendar: &Calendar,
238 ) -> NaiveDate {
239 let mut candidate = earliest_start;
240
241 loop {
242 let mut all_clear = true;
244 let mut check_date = candidate;
245 let mut working_days_checked = 0;
246
247 while working_days_checked < duration_days {
248 if calendar.is_working_day(check_date) {
249 let current_usage = self
250 .usage
251 .get(&check_date)
252 .map(|d| d.total_units)
253 .unwrap_or(0.0);
254
255 if current_usage + units > self.capacity {
256 all_clear = false;
257 break;
258 }
259 working_days_checked += 1;
260 }
261 check_date = check_date.succ_opt().unwrap_or(check_date);
262 }
263
264 if all_clear {
265 return candidate;
266 }
267
268 candidate = candidate.succ_opt().unwrap_or(candidate);
270 while !calendar.is_working_day(candidate) {
271 candidate = candidate.succ_opt().unwrap_or(candidate);
272 }
273 }
274 }
275}
276
277#[derive(Debug, Clone)]
279pub struct OverallocationPeriod {
280 pub start: NaiveDate,
281 pub end: NaiveDate,
282 pub peak_usage: f32,
283 pub involved_tasks: Vec<TaskId>,
284}
285
286#[derive(Debug, Clone)]
288pub struct LevelingResult {
289 pub original_schedule: Schedule,
291 pub leveled_schedule: Schedule,
293 pub shifted_tasks: Vec<ShiftedTask>,
295 pub unresolved_conflicts: Vec<UnresolvedConflict>,
297 pub project_extended: bool,
299 pub new_project_end: NaiveDate,
301 pub metrics: LevelingMetrics,
303 pub diagnostics: Vec<Diagnostic>,
305}
306
307impl LevelingResult {
309 pub fn schedule(&self) -> &Schedule {
311 &self.leveled_schedule
312 }
313}
314
315#[derive(Debug, Clone)]
317pub struct ShiftedTask {
318 pub task_id: TaskId,
319 pub original_start: NaiveDate,
320 pub new_start: NaiveDate,
321 pub days_shifted: i64,
322 pub reason: LevelingReason,
324 pub resources_involved: Vec<ResourceId>,
326}
327
328#[derive(Debug, Clone)]
330pub struct UnresolvedConflict {
331 pub resource_id: ResourceId,
332 pub period: OverallocationPeriod,
333 pub reason: String,
334}
335
336#[derive(Debug, Clone, Eq, PartialEq)]
338struct ShiftCandidate {
339 task_id: TaskId,
340 priority: u32,
341 slack_days: i64,
342 is_critical: bool,
343}
344
345impl Ord for ShiftCandidate {
346 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
347 match (self.is_critical, other.is_critical) {
353 (true, false) => std::cmp::Ordering::Less,
354 (false, true) => std::cmp::Ordering::Greater,
355 _ => self
356 .slack_days
357 .cmp(&other.slack_days)
358 .then(other.priority.cmp(&self.priority))
359 .then(other.task_id.cmp(&self.task_id)), }
361 }
362}
363
364impl PartialOrd for ShiftCandidate {
365 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
366 Some(self.cmp(other))
367 }
368}
369
370pub fn level_resources(
375 project: &Project,
376 schedule: &Schedule,
377 calendar: &Calendar,
378) -> LevelingResult {
379 level_resources_with_options(project, schedule, calendar, &LevelingOptions::default())
380}
381
382pub fn level_resources_with_options(
384 project: &Project,
385 schedule: &Schedule,
386 calendar: &Calendar,
387 options: &LevelingOptions,
388) -> LevelingResult {
389 let original_schedule = schedule.clone();
391 let mut leveled_tasks = schedule.tasks.clone();
392 let mut shifted_tasks = Vec::new();
393 let mut unresolved_conflicts = Vec::new();
394 let mut diagnostics = Vec::new();
395
396 let peak_utilization_before = calculate_peak_utilization(project, &leveled_tasks);
398
399 let mut timelines = build_resource_timelines(project, &leveled_tasks);
401
402 let task_priorities = build_task_priority_map(&leveled_tasks, project);
404
405 let original_milestone_dates: HashMap<TaskId, NaiveDate> = leveled_tasks
407 .iter()
408 .filter(|(_, t)| t.duration.minutes == 0) .map(|(id, t)| (id.clone(), t.start))
410 .collect();
411
412 let max_iterations = leveled_tasks.len() * 10; let mut iterations = 0;
415
416 let original_duration = schedule.project_duration.as_days() as f64;
418 let max_allowed_duration = options
419 .max_project_delay_factor
420 .map(|f| (original_duration * f) as i64);
421
422 while iterations < max_iterations {
423 iterations += 1;
424
425 let mut all_conflicts: Vec<(ResourceId, OverallocationPeriod)> = timelines
427 .iter()
428 .flat_map(|(_, timeline)| {
429 let resource_id = timeline.resource_id.clone();
430 timeline
431 .overallocated_periods()
432 .into_iter()
433 .map(move |p| (resource_id.clone(), p))
434 })
435 .collect();
436
437 all_conflicts.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.start.cmp(&b.1.start)));
439
440 let Some((resource_id, period)) = all_conflicts.into_iter().next() else {
441 break; };
443
444 let timeline_capacity = timelines
446 .get(&resource_id)
447 .map(|t| t.capacity)
448 .unwrap_or(1.0);
449
450 let mut candidates: BinaryHeap<ShiftCandidate> = period
452 .involved_tasks
453 .iter()
454 .filter_map(|task_id| {
455 let task = leveled_tasks.get(task_id)?;
456 let (priority, _) = task_priorities.get(task_id)?;
457 Some(ShiftCandidate {
458 task_id: task_id.clone(),
459 priority: *priority,
460 slack_days: task.slack.as_days() as i64,
461 is_critical: task.is_critical,
462 })
463 })
464 .collect();
465
466 if candidates.is_empty() {
467 unresolved_conflicts.push(UnresolvedConflict {
469 resource_id: resource_id.clone(),
470 period: period.clone(),
471 reason: "No shiftable tasks found".into(),
472 });
473
474 diagnostics.push(Diagnostic {
476 code: DiagnosticCode::L002UnresolvableConflict,
477 severity: Severity::Warning,
478 message: format!(
479 "Unresolvable resource conflict: '{}' at {} (demand={:.0}%, capacity={:.0}%)",
480 resource_id,
481 period.start,
482 period.peak_usage * 100.0,
483 timeline_capacity * 100.0
484 ),
485 file: None,
486 span: None,
487 secondary_spans: vec![],
488 notes: vec![],
489 hints: vec![],
490 });
491 continue;
492 }
493
494 let candidate = candidates.pop().unwrap();
496 let task = leveled_tasks.get(&candidate.task_id).unwrap();
497 let original_start = task.start;
498
499 let units = task
501 .assignments
502 .iter()
503 .find(|a| a.resource_id == resource_id)
504 .map(|a| a.units)
505 .unwrap_or(1.0);
506
507 let timeline = timelines.get_mut(&resource_id).unwrap();
509 let duration_days = task.duration.as_days() as i64;
510
511 timeline.remove_usage(&candidate.task_id);
513
514 let new_start = timeline.find_available_slot(
515 duration_days,
516 units,
517 period.end.succ_opt().unwrap_or(period.end),
518 calendar,
519 );
520
521 let days_shifted = count_working_days(original_start, new_start, calendar);
523 let new_finish = add_working_days(new_start, duration_days, calendar);
524
525 if let Some(max_duration) = max_allowed_duration {
527 let new_project_end = leveled_tasks
528 .values()
529 .map(|t| t.finish)
530 .chain(std::iter::once(new_finish))
531 .max()
532 .unwrap_or(schedule.project_end);
533 let new_duration = count_working_days(project.start, new_project_end, calendar);
534 if new_duration > max_duration {
535 unresolved_conflicts.push(UnresolvedConflict {
537 resource_id: resource_id.clone(),
538 period: period.clone(),
539 reason: format!(
540 "Shifting would exceed max delay factor ({:.1}x)",
541 options.max_project_delay_factor.unwrap()
542 ),
543 });
544 timeline.add_usage(&candidate.task_id, original_start, task.finish, units);
546 continue;
547 }
548 }
549
550 if let Some(task) = leveled_tasks.get_mut(&candidate.task_id) {
552 task.start = new_start;
553 task.finish = new_finish;
554 task.early_start = new_start;
555 task.early_finish = new_finish;
556
557 for assignment in &mut task.assignments {
559 assignment.start = new_start;
560 assignment.finish = new_finish;
561 }
562 }
563
564 timeline.add_usage(&candidate.task_id, new_start, new_finish, units);
566
567 let conflict_dates: Vec<NaiveDate> = {
569 let mut dates = Vec::new();
570 let mut d = period.start;
571 while d <= period.end {
572 dates.push(d);
573 d = d.succ_opt().unwrap_or(d);
574 if dates.len() > 100 {
575 break;
576 } }
578 dates
579 };
580
581 shifted_tasks.push(ShiftedTask {
583 task_id: candidate.task_id.clone(),
584 original_start,
585 new_start,
586 days_shifted,
587 reason: LevelingReason::ResourceOverallocated {
588 resource: resource_id.clone(),
589 peak_demand: period.peak_usage,
590 capacity: timeline_capacity,
591 dates: conflict_dates,
592 },
593 resources_involved: vec![resource_id.clone()],
594 });
595
596 diagnostics.push(Diagnostic {
598 code: DiagnosticCode::L001OverallocationResolved,
599 severity: Severity::Hint,
600 message: format!(
601 "Resource overallocation resolved by delaying '{}' by {} day(s)",
602 candidate.task_id, days_shifted
603 ),
604 file: None,
605 span: None,
606 secondary_spans: vec![],
607 notes: vec![],
608 hints: vec![format!("Resource '{}' was overallocated", resource_id)],
609 });
610 }
611
612 let new_project_end = leveled_tasks
614 .values()
615 .map(|t| t.finish)
616 .max()
617 .unwrap_or(schedule.project_end);
618
619 let project_extended = new_project_end > schedule.project_end;
620 let duration_increase =
621 count_working_days(schedule.project_end, new_project_end, calendar).max(0);
622
623 if project_extended {
625 diagnostics.push(Diagnostic {
626 code: DiagnosticCode::L003DurationIncreased,
627 severity: Severity::Hint,
628 message: format!(
629 "Project duration increased by {} day(s) due to leveling",
630 duration_increase
631 ),
632 file: None,
633 span: None,
634 secondary_spans: vec![],
635 notes: vec![],
636 hints: vec![],
637 });
638 }
639
640 for (milestone_id, original_date) in &original_milestone_dates {
642 if let Some(task) = leveled_tasks.get(milestone_id) {
643 if task.start > *original_date {
644 let delay = count_working_days(*original_date, task.start, calendar);
645 diagnostics.push(Diagnostic {
646 code: DiagnosticCode::L004MilestoneDelayed,
647 severity: Severity::Warning,
648 message: format!(
649 "Milestone '{}' delayed by {} day(s) due to leveling",
650 milestone_id, delay
651 ),
652 file: None,
653 span: None,
654 secondary_spans: vec![],
655 notes: vec![],
656 hints: vec![],
657 });
658 }
659 }
660 }
661
662 let critical_path = if project_extended {
664 recalculate_critical_path(&leveled_tasks, new_project_end)
665 } else {
666 schedule.critical_path.clone()
667 };
668
669 let project_duration_days = count_working_days(project.start, new_project_end, calendar);
671
672 let peak_utilization_after = calculate_peak_utilization(project, &leveled_tasks);
674
675 let metrics = LevelingMetrics {
677 project_duration_increase: duration_increase,
678 peak_utilization_before,
679 peak_utilization_after,
680 tasks_delayed: shifted_tasks.len(),
681 total_delay_days: shifted_tasks.iter().map(|s| s.days_shifted).sum(),
682 };
683
684 LevelingResult {
685 original_schedule,
686 leveled_schedule: Schedule {
687 tasks: leveled_tasks,
688 critical_path,
689 project_duration: Duration::days(project_duration_days),
690 project_end: new_project_end,
691 total_cost: schedule.total_cost.clone(),
692 total_cost_range: schedule.total_cost_range.clone(),
693 project_progress: schedule.project_progress,
694 project_baseline_finish: schedule.project_baseline_finish,
695 project_forecast_finish: schedule.project_forecast_finish,
696 project_variance_days: schedule.project_variance_days,
697 planned_value: schedule.planned_value,
698 earned_value: schedule.earned_value,
699 spi: schedule.spi,
700 },
701 shifted_tasks,
702 unresolved_conflicts,
703 project_extended,
704 new_project_end,
705 metrics,
706 diagnostics,
707 }
708}
709
710fn calculate_peak_utilization(project: &Project, tasks: &HashMap<TaskId, ScheduledTask>) -> f32 {
712 let timelines = build_resource_timelines(project, tasks);
713 timelines
714 .values()
715 .flat_map(|t| t.usage.values().map(|d| d.total_units / t.capacity))
716 .fold(0.0f32, |max, u| max.max(u))
717}
718
719fn build_resource_timelines(
721 project: &Project,
722 tasks: &HashMap<TaskId, ScheduledTask>,
723) -> HashMap<ResourceId, ResourceTimeline> {
724 let mut timelines: HashMap<ResourceId, ResourceTimeline> = HashMap::new();
725
726 for resource in &project.resources {
728 timelines.insert(
729 resource.id.clone(),
730 ResourceTimeline::new(resource.id.clone(), resource.capacity),
731 );
732 }
733
734 for task in tasks.values() {
736 for assignment in &task.assignments {
737 if let Some(timeline) = timelines.get_mut(&assignment.resource_id) {
738 timeline.add_usage(
739 &task.task_id,
740 assignment.start,
741 assignment.finish,
742 assignment.units,
743 );
744 }
745 }
746 }
747
748 timelines
749}
750
751fn build_task_priority_map(
753 tasks: &HashMap<TaskId, ScheduledTask>,
754 project: &Project,
755) -> HashMap<TaskId, (u32, ())> {
756 let mut map = HashMap::new();
757
758 fn add_tasks(tasks: &[utf8proj_core::Task], map: &mut HashMap<TaskId, u32>, prefix: &str) {
760 for task in tasks {
761 let qualified_id = if prefix.is_empty() {
762 task.id.clone()
763 } else {
764 format!("{}.{}", prefix, task.id)
765 };
766 map.insert(qualified_id.clone(), task.priority);
767
768 if !task.children.is_empty() {
769 add_tasks(&task.children, map, &qualified_id);
770 }
771 }
772 }
773
774 let mut priorities = HashMap::new();
775 add_tasks(&project.tasks, &mut priorities, "");
776
777 for task_id in tasks.keys() {
778 let priority = priorities.get(task_id).copied().unwrap_or(500);
779 map.insert(task_id.clone(), (priority, ()));
780 }
781
782 map
783}
784
785fn add_working_days(start: NaiveDate, days: i64, calendar: &Calendar) -> NaiveDate {
787 if days <= 0 {
788 return start;
789 }
790
791 let mut current = start;
792 let mut remaining = days;
793
794 while remaining > 0 {
795 current = current.succ_opt().unwrap_or(current);
796 if calendar.is_working_day(current) {
797 remaining -= 1;
798 }
799 }
800
801 current
802}
803
804fn count_working_days(start: NaiveDate, end: NaiveDate, calendar: &Calendar) -> i64 {
806 if end <= start {
807 return 0;
808 }
809
810 let mut current = start;
811 let mut count = 0;
812
813 while current < end {
814 current = current.succ_opt().unwrap_or(current);
815 if calendar.is_working_day(current) {
816 count += 1;
817 }
818 }
819
820 count
821}
822
823fn recalculate_critical_path(
825 tasks: &HashMap<TaskId, ScheduledTask>,
826 project_end: NaiveDate,
827) -> Vec<TaskId> {
828 tasks
830 .iter()
831 .filter(|(_, task)| task.finish == project_end || task.slack.minutes == 0)
832 .map(|(id, _)| id.clone())
833 .collect()
834}
835
836pub fn detect_overallocations(
838 project: &Project,
839 schedule: &Schedule,
840) -> Vec<(ResourceId, OverallocationPeriod)> {
841 let timelines = build_resource_timelines(project, &schedule.tasks);
842
843 timelines
844 .into_iter()
845 .flat_map(|(_, timeline)| {
846 let resource_id = timeline.resource_id.clone();
847 timeline
848 .overallocated_periods()
849 .into_iter()
850 .map(move |p| (resource_id.clone(), p))
851 })
852 .collect()
853}
854
855#[derive(Debug, Clone)]
857pub struct ResourceUtilization {
858 pub resource_id: ResourceId,
860 pub capacity: f32,
862 pub total_days: i64,
864 pub used_days: f32,
866 pub utilization_percent: f32,
868 pub peak_usage: f32,
870 pub assigned_days: i64,
872}
873
874#[derive(Debug, Clone)]
876pub struct UtilizationSummary {
877 pub resources: Vec<ResourceUtilization>,
879 pub schedule_start: NaiveDate,
881 pub schedule_end: NaiveDate,
883 pub total_working_days: i64,
885 pub average_utilization: f32,
887}
888
889pub fn calculate_utilization(
891 project: &Project,
892 schedule: &Schedule,
893 calendar: &Calendar,
894) -> UtilizationSummary {
895 let timelines = build_resource_timelines(project, &schedule.tasks);
896
897 let schedule_start = schedule
899 .tasks
900 .values()
901 .map(|t| t.start)
902 .min()
903 .unwrap_or(project.start);
904 let schedule_end = schedule
905 .tasks
906 .values()
907 .map(|t| t.finish)
908 .max()
909 .unwrap_or(project.start);
910
911 let total_working_days = count_schedule_working_days(schedule_start, schedule_end, calendar);
913
914 let mut resources = Vec::new();
915
916 for resource in &project.resources {
917 let timeline = timelines.get(&resource.id);
918
919 let (used_days, peak_usage, assigned_days) = if let Some(timeline) = timeline {
920 let mut used = 0.0f32;
921 let mut peak = 0.0f32;
922 let mut assigned = 0i64;
923
924 for day_usage in timeline.usage.values() {
925 used += day_usage.total_units;
926 peak = peak.max(day_usage.total_units);
927 if day_usage.total_units > 0.0 {
928 assigned += 1;
929 }
930 }
931
932 (used, peak, assigned)
933 } else {
934 (0.0, 0.0, 0)
935 };
936
937 let capacity_days = total_working_days as f32 * resource.capacity;
939 let utilization_percent = if capacity_days > 0.0 {
940 (used_days / capacity_days) * 100.0
941 } else {
942 0.0
943 };
944
945 resources.push(ResourceUtilization {
946 resource_id: resource.id.clone(),
947 capacity: resource.capacity,
948 total_days: total_working_days,
949 used_days,
950 utilization_percent,
951 peak_usage,
952 assigned_days,
953 });
954 }
955
956 let average_utilization = if resources.is_empty() {
958 0.0
959 } else {
960 resources.iter().map(|r| r.utilization_percent).sum::<f32>() / resources.len() as f32
961 };
962
963 UtilizationSummary {
964 resources,
965 schedule_start,
966 schedule_end,
967 total_working_days,
968 average_utilization,
969 }
970}
971
972fn count_schedule_working_days(start: NaiveDate, end: NaiveDate, calendar: &Calendar) -> i64 {
974 if end <= start {
975 return 0;
976 }
977
978 let mut current = start;
979 let mut count = 0;
980
981 while current <= end {
982 if calendar.is_working_day(current) {
983 count += 1;
984 }
985 current = match current.succ_opt() {
986 Some(d) => d,
987 None => break,
988 };
989 }
990
991 count
992}
993
994#[cfg(test)]
995mod tests {
996 use super::*;
997 use utf8proj_core::{Resource, Task};
998
999 fn make_test_calendar() -> Calendar {
1000 Calendar::default()
1001 }
1002
1003 #[test]
1004 fn timeline_tracks_usage() {
1005 let mut timeline = ResourceTimeline::new("dev".into(), 1.0);
1006 let start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1007 let finish = NaiveDate::from_ymd_opt(2025, 1, 8).unwrap();
1008
1009 timeline.add_usage(&"task1".into(), start, finish, 0.5);
1010
1011 assert!(!timeline.is_overallocated(start));
1012 assert_eq!(timeline.usage.get(&start).unwrap().total_units, 0.5);
1013 }
1014
1015 #[test]
1016 fn timeline_detects_overallocation() {
1017 let mut timeline = ResourceTimeline::new("dev".into(), 1.0);
1018 let start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1019 let finish = NaiveDate::from_ymd_opt(2025, 1, 8).unwrap();
1020
1021 timeline.add_usage(&"task1".into(), start, finish, 0.6);
1022 timeline.add_usage(&"task2".into(), start, finish, 0.6);
1023
1024 assert!(timeline.is_overallocated(start));
1025
1026 let periods = timeline.overallocated_periods();
1027 assert_eq!(periods.len(), 1);
1028 assert!(periods[0].peak_usage > 1.0);
1029 }
1030
1031 #[test]
1032 fn timeline_removes_usage() {
1033 let mut timeline = ResourceTimeline::new("dev".into(), 1.0);
1034 let start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1035 let finish = NaiveDate::from_ymd_opt(2025, 1, 8).unwrap();
1036
1037 timeline.add_usage(&"task1".into(), start, finish, 0.6);
1038 timeline.add_usage(&"task2".into(), start, finish, 0.6);
1039
1040 assert!(timeline.is_overallocated(start));
1041
1042 timeline.remove_usage(&"task1".into());
1043
1044 assert!(!timeline.is_overallocated(start));
1045 }
1046
1047 #[test]
1048 fn find_available_slot_basic() {
1049 let mut timeline = ResourceTimeline::new("dev".into(), 1.0);
1050 let calendar = make_test_calendar();
1051
1052 let start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(); let finish = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(); timeline.add_usage(&"task1".into(), start, finish, 1.0);
1057
1058 let slot = timeline.find_available_slot(3, 1.0, start, &calendar);
1060
1061 assert!(slot > finish);
1063 }
1064
1065 #[test]
1066 fn detect_overallocations_finds_conflicts() {
1067 use utf8proj_core::Scheduler;
1068
1069 let mut project = Project::new("Test");
1070 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1071 project.resources = vec![Resource::new("dev").capacity(1.0)];
1072 project.tasks = vec![
1073 Task::new("task1").effort(Duration::days(5)).assign("dev"),
1074 Task::new("task2").effort(Duration::days(5)).assign("dev"),
1075 ];
1076
1077 let solver = crate::CpmSolver::new();
1078 let schedule = solver.schedule(&project).unwrap();
1079
1080 let conflicts = detect_overallocations(&project, &schedule);
1082
1083 assert!(
1084 !conflicts.is_empty(),
1085 "Should detect resource conflict when same resource assigned to parallel tasks"
1086 );
1087 }
1088
1089 #[test]
1090 fn level_resources_resolves_simple_conflict() {
1091 use utf8proj_core::Scheduler;
1092
1093 let mut project = Project::new("Test");
1094 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1095 project.resources = vec![Resource::new("dev").capacity(1.0)];
1096 project.tasks = vec![
1097 Task::new("task1").effort(Duration::days(3)).assign("dev"),
1098 Task::new("task2").effort(Duration::days(3)).assign("dev"),
1099 ];
1100
1101 let solver = crate::CpmSolver::new();
1102 let schedule = solver.schedule(&project).unwrap();
1103
1104 let calendar = Calendar::default();
1105 let result = level_resources(&project, &schedule, &calendar);
1106
1107 assert!(
1109 !result.shifted_tasks.is_empty(),
1110 "Should shift at least one task"
1111 );
1112
1113 assert!(
1115 result.unresolved_conflicts.is_empty(),
1116 "Should resolve all conflicts"
1117 );
1118
1119 let task1 = &result.leveled_schedule.tasks["task1"];
1121 let task2 = &result.leveled_schedule.tasks["task2"];
1122
1123 let overlap = task1.start <= task2.finish && task2.start <= task1.finish;
1124 assert!(
1125 !overlap || task1.start == task2.start,
1126 "Tasks should not overlap after leveling"
1127 );
1128 }
1129
1130 #[test]
1131 fn level_resources_respects_dependencies() {
1132 use utf8proj_core::Scheduler;
1133
1134 let mut project = Project::new("Test");
1135 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1136 project.resources = vec![Resource::new("dev").capacity(1.0)];
1137 project.tasks = vec![
1138 Task::new("task1").effort(Duration::days(3)).assign("dev"),
1139 Task::new("task2")
1140 .effort(Duration::days(3))
1141 .assign("dev")
1142 .depends_on("task1"),
1143 ];
1144
1145 let solver = crate::CpmSolver::new();
1146 let schedule = solver.schedule(&project).unwrap();
1147
1148 let calendar = Calendar::default();
1149 let result = level_resources(&project, &schedule, &calendar);
1150
1151 let task1 = &result.leveled_schedule.tasks["task1"];
1153 let task2 = &result.leveled_schedule.tasks["task2"];
1154
1155 assert!(
1156 task2.start > task1.finish || task2.start == task1.finish.succ_opt().unwrap(),
1157 "Task2 should start after task1 finishes"
1158 );
1159 }
1160
1161 #[test]
1162 fn level_resources_extends_project_when_needed() {
1163 use utf8proj_core::Scheduler;
1164
1165 let mut project = Project::new("Test");
1166 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1167 project.resources = vec![Resource::new("dev").capacity(1.0)];
1168 project.tasks = vec![
1169 Task::new("task1").effort(Duration::days(5)).assign("dev"),
1170 Task::new("task2").effort(Duration::days(5)).assign("dev"),
1171 ];
1172
1173 let solver = crate::CpmSolver::new();
1174 let schedule = solver.schedule(&project).unwrap();
1175
1176 let calendar = Calendar::default();
1177 let result = level_resources(&project, &schedule, &calendar);
1178
1179 assert!(
1182 result.project_extended,
1183 "Project should be extended when leveling parallel tasks"
1184 );
1185 assert!(result.new_project_end > schedule.project_end);
1186 }
1187
1188 #[test]
1189 fn add_working_days_zero_or_negative() {
1190 let calendar = make_test_calendar();
1191 let start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1192
1193 assert_eq!(add_working_days(start, 0, &calendar), start);
1195
1196 assert_eq!(add_working_days(start, -5, &calendar), start);
1198 }
1199
1200 #[test]
1201 fn count_working_days_end_before_start() {
1202 let calendar = make_test_calendar();
1203 let start = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
1204 let end = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1205
1206 assert_eq!(count_working_days(start, end, &calendar), 0);
1208
1209 assert_eq!(count_working_days(start, start, &calendar), 0);
1211 }
1212
1213 #[test]
1214 fn shift_candidate_ordering_critical_vs_non_critical() {
1215 let critical = ShiftCandidate {
1217 task_id: "critical_task".into(),
1218 priority: 100,
1219 slack_days: 0,
1220 is_critical: true,
1221 };
1222
1223 let non_critical = ShiftCandidate {
1224 task_id: "non_critical_task".into(),
1225 priority: 100,
1226 slack_days: 0,
1227 is_critical: false,
1228 };
1229
1230 assert!(non_critical > critical);
1232 assert!(critical < non_critical);
1233 }
1234
1235 #[test]
1236 fn overallocated_periods_multiple_consecutive_days() {
1237 let mut timeline = ResourceTimeline::new("dev".into(), 1.0);
1239 let day1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1240 let day2 = NaiveDate::from_ymd_opt(2025, 1, 7).unwrap();
1241 let day3 = NaiveDate::from_ymd_opt(2025, 1, 8).unwrap();
1242
1243 timeline.add_usage(&"task1".into(), day1, day1, 0.7);
1245 timeline.add_usage(&"task2".into(), day1, day1, 0.7);
1246
1247 timeline.add_usage(&"task1".into(), day2, day2, 0.7);
1249 timeline.add_usage(&"task3".into(), day2, day2, 0.7);
1250
1251 timeline.add_usage(&"task1".into(), day3, day3, 0.3);
1253
1254 let periods = timeline.overallocated_periods();
1255
1256 assert_eq!(periods.len(), 1);
1258 assert_eq!(periods[0].start, day1);
1259 assert_eq!(periods[0].end, day2);
1260 assert!(periods[0].involved_tasks.contains(&"task1".to_string()));
1262 assert!(periods[0].involved_tasks.contains(&"task2".to_string()));
1263 assert!(periods[0].involved_tasks.contains(&"task3".to_string()));
1264 }
1265
1266 #[test]
1267 fn overallocated_periods_non_consecutive_creates_multiple() {
1268 let mut timeline = ResourceTimeline::new("dev".into(), 1.0);
1270 let day1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(); let day3 = NaiveDate::from_ymd_opt(2025, 1, 8).unwrap(); timeline.add_usage(&"task1".into(), day1, day1, 1.5);
1275
1276 timeline.add_usage(&"task2".into(), day3, day3, 1.5);
1278
1279 let periods = timeline.overallocated_periods();
1280
1281 assert_eq!(periods.len(), 2);
1283 assert_eq!(periods[0].start, day1);
1284 assert_eq!(periods[0].end, day1);
1285 assert_eq!(periods[1].start, day3);
1286 assert_eq!(periods[1].end, day3);
1287 }
1288
1289 #[test]
1290 fn nested_task_priority_mapping() {
1291 use utf8proj_core::{Project, Scheduler};
1293
1294 let mut project = Project::new("Nested Priority Test");
1295 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1296
1297 let mut container = Task::new("phase1");
1299 container.priority = 100;
1300
1301 let mut child = Task::new("task1");
1302 child.priority = 200;
1303 child.duration = Some(utf8proj_core::Duration::days(2));
1304
1305 let mut grandchild = Task::new("subtask1");
1306 grandchild.priority = 300;
1307 grandchild.duration = Some(utf8proj_core::Duration::days(1));
1308
1309 child.children.push(grandchild);
1310 container.children.push(child);
1311 project.tasks.push(container);
1312
1313 let mut root_task = Task::new("standalone");
1315 root_task.priority = 500;
1316 root_task.duration = Some(utf8proj_core::Duration::days(1));
1317 project.tasks.push(root_task);
1318
1319 let solver = crate::CpmSolver::new();
1321 let schedule = solver.schedule(&project).unwrap();
1322
1323 let priority_map = build_task_priority_map(&schedule.tasks, &project);
1325
1326 assert!(priority_map.contains_key("phase1.task1.subtask1"));
1328 assert!(priority_map.contains_key("phase1.task1"));
1329 assert!(priority_map.contains_key("standalone"));
1331
1332 assert_eq!(priority_map["standalone"].0, 500);
1334 }
1335
1336 #[test]
1337 fn unresolved_conflict_no_shiftable_tasks() {
1338 use utf8proj_core::{
1342 Dependency, DependencyType, Project, Resource, ResourceRef, Scheduler,
1343 };
1344
1345 let mut project = Project::new("All Critical Test");
1346 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1347
1348 project.resources.push(Resource::new("dev").capacity(1.0));
1350
1351 let mut task1 = Task::new("critical1");
1355 task1.duration = Some(utf8proj_core::Duration::days(5));
1356 task1.assigned.push(ResourceRef {
1357 resource_id: "dev".into(),
1358 units: 1.0,
1359 });
1360
1361 let mut task2 = Task::new("critical2");
1362 task2.duration = Some(utf8proj_core::Duration::days(5));
1363 task2.assigned.push(ResourceRef {
1364 resource_id: "dev".into(),
1365 units: 1.0,
1366 });
1367
1368 task2.depends = vec![Dependency {
1370 predecessor: "critical1".into(),
1371 dep_type: DependencyType::FinishToStart,
1372 lag: None,
1373 }];
1374
1375 project.tasks.push(task1);
1376 project.tasks.push(task2);
1377
1378 let solver = crate::CpmSolver::new();
1379 let schedule = solver.schedule(&project).unwrap();
1380
1381 assert!(schedule.tasks["critical1"].is_critical);
1383 assert!(schedule.tasks["critical2"].is_critical);
1384
1385 let overallocations = detect_overallocations(&project, &schedule);
1387 assert!(
1388 overallocations.is_empty(),
1389 "Sequential critical tasks should not conflict"
1390 );
1391 }
1392
1393 #[test]
1394 fn recalculate_critical_path_test() {
1395 use utf8proj_core::{Dependency, DependencyType, Project, Scheduler};
1397
1398 let mut project = Project::new("Critical Path Test");
1399 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1400
1401 let mut task1 = Task::new("first");
1403 task1.duration = Some(utf8proj_core::Duration::days(3));
1404
1405 let mut task2 = Task::new("second");
1406 task2.duration = Some(utf8proj_core::Duration::days(2));
1407 task2.depends = vec![Dependency {
1408 predecessor: "first".into(),
1409 dep_type: DependencyType::FinishToStart,
1410 lag: None,
1411 }];
1412
1413 project.tasks.push(task1);
1414 project.tasks.push(task2);
1415
1416 let solver = crate::CpmSolver::new();
1417 let schedule = solver.schedule(&project).unwrap();
1418
1419 let critical = recalculate_critical_path(&schedule.tasks, schedule.project_end);
1420
1421 assert!(critical.contains(&"first".to_string()));
1423 assert!(critical.contains(&"second".to_string()));
1424 }
1425
1426 #[test]
1427 fn calculate_utilization_basic() {
1428 use utf8proj_core::Scheduler;
1429
1430 let mut project = Project::new("Utilization Test");
1431 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(); project.resources = vec![Resource::new("dev").capacity(1.0)];
1433 project.tasks = vec![Task::new("task1").effort(Duration::days(5)).assign("dev")];
1434
1435 let solver = crate::CpmSolver::new();
1436 let schedule = solver.schedule(&project).unwrap();
1437 let calendar = Calendar::default();
1438
1439 let utilization = calculate_utilization(&project, &schedule, &calendar);
1440
1441 assert_eq!(utilization.resources.len(), 1);
1442 let dev_util = &utilization.resources[0];
1443 assert_eq!(dev_util.resource_id, "dev");
1444 assert!(dev_util.utilization_percent > 90.0);
1446 assert!(dev_util.used_days > 0.0);
1447 }
1448
1449 #[test]
1450 fn calculate_utilization_multiple_resources() {
1451 use utf8proj_core::Scheduler;
1452
1453 let mut project = Project::new("Multi Resource Test");
1454 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1455 project.resources = vec![
1456 Resource::new("dev1").capacity(1.0),
1457 Resource::new("dev2").capacity(1.0),
1458 ];
1459 project.tasks = vec![
1460 Task::new("task1").effort(Duration::days(5)).assign("dev1"),
1461 Task::new("task2").effort(Duration::days(3)).assign("dev2"),
1462 ];
1463
1464 let solver = crate::CpmSolver::new();
1465 let schedule = solver.schedule(&project).unwrap();
1466 let calendar = Calendar::default();
1467
1468 let utilization = calculate_utilization(&project, &schedule, &calendar);
1469
1470 assert_eq!(utilization.resources.len(), 2);
1471 assert!(utilization.average_utilization > 0.0);
1472
1473 let dev1 = utilization
1475 .resources
1476 .iter()
1477 .find(|r| r.resource_id == "dev1")
1478 .unwrap();
1479 let dev2 = utilization
1480 .resources
1481 .iter()
1482 .find(|r| r.resource_id == "dev2")
1483 .unwrap();
1484 assert!(dev1.used_days > dev2.used_days);
1485 }
1486
1487 #[test]
1488 fn calculate_utilization_no_resources() {
1489 use utf8proj_core::Scheduler;
1490
1491 let mut project = Project::new("No Resources Test");
1492 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1493 project.tasks = vec![Task::new("task1").effort(Duration::days(5))];
1494
1495 let solver = crate::CpmSolver::new();
1496 let schedule = solver.schedule(&project).unwrap();
1497 let calendar = Calendar::default();
1498
1499 let utilization = calculate_utilization(&project, &schedule, &calendar);
1500
1501 assert!(utilization.resources.is_empty());
1502 assert_eq!(utilization.average_utilization, 0.0);
1503 }
1504
1505 #[test]
1506 fn calculate_utilization_idle_resource() {
1507 use utf8proj_core::Scheduler;
1508
1509 let mut project = Project::new("Idle Resource Test");
1510 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1511 project.resources = vec![
1512 Resource::new("dev1").capacity(1.0),
1513 Resource::new("dev2").capacity(1.0), ];
1515 project.tasks = vec![Task::new("task1").effort(Duration::days(5)).assign("dev1")];
1516
1517 let solver = crate::CpmSolver::new();
1518 let schedule = solver.schedule(&project).unwrap();
1519 let calendar = Calendar::default();
1520
1521 let utilization = calculate_utilization(&project, &schedule, &calendar);
1522
1523 let dev2 = utilization
1524 .resources
1525 .iter()
1526 .find(|r| r.resource_id == "dev2")
1527 .unwrap();
1528 assert_eq!(dev2.used_days, 0.0);
1529 assert_eq!(dev2.utilization_percent, 0.0);
1530 assert_eq!(dev2.assigned_days, 0);
1531 }
1532
1533 #[test]
1534 fn count_schedule_working_days_basic() {
1535 let calendar = make_test_calendar();
1536 let start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(); let end = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(); let count = count_schedule_working_days(start, end, &calendar);
1541 assert_eq!(count, 5);
1542 }
1543
1544 #[test]
1545 fn count_schedule_working_days_same_date() {
1546 let calendar = make_test_calendar();
1547 let date = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1548
1549 let count = count_schedule_working_days(date, date, &calendar);
1551 assert_eq!(count, 0);
1552 }
1553
1554 #[test]
1555 fn count_schedule_working_days_end_before_start() {
1556 let calendar = make_test_calendar();
1557 let start = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
1558 let end = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1559
1560 let count = count_schedule_working_days(start, end, &calendar);
1562 assert_eq!(count, 0);
1563 }
1564}