utf8proj_solver/
leveling.rs

1//! Resource Leveling Algorithm
2//!
3//! Detects and resolves resource over-allocation by shifting tasks within their slack.
4//!
5//! ## RFC-0003 Compliance
6//!
7//! This module implements deterministic, explainable resource leveling:
8//! - Explicit opt-in only (`--level` flag)
9//! - Every delay has a structured reason (`LevelingReason`)
10//! - Original schedule preserved alongside leveled schedule
11//! - L001-L004 diagnostics emitted for transparency
12
13use 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// =============================================================================
21// RFC-0003 Data Structures
22// =============================================================================
23
24/// Leveling configuration (explicit user choice)
25#[derive(Debug, Clone)]
26pub struct LevelingOptions {
27    /// Strategy for selecting tasks to delay
28    pub strategy: LevelingStrategy,
29    /// Maximum allowed project duration increase factor (e.g., 2.0 = can't double duration)
30    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/// Strategy for selecting which tasks to delay during leveling
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum LevelingStrategy {
45    /// Delay non-critical tasks before critical ones (default)
46    CriticalPathFirst,
47    // Future strategies (not implemented in v1):
48    // PreferShorterDelay,
49    // PreserveEarlySchedule,
50}
51
52/// Structured reason for a leveling delay
53#[derive(Debug, Clone)]
54pub enum LevelingReason {
55    /// Task delayed due to resource overallocation
56    ResourceOverallocated {
57        resource: ResourceId,
58        peak_demand: f32,
59        capacity: f32,
60        dates: Vec<NaiveDate>,
61    },
62    /// Task delayed because predecessor was delayed (cascade)
63    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/// Metrics summarizing the leveling transformation
102#[derive(Debug, Clone)]
103pub struct LevelingMetrics {
104    /// Days added to project duration due to leveling
105    pub project_duration_increase: i64,
106    /// Peak resource utilization before leveling (0.0-1.0+)
107    pub peak_utilization_before: f32,
108    /// Peak resource utilization after leveling (0.0-1.0+)
109    pub peak_utilization_after: f32,
110    /// Number of tasks that were delayed
111    pub tasks_delayed: usize,
112    /// Total delay days across all tasks
113    pub total_delay_days: i64,
114}
115
116/// Resource usage on a specific day
117#[derive(Debug, Clone)]
118pub struct DayUsage {
119    /// Total units allocated (1.0 = 100%)
120    pub total_units: f32,
121    /// Tasks contributing to this usage
122    pub tasks: Vec<(TaskId, f32)>,
123}
124
125/// Timeline of resource usage
126#[derive(Debug, Clone)]
127pub struct ResourceTimeline {
128    pub resource_id: ResourceId,
129    pub capacity: f32,
130    /// Usage by date
131    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    /// Add usage for a task over a date range
144    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    /// Remove usage for a task
158    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        // Clean up empty days
170        self.usage.retain(|_, day| day.total_units > 0.0);
171    }
172
173    /// Check if over-allocated on a specific date
174    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    /// Get all over-allocated periods
182    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    /// Find first available slot where a task can be scheduled without overallocation
232    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            // Check if all days in this slot are available
243            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            // Move to next working day
269            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/// A period of resource over-allocation
278#[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/// Result of resource leveling (RFC-0003 compliant)
287#[derive(Debug, Clone)]
288pub struct LevelingResult {
289    /// Original schedule (preserved, authoritative)
290    pub original_schedule: Schedule,
291    /// Leveled schedule (delta transformation)
292    pub leveled_schedule: Schedule,
293    /// Tasks that were shifted (each with structured reason)
294    pub shifted_tasks: Vec<ShiftedTask>,
295    /// Conflicts that could not be resolved
296    pub unresolved_conflicts: Vec<UnresolvedConflict>,
297    /// Whether the project duration was extended
298    pub project_extended: bool,
299    /// New project end date
300    pub new_project_end: NaiveDate,
301    /// Metrics summarizing the transformation
302    pub metrics: LevelingMetrics,
303    /// Diagnostics emitted during leveling (L001-L004)
304    pub diagnostics: Vec<Diagnostic>,
305}
306
307// Backwards compatibility alias
308impl LevelingResult {
309    /// Get the leveled schedule (alias for backwards compatibility)
310    pub fn schedule(&self) -> &Schedule {
311        &self.leveled_schedule
312    }
313}
314
315/// A task that was shifted during leveling
316#[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    /// Structured reason for the delay (RFC-0003)
323    pub reason: LevelingReason,
324    /// Resources involved in the conflict
325    pub resources_involved: Vec<ResourceId>,
326}
327
328/// A conflict that could not be resolved
329#[derive(Debug, Clone)]
330pub struct UnresolvedConflict {
331    pub resource_id: ResourceId,
332    pub period: OverallocationPeriod,
333    pub reason: String,
334}
335
336/// Task candidate for shifting (used in priority queue)
337#[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        // Deterministic ordering (RFC-0003):
348        // 1. Non-critical before critical (prefer shifting non-critical)
349        // 2. More slack before less slack
350        // 3. Lower priority before higher priority
351        // 4. Task ID as final tie-breaker (determinism guarantee)
352        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)), // Deterministic tie-breaker
360        }
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
370/// Perform resource leveling on a schedule (RFC-0003 compliant)
371///
372/// This function is deterministic: same input always produces same output.
373/// Original schedule is preserved; leveled schedule is a delta transformation.
374pub 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
382/// Perform resource leveling with explicit options
383pub fn level_resources_with_options(
384    project: &Project,
385    schedule: &Schedule,
386    calendar: &Calendar,
387    options: &LevelingOptions,
388) -> LevelingResult {
389    // Preserve original schedule (RFC-0003 requirement)
390    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    // Calculate peak utilization before leveling
397    let peak_utilization_before = calculate_peak_utilization(project, &leveled_tasks);
398
399    // Build resource timelines from schedule
400    let mut timelines = build_resource_timelines(project, &leveled_tasks);
401
402    // Build task priority map for shifting decisions
403    let task_priorities = build_task_priority_map(&leveled_tasks, project);
404
405    // Track milestones for L004 diagnostic
406    let original_milestone_dates: HashMap<TaskId, NaiveDate> = leveled_tasks
407        .iter()
408        .filter(|(_, t)| t.duration.minutes == 0) // Milestones have zero duration
409        .map(|(id, t)| (id.clone(), t.start))
410        .collect();
411
412    // Iterate until no more over-allocations or can't resolve
413    let max_iterations = leveled_tasks.len() * 10; // Prevent infinite loops
414    let mut iterations = 0;
415
416    // Check max delay factor constraint
417    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        // DETERMINISM: Collect ALL conflicts, then sort them
426        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        // DETERMINISM: Sort by (resource_id, start_date) for consistent ordering
438        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; // No more conflicts
442        };
443
444        // Get timeline capacity for this resource
445        let timeline_capacity = timelines
446            .get(&resource_id)
447            .map(|t| t.capacity)
448            .unwrap_or(1.0);
449
450        // Find candidates to shift (tasks in this period)
451        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            // Can't resolve this conflict - no tasks found
468            unresolved_conflicts.push(UnresolvedConflict {
469                resource_id: resource_id.clone(),
470                period: period.clone(),
471                reason: "No shiftable tasks found".into(),
472            });
473
474            // Emit L002 diagnostic
475            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        // Pick the best candidate to shift (deterministic due to Ord impl)
495        let candidate = candidates.pop().unwrap();
496        let task = leveled_tasks.get(&candidate.task_id).unwrap();
497        let original_start = task.start;
498
499        // Get resource units for this task
500        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        // Find next available slot
508        let timeline = timelines.get_mut(&resource_id).unwrap();
509        let duration_days = task.duration.as_days() as i64;
510
511        // Remove current usage before finding new slot
512        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        // Shift the task
522        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        // Check if this would exceed max delay factor
526        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                // Would exceed max delay - record as unresolved
536                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                // Re-add the usage we removed
545                timeline.add_usage(&candidate.task_id, original_start, task.finish, units);
546                continue;
547            }
548        }
549
550        // Update the task in our schedule
551        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            // Update assignments
558            for assignment in &mut task.assignments {
559                assignment.start = new_start;
560                assignment.finish = new_finish;
561            }
562        }
563
564        // Re-add usage at new position
565        timeline.add_usage(&candidate.task_id, new_start, new_finish, units);
566
567        // Collect conflict dates for structured reason
568        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                } // Safety limit
577            }
578            dates
579        };
580
581        // Record the shift with structured reason (RFC-0003)
582        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        // Emit L001 diagnostic
597        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    // Calculate new project end
613    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    // Emit L003 if project duration increased
624    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    // Check for delayed milestones (L004)
641    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    // Recalculate critical path if project was extended
663    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    // Calculate new project duration
670    let project_duration_days = count_working_days(project.start, new_project_end, calendar);
671
672    // Calculate peak utilization after leveling
673    let peak_utilization_after = calculate_peak_utilization(project, &leveled_tasks);
674
675    // Build metrics
676    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
710/// Calculate peak resource utilization across all resources
711fn 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
719/// Build resource timelines from scheduled tasks
720fn 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    // Initialize timelines for all resources
727    for resource in &project.resources {
728        timelines.insert(
729            resource.id.clone(),
730            ResourceTimeline::new(resource.id.clone(), resource.capacity),
731        );
732    }
733
734    // Add task assignments to timelines
735    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
751/// Build a map of task ID to (priority, task reference)
752fn build_task_priority_map(
753    tasks: &HashMap<TaskId, ScheduledTask>,
754    project: &Project,
755) -> HashMap<TaskId, (u32, ())> {
756    let mut map = HashMap::new();
757
758    // Flatten project tasks to get priorities
759    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
785/// Add working days to a date
786fn 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
804/// Count working days between two dates
805fn 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
823/// Recalculate critical path after leveling
824fn recalculate_critical_path(
825    tasks: &HashMap<TaskId, ScheduledTask>,
826    project_end: NaiveDate,
827) -> Vec<TaskId> {
828    // Tasks on critical path end on project end date and have zero slack
829    tasks
830        .iter()
831        .filter(|(_, task)| task.finish == project_end || task.slack.minutes == 0)
832        .map(|(id, _)| id.clone())
833        .collect()
834}
835
836/// Detect resource over-allocations without resolving them
837pub 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/// Utilization statistics for a single resource
856#[derive(Debug, Clone)]
857pub struct ResourceUtilization {
858    /// Resource identifier
859    pub resource_id: ResourceId,
860    /// Resource capacity (1.0 = 100%)
861    pub capacity: f32,
862    /// Total working days in the schedule period
863    pub total_days: i64,
864    /// Sum of daily usage (in resource-days)
865    pub used_days: f32,
866    /// Utilization percentage (0-100+, can exceed 100 if over-allocated)
867    pub utilization_percent: f32,
868    /// Peak daily usage
869    pub peak_usage: f32,
870    /// Number of days with any assignment
871    pub assigned_days: i64,
872}
873
874/// Summary of resource utilization across all resources
875#[derive(Debug, Clone)]
876pub struct UtilizationSummary {
877    /// Per-resource utilization statistics
878    pub resources: Vec<ResourceUtilization>,
879    /// Schedule start date
880    pub schedule_start: NaiveDate,
881    /// Schedule end date
882    pub schedule_end: NaiveDate,
883    /// Total working days in schedule period
884    pub total_working_days: i64,
885    /// Average utilization across all resources
886    pub average_utilization: f32,
887}
888
889/// Calculate resource utilization for a schedule
890pub 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    // Determine schedule date range
898    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    // Count working days in schedule period
912    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        // Calculate utilization: used_days / (total_working_days * capacity) * 100
938        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    // Calculate average utilization
957    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
972/// Count working days between two dates (inclusive of start, exclusive of end)
973fn 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(); // Monday
1053        let finish = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(); // Friday
1054
1055        // Block the first week
1056        timeline.add_usage(&"task1".into(), start, finish, 1.0);
1057
1058        // Find slot for a 3-day task
1059        let slot = timeline.find_available_slot(3, 1.0, start, &calendar);
1060
1061        // Should find slot starting after the blocked period
1062        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        // Both tasks start on day 0, both use dev at 100%
1081        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        // One task should have been shifted
1108        assert!(
1109            !result.shifted_tasks.is_empty(),
1110            "Should shift at least one task"
1111        );
1112
1113        // No unresolved conflicts
1114        assert!(
1115            result.unresolved_conflicts.is_empty(),
1116            "Should resolve all conflicts"
1117        );
1118
1119        // Tasks should no longer overlap
1120        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        // With dependencies, tasks already don't overlap
1152        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        // Original: both tasks 5 days, parallel = 5 days
1180        // After leveling: sequential = 10 days
1181        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        // Zero days returns start (line 461)
1194        assert_eq!(add_working_days(start, 0, &calendar), start);
1195
1196        // Negative days also returns start (line 461)
1197        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        // end < start returns 0 (line 480)
1207        assert_eq!(count_working_days(start, end, &calendar), 0);
1208
1209        // end == start also returns 0 (line 480)
1210        assert_eq!(count_working_days(start, start, &calendar), 0);
1211    }
1212
1213    #[test]
1214    fn shift_candidate_ordering_critical_vs_non_critical() {
1215        // Test line 224: when one is critical and other is not
1216        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        // Non-critical should be preferred (Greater) over critical (line 224-225)
1231        assert!(non_critical > critical);
1232        assert!(critical < non_critical);
1233    }
1234
1235    #[test]
1236    fn overallocated_periods_multiple_consecutive_days() {
1237        // Tests lines 86-93: continuing overallocation period with new tasks
1238        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        // Day 1: task1 + task2 overallocated
1244        timeline.add_usage(&"task1".into(), day1, day1, 0.7);
1245        timeline.add_usage(&"task2".into(), day1, day1, 0.7);
1246
1247        // Day 2: task1 + task3 overallocated (different set of tasks)
1248        timeline.add_usage(&"task1".into(), day2, day2, 0.7);
1249        timeline.add_usage(&"task3".into(), day2, day2, 0.7);
1250
1251        // Day 3: not overallocated
1252        timeline.add_usage(&"task1".into(), day3, day3, 0.3);
1253
1254        let periods = timeline.overallocated_periods();
1255
1256        // Should be one period spanning day1-day2
1257        assert_eq!(periods.len(), 1);
1258        assert_eq!(periods[0].start, day1);
1259        assert_eq!(periods[0].end, day2);
1260        // Line 91: task3 should be added to involved_tasks
1261        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        // Tests line 97: pushing completed period when non-consecutive overallocation starts
1269        let mut timeline = ResourceTimeline::new("dev".into(), 1.0);
1270        let day1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(); // Monday
1271        let day3 = NaiveDate::from_ymd_opt(2025, 1, 8).unwrap(); // Wednesday
1272
1273        // Day 1: overallocated
1274        timeline.add_usage(&"task1".into(), day1, day1, 1.5);
1275
1276        // Day 3 (gap on day 2): overallocated again
1277        timeline.add_usage(&"task2".into(), day3, day3, 1.5);
1278
1279        let periods = timeline.overallocated_periods();
1280
1281        // Should create two separate periods (line 97 pushes first period)
1282        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        // Test lines 438, 443: format! for qualified IDs and recursive add_tasks
1292        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        // Create nested tasks with different priorities
1298        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        // Add a root-level task for comparison
1314        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        // Schedule the project
1320        let solver = crate::CpmSolver::new();
1321        let schedule = solver.schedule(&project).unwrap();
1322
1323        // Build priority map - this calls the internal function
1324        let priority_map = build_task_priority_map(&schedule.tasks, &project);
1325
1326        // Verify qualified IDs are constructed correctly (line 438)
1327        assert!(priority_map.contains_key("phase1.task1.subtask1"));
1328        assert!(priority_map.contains_key("phase1.task1"));
1329        // Root level task has no prefix
1330        assert!(priority_map.contains_key("standalone"));
1331
1332        // Verify priorities are captured correctly
1333        assert_eq!(priority_map["standalone"].0, 500);
1334    }
1335
1336    #[test]
1337    fn unresolved_conflict_no_shiftable_tasks() {
1338        // Test lines 297-300: UnresolvedConflict when candidates.is_empty()
1339        // This happens when all conflicting tasks are on the critical path
1340        // and have zero slack
1341        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        // Add a resource
1349        project.resources.push(Resource::new("dev").capacity(1.0));
1350
1351        // Create two parallel critical tasks that conflict
1352        // Both tasks are independent (no dependencies) and same duration
1353        // so both could be critical depending on solver decisions
1354        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        // Make them sequential through dependency so both are critical
1369        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        // Both tasks should be critical
1382        assert!(schedule.tasks["critical1"].is_critical);
1383        assert!(schedule.tasks["critical2"].is_critical);
1384
1385        // Detect overallocation - there shouldn't be any since tasks are sequential
1386        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        // Test the recalculate_critical_path function
1396        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        // Create a simple chain
1402        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        // Both tasks in a linear chain should be critical
1422        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(); // Monday
1432        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        // 5 days used over ~5 working days = ~100% utilization
1445        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        // dev1 should have higher utilization than dev2
1474        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), // No assignments
1514        ];
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(); // Monday
1537        let end = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(); // Friday
1538
1539        // Monday through Friday = 5 working days
1540        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        // Same start and end returns 0 (function requires end > start)
1550        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        // Invalid range should return 0
1561        let count = count_schedule_working_days(start, end, &calendar);
1562        assert_eq!(count, 0);
1563    }
1564}