1use chrono::{Datelike, Local, NaiveDate, TimeDelta};
28use std::collections::{HashMap, VecDeque};
29
30use rust_decimal::prelude::FromPrimitive;
31use rust_decimal::Decimal;
32use std::path::PathBuf;
33use utf8proj_core::{
34 Assignment,
35 Calendar,
36 CostRange,
37 DependencyType,
38 Diagnostic,
40 DiagnosticCode,
41 DiagnosticEmitter,
42 Duration,
43 Explanation,
44 FeasibilityResult,
45 Money,
46 Project,
47 RateRange,
48 ResourceProfile,
49 ResourceRate,
50 Schedule,
51 ScheduleError,
52 ScheduledTask,
53 Scheduler,
54 SchedulingMode,
55 Task,
56 TaskConstraint,
57 TaskId,
58 TaskStatus,
59};
60
61pub mod bdd;
62pub mod cpm;
63pub mod dag;
64pub mod leveling;
65
66pub use bdd::{
67 BddConflictAnalyzer, BddStats, ConflictAnalysis, ConflictResolution, ResourceConflict,
68 ShiftDirection,
69};
70pub use leveling::{
71 calculate_utilization, detect_overallocations, level_resources, level_resources_with_options,
72 LevelingMetrics, LevelingOptions, LevelingReason, LevelingResult, LevelingStrategy,
73 OverallocationPeriod, ResourceTimeline, ResourceUtilization, ShiftedTask, UnresolvedConflict,
74 UtilizationSummary,
75};
76
77pub struct CpmSolver {
79 pub resource_leveling: bool,
81 pub status_date_override: Option<NaiveDate>,
84}
85
86impl CpmSolver {
87 pub fn new() -> Self {
88 Self {
89 resource_leveling: false,
90 status_date_override: None,
91 }
92 }
93
94 pub fn with_leveling() -> Self {
96 Self {
97 resource_leveling: true,
98 status_date_override: None,
99 }
100 }
101
102 pub fn with_status_date(date: NaiveDate) -> Self {
105 Self {
106 resource_leveling: false,
107 status_date_override: Some(date),
108 }
109 }
110
111 pub fn effective_status_date(&self, project: &Project) -> NaiveDate {
116 self.status_date_override
117 .or(project.status_date)
118 .unwrap_or_else(|| Local::now().date_naive())
119 }
120
121 fn analyze_constraint_effects(
123 &self,
124 project: &Project,
125 task: &Task,
126 ) -> Vec<utf8proj_core::ConstraintEffect> {
127 use utf8proj_core::{ConstraintEffect, ConstraintEffectType, TaskConstraint};
128
129 if task.constraints.is_empty() {
130 return vec![];
131 }
132
133 let schedule_result = Scheduler::schedule(self, project);
135 let schedule = match schedule_result {
136 Ok(s) => s,
137 Err(_) => {
138 return task
140 .constraints
141 .iter()
142 .map(|c| ConstraintEffect {
143 constraint: c.clone(),
144 effect: ConstraintEffectType::PushedStart, description: format!(
146 "{} (scheduling failed - effect unknown)",
147 Self::format_constraint(c)
148 ),
149 })
150 .collect();
151 }
152 };
153
154 let scheduled_task = match schedule.tasks.get(&task.id) {
156 Some(t) => t,
157 None => {
158 return task
159 .constraints
160 .iter()
161 .map(|c| ConstraintEffect {
162 constraint: c.clone(),
163 effect: ConstraintEffectType::Redundant,
164 description: format!(
165 "{} (task not in schedule)",
166 Self::format_constraint(c)
167 ),
168 })
169 .collect();
170 }
171 };
172
173 let es = scheduled_task.start;
174 let ef = scheduled_task.finish;
175 let ls = scheduled_task.late_start;
176 let lf = scheduled_task.late_finish;
177 let slack = scheduled_task.slack;
178 let zero_slack = Duration::zero();
179
180 task.constraints
181 .iter()
182 .map(|c| {
183 let (effect, description) = match c {
184 TaskConstraint::MustStartOn(date) => {
185 if es == *date && ls == *date {
186 (
187 ConstraintEffectType::Pinned,
188 format!("Task pinned to start on {}", date),
189 )
190 } else if es == *date {
191 (
192 ConstraintEffectType::PushedStart,
193 format!("Constraint pushed early start to {}", date),
194 )
195 } else if es > *date {
196 (
197 ConstraintEffectType::Redundant,
198 format!(
199 "Constraint date {} superseded by dependencies (ES={})",
200 date, es
201 ),
202 )
203 } else {
204 (
205 ConstraintEffectType::CappedLate,
206 format!("Constraint capped late start at {}", date),
207 )
208 }
209 }
210 TaskConstraint::MustFinishOn(date) => {
211 if ef == *date && lf == *date {
212 (
213 ConstraintEffectType::Pinned,
214 format!("Task pinned to finish on {}", date),
215 )
216 } else if ef == *date {
217 (
218 ConstraintEffectType::PushedStart,
219 format!("Constraint pushed early finish to {}", date),
220 )
221 } else if ef > *date {
222 (
223 ConstraintEffectType::Redundant,
224 format!(
225 "Constraint date {} superseded by dependencies (EF={})",
226 date, ef
227 ),
228 )
229 } else {
230 (
231 ConstraintEffectType::CappedLate,
232 format!("Constraint capped late finish at {}", date),
233 )
234 }
235 }
236 TaskConstraint::StartNoEarlierThan(date) => {
237 if es == *date {
238 (
239 ConstraintEffectType::PushedStart,
240 format!("Task starts exactly on constraint boundary {}", date),
241 )
242 } else if es > *date {
243 (
244 ConstraintEffectType::Redundant,
245 format!(
246 "Constraint {} redundant (dependencies already push ES to {})",
247 date, es
248 ),
249 )
250 } else {
251 (
253 ConstraintEffectType::PushedStart,
254 format!("Constraint pushed early start to {}", date),
255 )
256 }
257 }
258 TaskConstraint::StartNoLaterThan(date) => {
259 if ls == *date {
260 if slack == zero_slack {
261 (
262 ConstraintEffectType::CappedLate,
263 format!(
264 "Constraint made task critical (LS capped at {})",
265 date
266 ),
267 )
268 } else {
269 (
270 ConstraintEffectType::CappedLate,
271 format!("Constraint capped late start at {}", date),
272 )
273 }
274 } else if ls < *date {
275 (
276 ConstraintEffectType::Redundant,
277 format!(
278 "Constraint {} redundant (successors already require LS={})",
279 date, ls
280 ),
281 )
282 } else {
283 (
284 ConstraintEffectType::CappedLate,
285 format!("Constraint caps late start at {}", date),
286 )
287 }
288 }
289 TaskConstraint::FinishNoEarlierThan(date) => {
290 if ef == *date {
291 (
292 ConstraintEffectType::PushedStart,
293 format!("Task finishes exactly on constraint boundary {}", date),
294 )
295 } else if ef > *date {
296 (
297 ConstraintEffectType::Redundant,
298 format!(
299 "Constraint {} redundant (dependencies already push EF to {})",
300 date, ef
301 ),
302 )
303 } else {
304 (
305 ConstraintEffectType::PushedStart,
306 format!("Constraint pushed early finish to {}", date),
307 )
308 }
309 }
310 TaskConstraint::FinishNoLaterThan(date) => {
311 if lf == *date {
312 if slack == zero_slack {
313 (
314 ConstraintEffectType::CappedLate,
315 format!(
316 "Constraint made task critical (LF capped at {})",
317 date
318 ),
319 )
320 } else {
321 (
322 ConstraintEffectType::CappedLate,
323 format!("Constraint capped late finish at {}", date),
324 )
325 }
326 } else if lf < *date {
327 (
328 ConstraintEffectType::Redundant,
329 format!(
330 "Constraint {} redundant (successors already require LF={})",
331 date, lf
332 ),
333 )
334 } else {
335 (
336 ConstraintEffectType::CappedLate,
337 format!("Constraint caps late finish at {}", date),
338 )
339 }
340 }
341 };
342
343 ConstraintEffect {
344 constraint: c.clone(),
345 effect,
346 description,
347 }
348 })
349 .collect()
350 }
351
352 fn format_constraint(constraint: &utf8proj_core::TaskConstraint) -> String {
354 use utf8proj_core::TaskConstraint;
355 match constraint {
356 TaskConstraint::MustStartOn(d) => format!("MustStartOn({})", d),
357 TaskConstraint::MustFinishOn(d) => format!("MustFinishOn({})", d),
358 TaskConstraint::StartNoEarlierThan(d) => format!("StartNoEarlierThan({})", d),
359 TaskConstraint::StartNoLaterThan(d) => format!("StartNoLaterThan({})", d),
360 TaskConstraint::FinishNoEarlierThan(d) => format!("FinishNoEarlierThan({})", d),
361 TaskConstraint::FinishNoLaterThan(d) => format!("FinishNoLaterThan({})", d),
362 }
363 }
364}
365
366impl Default for CpmSolver {
367 fn default() -> Self {
368 Self::new()
369 }
370}
371
372#[derive(Clone, Debug)]
378struct TaskNode<'a> {
379 task: &'a Task,
380 original_duration_days: i64,
382 duration_days: i64,
384 early_start: i64,
386 early_finish: i64,
388 late_start: i64,
390 late_finish: i64,
392 slack: i64,
394 remaining_days: i64,
399 baseline_start_days: i64,
401 baseline_finish_days: i64,
403}
404
405#[derive(Clone, Debug)]
416enum ProgressState {
417 Complete {
419 actual_start_days: i64,
421 actual_finish_days: i64,
423 },
424 InProgress {
426 actual_start_days: i64,
428 remaining_days: i64,
430 },
431 NotStarted {
433 duration_days: i64,
435 },
436}
437
438fn classify_progress_state(
445 task: &Task,
446 project_start: NaiveDate,
447 calendar: &Calendar,
448) -> ProgressState {
449 let duration_days = get_task_duration_days(task);
450 let complete_pct = task.complete.unwrap_or(0.0);
451
452 if complete_pct >= 100.0 || task.actual_finish.is_some() {
453 let actual_start_days = task
455 .actual_start
456 .map(|d| date_to_working_days(project_start, d, calendar))
457 .unwrap_or(0);
458 let actual_finish_days = task
459 .actual_finish
460 .map(|d| date_to_working_days(project_start, d, calendar) + 1) .unwrap_or(actual_start_days + duration_days);
462
463 ProgressState::Complete {
464 actual_start_days,
465 actual_finish_days,
466 }
467 } else if complete_pct > 0.0 && task.actual_start.is_some() {
468 let remaining_days = if let Some(explicit) = &task.explicit_remaining {
470 explicit.as_days() as i64
471 } else {
472 ((duration_days as f64) * (1.0 - complete_pct as f64 / 100.0)).ceil() as i64
474 };
475 let actual_start_days =
476 date_to_working_days(project_start, task.actual_start.unwrap(), calendar);
477
478 ProgressState::InProgress {
479 actual_start_days,
480 remaining_days,
481 }
482 } else {
483 ProgressState::NotStarted { duration_days }
485 }
486}
487
488fn flatten_tasks_with_prefix<'a>(
505 tasks: &'a [Task],
506 prefix: &str,
507 map: &mut HashMap<String, &'a Task>,
508 context_map: &mut HashMap<String, String>,
509) {
510 for task in tasks {
511 let qualified_id = if prefix.is_empty() {
512 task.id.clone()
513 } else {
514 format!("{}.{}", prefix, task.id)
515 };
516
517 map.insert(qualified_id.clone(), task);
518 context_map.insert(qualified_id.clone(), prefix.to_string());
519
520 if !task.children.is_empty() {
521 flatten_tasks_with_prefix(&task.children, &qualified_id, map, context_map);
522 }
523 }
524}
525
526fn flatten_tasks<'a>(tasks: &'a [Task], map: &mut HashMap<String, &'a Task>) {
528 let mut context_map = HashMap::new();
529 flatten_tasks_with_prefix(tasks, "", map, &mut context_map);
530}
531
532fn flatten_tasks_with_context<'a>(
534 tasks: &'a [Task],
535) -> (HashMap<String, &'a Task>, HashMap<String, String>) {
536 let mut task_map = HashMap::new();
537 let mut context_map = HashMap::new();
538 flatten_tasks_with_prefix(tasks, "", &mut task_map, &mut context_map);
539 (task_map, context_map)
540}
541
542fn extract_task_from_infeasible_message(msg: &str) -> Option<String> {
545 if let Some(start) = msg.find("task '") {
547 let rest = &msg[start + 6..];
548 if let Some(end) = rest.find('\'') {
549 return Some(rest[..end].to_string());
550 }
551 }
552 None
553}
554
555fn build_children_map(task_map: &HashMap<String, &Task>) -> HashMap<String, Vec<String>> {
557 let mut children_map: HashMap<String, Vec<String>> = HashMap::new();
558
559 for qualified_id in task_map.keys() {
560 if let Some(dot_pos) = qualified_id.rfind('.') {
562 let parent_id = &qualified_id[..dot_pos];
563 children_map
564 .entry(parent_id.to_string())
565 .or_default()
566 .push(qualified_id.clone());
567 }
568 }
569
570 children_map
571}
572
573fn resolve_dependency_path(
579 dep_path: &str,
580 from_qualified_id: &str,
581 context_map: &HashMap<String, String>,
582 task_map: &HashMap<String, &Task>,
583) -> Option<String> {
584 if task_map.contains_key(dep_path) {
586 return Some(dep_path.to_string());
587 }
588
589 if dep_path.contains('.') {
591 return None;
592 }
593
594 if let Some(container) = context_map.get(from_qualified_id) {
596 let qualified = if container.is_empty() {
597 dep_path.to_string()
598 } else {
599 format!("{}.{}", container, dep_path)
600 };
601
602 if task_map.contains_key(&qualified) {
603 return Some(qualified);
604 }
605 }
606
607 None
608}
609
610fn get_task_duration_days(task: &Task) -> i64 {
623 if let Some(dur) = task.duration {
625 return dur.as_days().ceil() as i64;
626 }
627
628 if let Some(effort) = task.effort {
630 let total_units: f64 = if task.assigned.is_empty() {
631 1.0 } else {
633 task.assigned.iter().map(|r| r.units as f64).sum()
634 };
635
636 let effective_units = if total_units > 0.0 { total_units } else { 1.0 };
638 return (effort.as_days() / effective_units).ceil() as i64;
639 }
640
641 0
643}
644
645struct WorkingDayCache {
648 dates: Vec<NaiveDate>,
650}
651
652impl WorkingDayCache {
653 fn new(project_start: NaiveDate, max_days: i64, calendar: &Calendar) -> Self {
655 let mut dates = Vec::with_capacity((max_days + 1) as usize);
656 dates.push(project_start); let mut current = project_start;
659 for _ in 0..max_days {
660 current = current + TimeDelta::days(1);
661 while !calendar.is_working_day(current) {
662 current = current + TimeDelta::days(1);
663 }
664 dates.push(current);
665 }
666
667 Self { dates }
668 }
669
670 fn get(&self, working_days: i64) -> NaiveDate {
672 if working_days <= 0 {
673 return self.dates[0];
674 }
675 let idx = working_days as usize;
676 if idx < self.dates.len() {
677 self.dates[idx]
678 } else {
679 *self.dates.last().unwrap_or(&self.dates[0])
681 }
682 }
683}
684
685fn date_to_working_days(project_start: NaiveDate, target: NaiveDate, calendar: &Calendar) -> i64 {
687 if target <= project_start {
688 return 0;
689 }
690
691 let mut current = project_start;
692 let mut working_days = 0i64;
693
694 while current < target {
695 current = current + TimeDelta::days(1);
696 if calendar.is_working_day(current) {
697 working_days += 1;
698 }
699 }
700
701 working_days
702}
703
704struct TopoSortResult {
706 sorted_ids: Vec<String>,
708 successors: HashMap<String, Vec<String>>,
710}
711
712fn topological_sort(
719 tasks: &HashMap<String, &Task>,
720 context_map: &HashMap<String, String>,
721) -> Result<TopoSortResult, ScheduleError> {
722 let children_map = build_children_map(tasks);
724
725 let mut in_degree: HashMap<String, usize> = HashMap::new();
727 let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
728 let mut successors: HashMap<String, Vec<String>> = HashMap::new();
729
730 for id in tasks.keys() {
732 in_degree.insert(id.clone(), 0);
733 adjacency.insert(id.clone(), Vec::new());
734 successors.insert(id.clone(), Vec::new());
735 }
736
737 for (container_id, children) in &children_map {
739 for child_id in children {
740 if let Some(adj) = adjacency.get_mut(child_id) {
742 adj.push(container_id.clone());
743 }
744 if let Some(deg) = in_degree.get_mut(container_id) {
745 *deg += 1;
746 }
747 }
749 }
750
751 for (qualified_id, task) in tasks {
753 for dep in &task.depends {
754 let resolved =
756 resolve_dependency_path(&dep.predecessor, qualified_id, context_map, tasks);
757
758 if let Some(pred_id) = resolved {
759 if let Some(adj) = adjacency.get_mut(&pred_id) {
761 adj.push(qualified_id.clone());
762 }
763 if let Some(deg) = in_degree.get_mut(qualified_id) {
764 *deg += 1;
765 }
766 if let Some(succ) = successors.get_mut(&pred_id) {
768 succ.push(qualified_id.clone());
769 }
770 }
771 }
773 }
774
775 let mut queue: VecDeque<String> = VecDeque::new();
777 let mut result: Vec<String> = Vec::new();
778
779 for (id, °ree) in &in_degree {
781 if degree == 0 {
782 queue.push_back(id.clone());
783 }
784 }
785
786 while let Some(id) = queue.pop_front() {
787 result.push(id.clone());
788
789 if let Some(successors) = adjacency.get(&id) {
790 for successor in successors {
791 if let Some(deg) = in_degree.get_mut(successor) {
792 *deg -= 1;
793 if *deg == 0 {
794 queue.push_back(successor.clone());
795 }
796 }
797 }
798 }
799 }
800
801 if result.len() != tasks.len() {
803 let remaining: Vec<_> = tasks
804 .keys()
805 .filter(|id| !result.contains(id))
806 .cloned()
807 .collect();
808 return Err(ScheduleError::CircularDependency(format!(
809 "Cycle detected involving tasks: {:?}",
810 remaining
811 )));
812 }
813
814 Ok(TopoSortResult {
815 sorted_ids: result,
816 successors,
817 })
818}
819
820enum ResolvedAssignment<'a> {
826 Concrete {
828 rate: Option<&'a Money>,
829 #[allow(dead_code)]
830 resource_id: &'a str,
831 },
832 Abstract {
834 rate_range: Option<RateRange>,
835 #[allow(dead_code)]
836 profile_id: &'a str,
837 },
838}
839
840fn resolve_assignment<'a>(resource_id: &'a str, project: &'a Project) -> ResolvedAssignment<'a> {
842 if let Some(resource) = project.get_resource(resource_id) {
844 return ResolvedAssignment::Concrete {
845 rate: resource.rate.as_ref(),
846 resource_id,
847 };
848 }
849
850 if let Some(profile) = project.get_profile(resource_id) {
852 let rate_range = resolve_profile_rate(profile, project);
853 return ResolvedAssignment::Abstract {
854 rate_range,
855 profile_id: resource_id,
856 };
857 }
858
859 ResolvedAssignment::Concrete {
861 rate: None,
862 resource_id,
863 }
864}
865
866fn resolve_profile_rate(profile: &ResourceProfile, project: &Project) -> Option<RateRange> {
868 let base_rate = get_profile_rate_range(profile, project)?;
870
871 let trait_multiplier = calculate_trait_multiplier(&profile.traits, project);
873
874 Some(base_rate.apply_multiplier(trait_multiplier))
876}
877
878fn get_profile_rate_range(profile: &ResourceProfile, project: &Project) -> Option<RateRange> {
880 if let Some(ref rate) = profile.rate {
882 return match rate {
883 utf8proj_core::ResourceRate::Range(range) => Some(range.clone()),
884 utf8proj_core::ResourceRate::Fixed(money) => {
885 Some(RateRange::new(money.amount, money.amount))
887 }
888 };
889 }
890
891 if let Some(ref parent_id) = profile.specializes {
893 if let Some(parent) = project.get_profile(parent_id) {
894 return get_profile_rate_range(parent, project);
895 }
896 }
897
898 None
899}
900
901fn calculate_trait_multiplier(trait_ids: &[String], project: &Project) -> f64 {
903 let mut multiplier = 1.0;
904 for trait_id in trait_ids {
905 if let Some(t) = project.get_trait(trait_id) {
906 multiplier *= t.rate_multiplier;
907 }
908 }
909 multiplier
910}
911
912fn calculate_assignment_cost(
914 resource_id: &str,
915 units: f32,
916 duration_days: i64,
917 project: &Project,
918) -> (Option<CostRange>, bool) {
919 let resolved = resolve_assignment(resource_id, project);
920
921 match resolved {
922 ResolvedAssignment::Concrete { rate, .. } => {
923 if let Some(money) = rate {
924 let units_dec = Decimal::from_f32(units).unwrap_or(Decimal::ONE);
926 let days_dec = Decimal::from(duration_days);
927 let cost = money.amount * units_dec * days_dec;
928 let cost_range = CostRange::fixed(cost, &money.currency);
929 (Some(cost_range), false)
930 } else {
931 (None, false)
932 }
933 }
934 ResolvedAssignment::Abstract { rate_range, .. } => {
935 if let Some(range) = rate_range {
936 let units_dec = Decimal::from_f32(units).unwrap_or(Decimal::ONE);
938 let days_dec = Decimal::from(duration_days);
939 let factor = units_dec * days_dec;
940 let min_cost = range.min * factor;
941 let max_cost = range.max * factor;
942 let expected_cost = range.expected() * factor;
943 let currency = range
944 .currency
945 .clone()
946 .unwrap_or_else(|| project.currency.clone());
947
948 let cost_range = CostRange::new(min_cost, expected_cost, max_cost, currency);
949 (Some(cost_range), true)
950 } else {
951 (None, true)
952 }
953 }
954 }
955}
956
957fn aggregate_cost_ranges(ranges: &[CostRange]) -> Option<CostRange> {
959 if ranges.is_empty() {
960 return None;
961 }
962
963 let mut total = ranges[0].clone();
964 for range in &ranges[1..] {
965 total = total.add(range);
966 }
967 Some(total)
968}
969
970#[derive(Debug, Clone)]
976pub struct AnalysisConfig {
977 pub file: Option<PathBuf>,
979 pub cost_spread_threshold: f64,
981}
982
983impl Default for AnalysisConfig {
984 fn default() -> Self {
985 Self {
986 file: None,
987 cost_spread_threshold: 50.0,
988 }
989 }
990}
991
992impl AnalysisConfig {
993 pub fn new() -> Self {
994 Self::default()
995 }
996
997 pub fn with_file(mut self, file: impl Into<PathBuf>) -> Self {
998 self.file = Some(file.into());
999 self
1000 }
1001
1002 pub fn with_cost_spread_threshold(mut self, threshold: f64) -> Self {
1003 self.cost_spread_threshold = threshold;
1004 self
1005 }
1006}
1007
1008pub fn analyze_project(
1015 project: &Project,
1016 schedule: Option<&Schedule>,
1017 config: &AnalysisConfig,
1018 emitter: &mut dyn DiagnosticEmitter,
1019) {
1020 check_circular_specialization(project, config, emitter);
1022
1023 check_inverted_rate_ranges(project, config, emitter);
1025
1026 check_unknown_profile_references(project, config, emitter);
1028
1029 check_trait_multiplier_stack(project, config, emitter);
1031
1032 check_calendars(project, schedule, config, emitter);
1034
1035 check_unknown_traits(project, config, emitter);
1037
1038 check_profiles_without_rate(project, config, emitter);
1040
1041 let assignments_info = collect_assignment_info(project);
1043
1044 check_abstract_assignments(project, &assignments_info, config, emitter);
1046
1047 check_mixed_abstraction(project, &assignments_info, config, emitter);
1049
1050 if let Some(sched) = schedule {
1052 check_wide_cost_ranges(project, sched, config, emitter);
1053 }
1054
1055 check_unused_profiles(project, &assignments_info, config, emitter);
1057
1058 check_unused_traits(project, config, emitter);
1060
1061 check_unconstrained_tasks(project, config, emitter);
1063
1064 check_container_dependencies(project, config, emitter);
1066
1067 check_unresolved_dependencies(project, config, emitter);
1069
1070 if let Some(sched) = schedule {
1072 check_constraint_zero_slack(project, sched, config, emitter);
1073 }
1074
1075 if let Some(sched) = schedule {
1077 check_schedule_variance(sched, config, emitter);
1078 }
1079
1080 check_progress_conflicts(project, config, emitter);
1082
1083 if let Some(sched) = schedule {
1085 emit_project_summary(project, sched, &assignments_info, config, emitter);
1086 }
1087
1088 if let Some(sched) = schedule {
1090 check_project_status(sched, config, emitter);
1091 }
1092
1093 if let Some(sched) = schedule {
1095 check_earned_value(sched, config, emitter);
1096 }
1097}
1098
1099pub fn filter_task_diagnostics(task_id: &str, diagnostics: &[Diagnostic]) -> Vec<DiagnosticCode> {
1119 diagnostics
1120 .iter()
1121 .filter(|d| is_diagnostic_for_task(d, task_id))
1122 .map(|d| d.code.clone())
1123 .collect()
1124}
1125
1126fn is_diagnostic_for_task(diagnostic: &Diagnostic, task_id: &str) -> bool {
1128 let quoted_id = format!("'{}'", task_id);
1130
1131 match diagnostic.code {
1132 DiagnosticCode::C010NonWorkingDay | DiagnosticCode::C011CalendarMismatch => {
1134 diagnostic.message.contains("ed_id)
1135 }
1136
1137 DiagnosticCode::H004TaskUnconstrained => diagnostic.message.contains("ed_id),
1139
1140 DiagnosticCode::W001AbstractAssignment | DiagnosticCode::H001MixedAbstraction => {
1142 diagnostic.message.contains("ed_id)
1143 }
1144
1145 DiagnosticCode::W014ContainerDependency => {
1147 diagnostic.message.contains("ed_id)
1149 }
1150
1151 _ => false,
1157 }
1158}
1159
1160struct AssignmentInfo {
1162 assignments: HashMap<String, Vec<(String, bool)>>,
1164 used_profiles: std::collections::HashSet<String>,
1166 used_traits: std::collections::HashSet<String>,
1168 tasks_with_abstract: Vec<String>,
1170 tasks_with_mixed: Vec<String>,
1172}
1173
1174fn collect_assignment_info(project: &Project) -> AssignmentInfo {
1175 let mut info = AssignmentInfo {
1176 assignments: HashMap::new(),
1177 used_profiles: std::collections::HashSet::new(),
1178 used_traits: std::collections::HashSet::new(),
1179 tasks_with_abstract: Vec::new(),
1180 tasks_with_mixed: Vec::new(),
1181 };
1182
1183 for profile in &project.profiles {
1185 for trait_id in &profile.traits {
1186 info.used_traits.insert(trait_id.clone());
1187 }
1188 }
1189
1190 fn collect_from_tasks(
1192 tasks: &[Task],
1193 prefix: &str,
1194 project: &Project,
1195 info: &mut AssignmentInfo,
1196 ) {
1197 for task in tasks {
1198 let qualified_id = if prefix.is_empty() {
1199 task.id.clone()
1200 } else {
1201 format!("{}.{}", prefix, task.id)
1202 };
1203
1204 let mut has_concrete = false;
1205 let mut has_abstract = false;
1206
1207 for res_ref in &task.assigned {
1208 let is_abstract = project.get_profile(&res_ref.resource_id).is_some();
1209
1210 if is_abstract {
1211 has_abstract = true;
1212 info.used_profiles.insert(res_ref.resource_id.clone());
1213 } else {
1214 has_concrete = true;
1215 }
1216
1217 info.assignments
1218 .entry(res_ref.resource_id.clone())
1219 .or_default()
1220 .push((qualified_id.clone(), is_abstract));
1221 }
1222
1223 if has_abstract {
1224 info.tasks_with_abstract.push(qualified_id.clone());
1225 }
1226 if has_concrete && has_abstract {
1227 info.tasks_with_mixed.push(qualified_id.clone());
1228 }
1229
1230 if !task.children.is_empty() {
1232 collect_from_tasks(&task.children, &qualified_id, project, info);
1233 }
1234 }
1235 }
1236
1237 collect_from_tasks(&project.tasks, "", project, &mut info);
1238 info
1239}
1240
1241fn check_calendars(
1243 project: &Project,
1244 schedule: Option<&Schedule>,
1245 config: &AnalysisConfig,
1246 emitter: &mut dyn DiagnosticEmitter,
1247) {
1248 for calendar in &project.calendars {
1250 if calendar.working_hours.is_empty() {
1252 emitter.emit(
1253 Diagnostic::error(
1254 DiagnosticCode::C001ZeroWorkingHours,
1255 format!("calendar '{}' has no working hours defined", calendar.id),
1256 )
1257 .with_file(config.file.clone().unwrap_or_default())
1258 .with_hint("add working_hours: 09:00-12:00, 13:00-17:00"),
1259 );
1260 }
1261
1262 if calendar.working_days.is_empty() {
1264 emitter.emit(
1265 Diagnostic::error(
1266 DiagnosticCode::C002NoWorkingDays,
1267 format!("calendar '{}' has no working days defined", calendar.id),
1268 )
1269 .with_file(config.file.clone().unwrap_or_default())
1270 .with_hint("add working_days: mon-fri"),
1271 );
1272 }
1273
1274 if !calendar.working_days.is_empty() && calendar.working_days.len() < 3 {
1276 let pct = (calendar.working_days.len() as f32 / 7.0) * 100.0;
1277 emitter.emit(
1278 Diagnostic::new(
1279 DiagnosticCode::C020LowAvailability,
1280 format!(
1281 "calendar '{}' has low availability ({:.0}% working days, {} days/week)",
1282 calendar.id,
1283 pct,
1284 calendar.working_days.len()
1285 ),
1286 )
1287 .with_file(config.file.clone().unwrap_or_default())
1288 .with_note("low availability may extend project duration significantly"),
1289 );
1290 }
1291
1292 let daily_hours: u32 = calendar
1294 .working_hours
1295 .iter()
1296 .map(|r| ((r.end.saturating_sub(r.start)) / 60) as u32)
1297 .sum();
1298 if daily_hours > 16 {
1299 emitter.emit(
1300 Diagnostic::new(
1301 DiagnosticCode::C022SuspiciousHours,
1302 format!(
1303 "calendar '{}' has {} hours/day which may be unrealistic",
1304 calendar.id, daily_hours
1305 ),
1306 )
1307 .with_file(config.file.clone().unwrap_or_default())
1308 .with_hint("typical work day is 8 hours"),
1309 );
1310 } else if calendar.working_days.len() == 7 && daily_hours >= 8 {
1311 emitter.emit(
1312 Diagnostic::new(
1313 DiagnosticCode::C022SuspiciousHours,
1314 format!(
1315 "calendar '{}' has 7-day workweek with {} hours/day",
1316 calendar.id, daily_hours
1317 ),
1318 )
1319 .with_file(config.file.clone().unwrap_or_default())
1320 .with_note("verify this is intentional (e.g., 24/7 operations)"),
1321 );
1322 }
1323
1324 for holiday in &calendar.holidays {
1326 let weekday = holiday.start.weekday().num_days_from_sunday() as u8;
1327 if !calendar.working_days.contains(&weekday) {
1328 let day_name = match weekday {
1329 0 => "Sunday",
1330 1 => "Monday",
1331 2 => "Tuesday",
1332 3 => "Wednesday",
1333 4 => "Thursday",
1334 5 => "Friday",
1335 6 => "Saturday",
1336 _ => "Unknown",
1337 };
1338 emitter.emit(
1339 Diagnostic::new(
1340 DiagnosticCode::C023RedundantHoliday,
1341 format!(
1342 "holiday '{}' on {} falls on {}, which is already a non-working day",
1343 holiday.name, holiday.start, day_name
1344 ),
1345 )
1346 .with_file(config.file.clone().unwrap_or_default())
1347 .with_note("this holiday has no scheduling impact"),
1348 );
1349 }
1350 }
1351 }
1352
1353 fn collect_leaf_tasks(tasks: &[Task]) -> Vec<&Task> {
1356 let mut leaves = Vec::new();
1357 for task in tasks {
1358 if task.children.is_empty() {
1359 leaves.push(task);
1360 } else {
1361 leaves.extend(collect_leaf_tasks(&task.children));
1362 }
1363 }
1364 leaves
1365 }
1366
1367 for task in collect_leaf_tasks(&project.tasks) {
1368 let project_calendar = &project.calendar;
1369
1370 for assignment in &task.assigned {
1371 if let Some(resource) = project.get_resource(&assignment.resource_id) {
1372 if let Some(rc) = resource.calendar.as_ref() {
1373 if project_calendar != rc {
1374 emitter.emit(
1375 Diagnostic::warning(
1376 DiagnosticCode::C011CalendarMismatch,
1377 format!(
1378 "task '{}' uses project calendar '{}' but assigned resource '{}' uses calendar '{}'",
1379 task.id, project_calendar, resource.id, rc
1380 ),
1381 )
1382 .with_file(config.file.clone().unwrap_or_default())
1383 .with_note("different calendars may cause scheduling conflicts")
1384 .with_hint("ensure project and resource calendars are compatible"),
1385 );
1386 }
1387 }
1388 }
1389 }
1390 }
1391
1392 if let Some(sched) = schedule {
1394 let project_calendar = project
1396 .calendars
1397 .iter()
1398 .find(|c| c.id == project.calendar)
1399 .cloned()
1400 .unwrap_or_default();
1401
1402 for (task_id, task_schedule) in &sched.tasks {
1403 let start_weekday = task_schedule.start.weekday().num_days_from_sunday() as u8;
1405 if !project_calendar.working_days.contains(&start_weekday) {
1406 let day_name = task_schedule.start.format("%A").to_string();
1407 emitter.emit(
1408 Diagnostic::warning(
1409 DiagnosticCode::C010NonWorkingDay,
1410 format!(
1411 "task '{}' scheduled to start on {} ({}), which is a non-working day",
1412 task_id, task_schedule.start, day_name
1413 ),
1414 )
1415 .with_file(config.file.clone().unwrap_or_default())
1416 .with_hint("adjust task constraints or calendar"),
1417 );
1418 }
1419 }
1420 }
1421}
1422
1423fn check_circular_specialization(
1425 project: &Project,
1426 config: &AnalysisConfig,
1427 emitter: &mut dyn DiagnosticEmitter,
1428) {
1429 for profile in &project.profiles {
1430 if let Some(cycle) = detect_specialization_cycle(profile, project) {
1431 let cycle_str = cycle.join(" -> ");
1432 emitter.emit(
1433 Diagnostic::error(
1434 DiagnosticCode::E001CircularSpecialization,
1435 format!("circular specialization detected: {}", cycle_str),
1436 )
1437 .with_file(config.file.clone().unwrap_or_default())
1438 .with_note(format!("cycle: {}", cycle_str))
1439 .with_hint("remove one specialization to break the cycle"),
1440 );
1441 }
1442 }
1443}
1444
1445fn detect_specialization_cycle(
1447 profile: &ResourceProfile,
1448 project: &Project,
1449) -> Option<Vec<String>> {
1450 let mut visited = std::collections::HashSet::new();
1451 let mut path = Vec::new();
1452 let mut current = Some(profile);
1453
1454 while let Some(p) = current {
1455 if visited.contains(&p.id) {
1456 let cycle_start = path.iter().position(|id| id == &p.id).unwrap();
1458 let mut cycle: Vec<String> = path[cycle_start..].to_vec();
1459 cycle.push(p.id.clone());
1460 return Some(cycle);
1461 }
1462
1463 visited.insert(p.id.clone());
1464 path.push(p.id.clone());
1465
1466 current = p.specializes.as_ref().and_then(|s| project.get_profile(s));
1467 }
1468
1469 None
1470}
1471
1472fn check_inverted_rate_ranges(
1474 project: &Project,
1475 config: &AnalysisConfig,
1476 emitter: &mut dyn DiagnosticEmitter,
1477) {
1478 for profile in &project.profiles {
1479 if let Some(ResourceRate::Range(ref range)) = profile.rate {
1480 if range.min > range.max {
1481 emitter.emit(
1482 Diagnostic::error(
1483 DiagnosticCode::R102InvertedRateRange,
1484 format!(
1485 "profile '{}' has inverted rate range: min ({}) > max ({})",
1486 profile.id, range.min, range.max
1487 ),
1488 )
1489 .with_file(config.file.clone().unwrap_or_default())
1490 .with_note("rate range min must be less than or equal to max")
1491 .with_hint("swap the min and max values"),
1492 );
1493 }
1494 }
1495 }
1496}
1497
1498fn check_unknown_profile_references(
1500 project: &Project,
1501 config: &AnalysisConfig,
1502 emitter: &mut dyn DiagnosticEmitter,
1503) {
1504 let defined_profiles: std::collections::HashSet<_> =
1505 project.profiles.iter().map(|p| p.id.as_str()).collect();
1506
1507 for profile in &project.profiles {
1508 if let Some(ref parent_id) = profile.specializes {
1509 if !defined_profiles.contains(parent_id.as_str()) {
1510 emitter.emit(
1511 Diagnostic::error(
1512 DiagnosticCode::R104UnknownProfile,
1513 format!(
1514 "profile '{}' specializes unknown profile '{}'",
1515 profile.id, parent_id
1516 ),
1517 )
1518 .with_file(config.file.clone().unwrap_or_default())
1519 .with_note("specialized profile must be defined")
1520 .with_hint(format!(
1521 "define profile '{}' or remove the specialization",
1522 parent_id
1523 )),
1524 );
1525 }
1526 }
1527 }
1528}
1529
1530fn check_trait_multiplier_stack(
1532 project: &Project,
1533 config: &AnalysisConfig,
1534 emitter: &mut dyn DiagnosticEmitter,
1535) {
1536 const MULTIPLIER_THRESHOLD: f64 = 2.0;
1537
1538 for profile in &project.profiles {
1539 if profile.traits.is_empty() {
1540 continue;
1541 }
1542
1543 let mut compound_multiplier = 1.0;
1545 let mut applied_traits = Vec::new();
1546
1547 for trait_id in &profile.traits {
1548 if let Some(t) = project.traits.iter().find(|t| t.id == *trait_id) {
1549 compound_multiplier *= t.rate_multiplier;
1550 applied_traits.push(format!("{}(×{:.2})", trait_id, t.rate_multiplier));
1551 }
1552 }
1553
1554 if compound_multiplier > MULTIPLIER_THRESHOLD {
1555 emitter.emit(
1556 Diagnostic::warning(
1557 DiagnosticCode::R012TraitMultiplierStack,
1558 format!(
1559 "profile '{}' has trait multiplier stack {:.2} (exceeds {:.1})",
1560 profile.id, compound_multiplier, MULTIPLIER_THRESHOLD
1561 ),
1562 )
1563 .with_file(config.file.clone().unwrap_or_default())
1564 .with_note(format!("applied traits: {}", applied_traits.join(" × ")))
1565 .with_hint("consider reducing trait multipliers or removing some traits"),
1566 );
1567 }
1568 }
1569}
1570
1571fn check_unknown_traits(
1573 project: &Project,
1574 config: &AnalysisConfig,
1575 emitter: &mut dyn DiagnosticEmitter,
1576) {
1577 let defined_traits: std::collections::HashSet<_> =
1578 project.traits.iter().map(|t| t.id.as_str()).collect();
1579
1580 for profile in &project.profiles {
1581 for trait_id in &profile.traits {
1582 if !defined_traits.contains(trait_id.as_str()) {
1583 emitter.emit(
1584 Diagnostic::new(
1585 DiagnosticCode::W003UnknownTrait,
1586 format!(
1587 "profile '{}' references unknown trait '{}'",
1588 profile.id, trait_id
1589 ),
1590 )
1591 .with_file(config.file.clone().unwrap_or_default())
1592 .with_note("unknown traits are ignored (multiplier = 1.0)")
1593 .with_hint("define the trait or remove the reference"),
1594 );
1595 }
1596 }
1597 }
1598}
1599
1600fn check_profiles_without_rate(
1602 project: &Project,
1603 config: &AnalysisConfig,
1604 emitter: &mut dyn DiagnosticEmitter,
1605) {
1606 let mut assigned_profiles: HashMap<String, Vec<String>> = HashMap::new();
1608
1609 fn collect_assignments(tasks: &[Task], prefix: &str, map: &mut HashMap<String, Vec<String>>) {
1610 for task in tasks {
1611 let qualified_id = if prefix.is_empty() {
1612 task.id.clone()
1613 } else {
1614 format!("{}.{}", prefix, task.id)
1615 };
1616
1617 for res_ref in &task.assigned {
1618 map.entry(res_ref.resource_id.clone())
1619 .or_default()
1620 .push(qualified_id.clone());
1621 }
1622
1623 if !task.children.is_empty() {
1624 collect_assignments(&task.children, &qualified_id, map);
1625 }
1626 }
1627 }
1628
1629 collect_assignments(&project.tasks, "", &mut assigned_profiles);
1630
1631 for profile in &project.profiles {
1633 let tasks = match assigned_profiles.get(&profile.id) {
1635 Some(t) if !t.is_empty() => t,
1636 _ => continue,
1637 };
1638
1639 let has_rate = get_profile_rate_range(profile, project).is_some();
1641
1642 if !has_rate {
1643 let task_list = if tasks.len() <= 3 {
1644 tasks.join(", ")
1645 } else {
1646 format!("{}, ... ({} tasks)", tasks[..2].join(", "), tasks.len())
1647 };
1648
1649 emitter.emit(
1650 Diagnostic::new(
1651 DiagnosticCode::E002ProfileWithoutRate,
1652 format!(
1653 "profile '{}' has no rate defined but is assigned to tasks",
1654 profile.id
1655 ),
1656 )
1657 .with_file(config.file.clone().unwrap_or_default())
1658 .with_note(format!(
1659 "cost calculations will be incomplete for: {}",
1660 task_list
1661 ))
1662 .with_hint(
1663 "add 'rate:' or 'rate_range:' block, or specialize from a profile with rate",
1664 ),
1665 );
1666 }
1667 }
1668}
1669
1670fn check_abstract_assignments(
1672 project: &Project,
1673 info: &AssignmentInfo,
1674 config: &AnalysisConfig,
1675 emitter: &mut dyn DiagnosticEmitter,
1676) {
1677 let mut task_map: HashMap<String, &Task> = HashMap::new();
1679 flatten_tasks(&project.tasks, &mut task_map);
1680
1681 for task_id in &info.tasks_with_abstract {
1682 if let Some(task) = task_map.get(task_id) {
1683 for res_ref in &task.assigned {
1684 if let Some(profile) = project.get_profile(&res_ref.resource_id) {
1685 let rate_range = resolve_profile_rate(profile, project);
1687 let cost_note = if let Some(range) = rate_range {
1688 let spread = range.spread_percent();
1689 format!(
1690 "cost range is ${} - ${} ({:.0}% spread)",
1691 range.min, range.max, spread
1692 )
1693 } else {
1694 "cost range is unknown (no rate defined)".to_string()
1695 };
1696
1697 emitter.emit(
1698 Diagnostic::new(
1699 DiagnosticCode::W001AbstractAssignment,
1700 format!(
1701 "task '{}' is assigned to abstract profile '{}'",
1702 task_id, res_ref.resource_id
1703 ),
1704 )
1705 .with_file(config.file.clone().unwrap_or_default())
1706 .with_note(cost_note)
1707 .with_hint("assign a concrete resource to lock in exact cost"),
1708 );
1709 }
1710 }
1711 }
1712 }
1713}
1714
1715fn check_mixed_abstraction(
1717 project: &Project,
1718 info: &AssignmentInfo,
1719 config: &AnalysisConfig,
1720 emitter: &mut dyn DiagnosticEmitter,
1721) {
1722 let mut task_map: HashMap<String, &Task> = HashMap::new();
1723 flatten_tasks(&project.tasks, &mut task_map);
1724
1725 for task_id in &info.tasks_with_mixed {
1726 if let Some(task) = task_map.get(task_id) {
1727 let _concrete: Vec<_> = task
1728 .assigned
1729 .iter()
1730 .filter(|r| project.get_profile(&r.resource_id).is_none())
1731 .map(|r| r.resource_id.as_str())
1732 .collect();
1733 let abstract_: Vec<_> = task
1734 .assigned
1735 .iter()
1736 .filter(|r| project.get_profile(&r.resource_id).is_some())
1737 .map(|r| r.resource_id.as_str())
1738 .collect();
1739
1740 if !abstract_.is_empty() {
1741 emitter.emit(
1742 Diagnostic::new(
1743 DiagnosticCode::H001MixedAbstraction,
1744 format!("task '{}' mixes concrete and abstract assignments", task_id),
1745 )
1746 .with_file(config.file.clone().unwrap_or_default())
1747 .with_note("this is valid but may indicate incomplete refinement")
1748 .with_hint(format!(
1749 "consider refining '{}' to a concrete resource",
1750 abstract_.join("', '")
1751 )),
1752 );
1753 }
1754 }
1755 }
1756}
1757
1758fn check_wide_cost_ranges(
1760 project: &Project,
1761 schedule: &Schedule,
1762 config: &AnalysisConfig,
1763 emitter: &mut dyn DiagnosticEmitter,
1764) {
1765 for (task_id, scheduled_task) in &schedule.tasks {
1766 if let Some(ref cost_range) = scheduled_task.cost_range {
1767 let spread = cost_range.spread_percent();
1768 if spread > config.cost_spread_threshold {
1769 let mut contributors: Vec<String> = Vec::new();
1771
1772 let mut task_map: HashMap<String, &Task> = HashMap::new();
1774 flatten_tasks(&project.tasks, &mut task_map);
1775
1776 if let Some(task) = task_map.get(task_id) {
1777 for res_ref in &task.assigned {
1778 if let Some(profile) = project.get_profile(&res_ref.resource_id) {
1779 if let Some(rate) = resolve_profile_rate(profile, project) {
1780 contributors.push(format!(
1781 "{}: ${} - ${}/day",
1782 res_ref.resource_id, rate.min, rate.max
1783 ));
1784 }
1785 for trait_id in &profile.traits {
1786 if let Some(t) = project.get_trait(trait_id) {
1787 if (t.rate_multiplier - 1.0).abs() > 0.01 {
1788 contributors.push(format!(
1789 "{} trait: {}x multiplier",
1790 trait_id, t.rate_multiplier
1791 ));
1792 }
1793 }
1794 }
1795 }
1796 }
1797 }
1798
1799 let mut diag = Diagnostic::new(
1800 DiagnosticCode::W002WideCostRange,
1801 format!(
1802 "task '{}' has wide cost uncertainty ({:.0}% spread)",
1803 task_id, spread
1804 ),
1805 )
1806 .with_file(config.file.clone().unwrap_or_default())
1807 .with_note(format!(
1808 "cost range: ${} - ${} (expected: ${})",
1809 cost_range.min, cost_range.max, cost_range.expected
1810 ));
1811
1812 if !contributors.is_empty() {
1813 diag = diag.with_note(format!("contributors: {}", contributors.join(", ")));
1814 }
1815
1816 diag = diag.with_hint("narrow the profile rate range or assign concrete resources");
1817
1818 emitter.emit(diag);
1819 }
1820 }
1821 }
1822}
1823
1824fn check_unused_profiles(
1826 project: &Project,
1827 info: &AssignmentInfo,
1828 config: &AnalysisConfig,
1829 emitter: &mut dyn DiagnosticEmitter,
1830) {
1831 for profile in &project.profiles {
1832 if !info.used_profiles.contains(&profile.id) {
1833 emitter.emit(
1834 Diagnostic::new(
1835 DiagnosticCode::H002UnusedProfile,
1836 format!("profile '{}' is defined but never assigned", profile.id),
1837 )
1838 .with_file(config.file.clone().unwrap_or_default())
1839 .with_hint("assign to tasks or remove if no longer needed"),
1840 );
1841 }
1842 }
1843}
1844
1845fn check_unused_traits(
1847 project: &Project,
1848 config: &AnalysisConfig,
1849 emitter: &mut dyn DiagnosticEmitter,
1850) {
1851 let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
1853 for profile in &project.profiles {
1854 for trait_id in &profile.traits {
1855 used.insert(trait_id.clone());
1856 }
1857 }
1858
1859 for t in &project.traits {
1860 if !used.contains(&t.id) {
1861 emitter.emit(
1862 Diagnostic::new(
1863 DiagnosticCode::H003UnusedTrait,
1864 format!("trait '{}' is defined but never referenced", t.id),
1865 )
1866 .with_file(config.file.clone().unwrap_or_default())
1867 .with_hint("add to profile traits or remove if no longer needed"),
1868 );
1869 }
1870 }
1871}
1872
1873fn check_unconstrained_tasks(
1882 project: &Project,
1883 config: &AnalysisConfig,
1884 emitter: &mut dyn DiagnosticEmitter,
1885) {
1886 check_unconstrained_recursive(&project.tasks, "", config, emitter);
1887}
1888
1889fn check_unconstrained_recursive(
1891 tasks: &[Task],
1892 parent_path: &str,
1893 config: &AnalysisConfig,
1894 emitter: &mut dyn DiagnosticEmitter,
1895) {
1896 for task in tasks {
1897 let task_path = if parent_path.is_empty() {
1898 task.id.clone()
1899 } else {
1900 format!("{}.{}", parent_path, task.id)
1901 };
1902
1903 if task.children.is_empty() {
1904 let has_predecessors = !task.depends.is_empty();
1906 let has_date_constraint = !task.constraints.is_empty();
1907
1908 if !has_predecessors && !has_date_constraint {
1909 emitter.emit(
1910 Diagnostic::new(
1911 DiagnosticCode::H004TaskUnconstrained,
1912 format!(
1913 "task '{}' has no predecessors or date constraints",
1914 task.name
1915 ),
1916 )
1917 .with_file(config.file.clone().unwrap_or_default())
1918 .with_note(format!(
1919 "'{}' will start on project start date (ASAP scheduling)",
1920 task.name
1921 ))
1922 .with_hint(
1923 "add 'depends:' or 'start_no_earlier_than:' to anchor scheduling logic",
1924 ),
1925 );
1926 }
1927 } else {
1928 check_unconstrained_recursive(&task.children, &task_path, config, emitter);
1930 }
1931 }
1932}
1933
1934fn check_container_dependencies(
1942 project: &Project,
1943 config: &AnalysisConfig,
1944 emitter: &mut dyn DiagnosticEmitter,
1945) {
1946 check_container_deps_recursive(&project.tasks, "", config, emitter);
1947}
1948
1949fn check_container_deps_recursive(
1951 tasks: &[Task],
1952 parent_path: &str,
1953 config: &AnalysisConfig,
1954 emitter: &mut dyn DiagnosticEmitter,
1955) {
1956 for task in tasks {
1957 let task_path = if parent_path.is_empty() {
1958 task.id.clone()
1959 } else {
1960 format!("{}.{}", parent_path, task.id)
1961 };
1962
1963 if !task.children.is_empty() {
1965 if !task.depends.is_empty() {
1967 let container_deps: std::collections::HashSet<_> =
1969 task.depends.iter().map(|d| d.predecessor.clone()).collect();
1970
1971 for child in &task.children {
1973 let child_deps: std::collections::HashSet<_> = child
1975 .depends
1976 .iter()
1977 .map(|d| d.predecessor.clone())
1978 .collect();
1979
1980 let has_container_dep = container_deps.iter().any(|d| child_deps.contains(d));
1982
1983 if !has_container_dep {
1984 let deps_str = task
1986 .depends
1987 .iter()
1988 .map(|d| d.predecessor.as_str())
1989 .collect::<Vec<_>>()
1990 .join(", ");
1991
1992 emitter.emit(
1993 Diagnostic::new(
1994 DiagnosticCode::W014ContainerDependency,
1995 format!(
1996 "container '{}' depends on [{}] but child '{}' has no matching dependencies",
1997 task.name, deps_str, child.name
1998 ),
1999 )
2000 .with_file(config.file.clone().unwrap_or_default())
2001 .with_note(format!(
2002 "MS Project behavior: '{}' would be blocked until [{}] completes",
2003 child.name, deps_str
2004 ))
2005 .with_note(format!(
2006 "utf8proj behavior: '{}' can start immediately (explicit dependencies only)",
2007 child.name
2008 ))
2009 .with_hint(format!(
2010 "add 'depends: {}' to match MS Project behavior",
2011 task.depends[0].predecessor
2012 )),
2013 );
2014 }
2015 }
2016 }
2017
2018 check_container_deps_recursive(&task.children, &task_path, config, emitter);
2020 }
2021 }
2022}
2023
2024fn check_unresolved_dependencies(
2029 project: &Project,
2030 config: &AnalysisConfig,
2031 emitter: &mut dyn DiagnosticEmitter,
2032) {
2033 let all_task_ids = collect_all_task_ids(&project.tasks, "");
2035
2036 check_deps_recursive(&project.tasks, "", &all_task_ids, config, emitter);
2038}
2039
2040fn collect_all_task_ids(tasks: &[Task], parent_path: &str) -> std::collections::HashSet<String> {
2042 let mut ids = std::collections::HashSet::new();
2043
2044 for task in tasks {
2045 let qualified_path = if parent_path.is_empty() {
2046 task.id.clone()
2047 } else {
2048 format!("{}.{}", parent_path, task.id)
2049 };
2050
2051 ids.insert(task.id.clone());
2053 ids.insert(qualified_path.clone());
2054
2055 let child_ids = collect_all_task_ids(&task.children, &qualified_path);
2057 ids.extend(child_ids);
2058 }
2059
2060 ids
2061}
2062
2063fn check_deps_recursive(
2065 tasks: &[Task],
2066 parent_path: &str,
2067 all_task_ids: &std::collections::HashSet<String>,
2068 config: &AnalysisConfig,
2069 emitter: &mut dyn DiagnosticEmitter,
2070) {
2071 for task in tasks {
2072 let task_path = if parent_path.is_empty() {
2073 task.id.clone()
2074 } else {
2075 format!("{}.{}", parent_path, task.id)
2076 };
2077
2078 for dep in &task.depends {
2080 let resolved = resolve_dependency_reference(&dep.predecessor, &task_path, all_task_ids);
2082
2083 if !resolved {
2084 emitter.emit(
2085 Diagnostic::new(
2086 DiagnosticCode::W007UnresolvedDependency,
2087 format!(
2088 "task '{}' depends on '{}' which does not exist",
2089 task.name, dep.predecessor
2090 ),
2091 )
2092 .with_file(config.file.clone().unwrap_or_default())
2093 .with_note(format!(
2094 "dependency '{}' cannot be resolved to any task in the project",
2095 dep.predecessor
2096 ))
2097 .with_hint("check spelling or use qualified path (e.g., 'phase1.task1')"),
2098 );
2099 }
2100 }
2101
2102 check_deps_recursive(&task.children, &task_path, all_task_ids, config, emitter);
2104 }
2105}
2106
2107fn resolve_dependency_reference(
2109 predecessor: &str,
2110 current_task_path: &str,
2111 all_task_ids: &std::collections::HashSet<String>,
2112) -> bool {
2113 if all_task_ids.contains(predecessor) {
2115 return true;
2116 }
2117
2118 for id in all_task_ids {
2120 if id.ends_with(&format!(".{}", predecessor)) || id == predecessor {
2121 return true;
2122 }
2123 }
2124
2125 if let Some(parent_path) = current_task_path.rsplit_once('.').map(|(p, _)| p) {
2127 let sibling_path = format!("{}.{}", parent_path, predecessor);
2128 if all_task_ids.contains(&sibling_path) {
2129 return true;
2130 }
2131 }
2132
2133 false
2134}
2135
2136pub fn fix_container_dependencies(project: &mut Project) -> usize {
2174 fix_container_deps_recursive(&mut project.tasks)
2175}
2176
2177fn fix_container_deps_recursive(tasks: &mut [Task]) -> usize {
2179 let mut fixed_count = 0;
2180
2181 for task in tasks.iter_mut() {
2182 if !task.children.is_empty() && !task.depends.is_empty() {
2184 let container_deps: std::collections::HashSet<_> =
2186 task.depends.iter().map(|d| d.predecessor.clone()).collect();
2187
2188 for child in &mut task.children {
2190 let child_deps: std::collections::HashSet<_> = child
2192 .depends
2193 .iter()
2194 .map(|d| d.predecessor.clone())
2195 .collect();
2196
2197 let has_container_dep = container_deps.iter().any(|d| child_deps.contains(d));
2199
2200 if !has_container_dep {
2201 for dep in &task.depends {
2203 if !child_deps.contains(&dep.predecessor) {
2204 child.depends.push(utf8proj_core::Dependency {
2205 predecessor: dep.predecessor.clone(),
2206 dep_type: dep.dep_type,
2207 lag: dep.lag,
2208 });
2209 fixed_count += 1;
2210 }
2211 }
2212 }
2213 }
2214
2215 fixed_count += fix_container_deps_recursive(&mut task.children);
2217 } else if !task.children.is_empty() {
2218 fixed_count += fix_container_deps_recursive(&mut task.children);
2220 }
2221 }
2222
2223 fixed_count
2224}
2225
2226fn check_constraint_zero_slack(
2228 project: &Project,
2229 schedule: &Schedule,
2230 config: &AnalysisConfig,
2231 emitter: &mut dyn DiagnosticEmitter,
2232) {
2233 let mut task_map: HashMap<String, &Task> = HashMap::new();
2235 flatten_tasks(&project.tasks, &mut task_map);
2236
2237 for (task_id, scheduled) in &schedule.tasks {
2238 if scheduled.slack != Duration::zero() {
2240 continue;
2241 }
2242
2243 let Some(task) = task_map.get(task_id) else {
2245 continue;
2246 };
2247
2248 let has_ceiling_constraint = task.constraints.iter().any(|c| {
2251 matches!(
2252 c,
2253 TaskConstraint::MustStartOn(_)
2254 | TaskConstraint::MustFinishOn(_)
2255 | TaskConstraint::StartNoLaterThan(_)
2256 | TaskConstraint::FinishNoLaterThan(_)
2257 )
2258 });
2259
2260 if has_ceiling_constraint {
2261 let constraint_desc = task
2263 .constraints
2264 .iter()
2265 .find_map(|c| match c {
2266 TaskConstraint::MustStartOn(d) => Some(format!("must_start_on: {}", d)),
2267 TaskConstraint::MustFinishOn(d) => Some(format!("must_finish_on: {}", d)),
2268 TaskConstraint::StartNoLaterThan(d) => {
2269 Some(format!("start_no_later_than: {}", d))
2270 }
2271 TaskConstraint::FinishNoLaterThan(d) => {
2272 Some(format!("finish_no_later_than: {}", d))
2273 }
2274 _ => None,
2275 })
2276 .unwrap_or_else(|| "constraint".to_string());
2277
2278 emitter.emit(
2279 Diagnostic::new(
2280 DiagnosticCode::W005ConstraintZeroSlack,
2281 format!("constraint reduces slack to zero for task '{}'", task_id),
2282 )
2283 .with_file(config.file.clone().unwrap_or_default())
2284 .with_note(format!("{} makes task critical", constraint_desc))
2285 .with_hint("consider relaxing constraint or adding buffer"),
2286 );
2287 }
2288 }
2289}
2290
2291const VARIANCE_THRESHOLD_DAYS: i64 = 5;
2293
2294fn check_schedule_variance(
2296 schedule: &Schedule,
2297 config: &AnalysisConfig,
2298 emitter: &mut dyn DiagnosticEmitter,
2299) {
2300 for (task_id, scheduled) in &schedule.tasks {
2301 if scheduled.finish_variance_days > VARIANCE_THRESHOLD_DAYS {
2303 let variance_str = format!("+{}d", scheduled.finish_variance_days);
2304 emitter.emit(
2305 Diagnostic::new(
2306 DiagnosticCode::W006ScheduleVariance,
2307 format!("task '{}' is slipping ({})", task_id, variance_str),
2308 )
2309 .with_file(config.file.clone().unwrap_or_default())
2310 .with_note(format!(
2311 "baseline finish: {}, forecast finish: {}",
2312 scheduled.baseline_finish, scheduled.forecast_finish
2313 ))
2314 .with_hint("review progress or adjust plan"),
2315 );
2316 }
2317 }
2318}
2319
2320fn check_progress_conflicts(
2322 project: &Project,
2323 config: &AnalysisConfig,
2324 emitter: &mut dyn DiagnosticEmitter,
2325) {
2326 check_progress_conflicts_recursive(&project.tasks, config, emitter);
2327}
2328
2329fn check_progress_conflicts_recursive(
2330 tasks: &[Task],
2331 config: &AnalysisConfig,
2332 emitter: &mut dyn DiagnosticEmitter,
2333) {
2334 for task in tasks {
2335 if let (Some(explicit_remaining), Some(complete_pct)) =
2337 (&task.explicit_remaining, task.complete)
2338 {
2339 let duration_days = get_task_duration_days(task);
2340 let linear_remaining =
2341 ((duration_days as f64) * (1.0 - complete_pct as f64 / 100.0)).ceil() as i64;
2342 let explicit_days = explicit_remaining.as_days() as i64;
2343
2344 if (explicit_days - linear_remaining).abs() > 1 {
2346 emitter.emit(
2347 Diagnostic::new(
2348 DiagnosticCode::P005RemainingCompleteConflict,
2349 format!(
2350 "task '{}' has explicit remaining ({}d) inconsistent with {}% complete (linear: {}d)",
2351 task.id, explicit_days, complete_pct as i32, linear_remaining
2352 ),
2353 )
2354 .with_file(config.file.clone().unwrap_or_default())
2355 .with_note("explicit remaining takes precedence over linear calculation")
2356 .with_hint("update complete% or remaining to be consistent"),
2357 );
2358 }
2359 }
2360
2361 if !task.children.is_empty() {
2363 if let Some(explicit_complete) = task.complete {
2364 let (children_weighted_sum, children_total_duration) =
2365 compute_children_progress_weighted(&task.children);
2366
2367 if children_total_duration > 0 {
2368 let derived_complete =
2369 (children_weighted_sum as f64 / children_total_duration as f64) * 100.0;
2370 let diff = (explicit_complete as f64 - derived_complete).abs();
2371
2372 if diff > 10.0 {
2374 emitter.emit(
2375 Diagnostic::new(
2376 DiagnosticCode::P006ContainerProgressMismatch,
2377 format!(
2378 "container '{}' has explicit complete ({}%) inconsistent with children average ({:.0}%)",
2379 task.id, explicit_complete as i32, derived_complete
2380 ),
2381 )
2382 .with_file(config.file.clone().unwrap_or_default())
2383 .with_note(format!(
2384 "children weighted average: {:.1}%, explicit: {}%",
2385 derived_complete, explicit_complete as i32
2386 ))
2387 .with_hint("remove explicit complete% or update to match children"),
2388 );
2389 }
2390 }
2391 }
2392 }
2393
2394 check_progress_conflicts_recursive(&task.children, config, emitter);
2396 }
2397}
2398
2399fn compute_children_progress_weighted(children: &[Task]) -> (i64, i64) {
2401 let mut weighted_sum: i64 = 0;
2402 let mut total_duration: i64 = 0;
2403
2404 for child in children {
2405 let duration = get_task_duration_days(child);
2406 let complete = child.complete.unwrap_or(0.0);
2407 weighted_sum += (duration as f64 * complete as f64 / 100.0) as i64;
2408 total_duration += duration;
2409
2410 let (nested_sum, nested_duration) = compute_children_progress_weighted(&child.children);
2412 weighted_sum += nested_sum;
2413 total_duration += nested_duration;
2414 }
2415
2416 (weighted_sum, total_duration)
2417}
2418
2419fn check_project_status(
2421 schedule: &Schedule,
2422 config: &AnalysisConfig,
2423 emitter: &mut dyn DiagnosticEmitter,
2424) {
2425 let variance_indicator = if schedule.project_variance_days > 0 {
2426 format!("+{}d behind", schedule.project_variance_days)
2427 } else if schedule.project_variance_days < 0 {
2428 format!("{}d ahead", schedule.project_variance_days.abs())
2429 } else {
2430 "on schedule".to_string()
2431 };
2432
2433 let status_emoji = if schedule.project_variance_days > VARIANCE_THRESHOLD_DAYS {
2434 "🔴"
2435 } else if schedule.project_variance_days > 0 {
2436 "🟡"
2437 } else {
2438 "🟢"
2439 };
2440
2441 emitter.emit(
2442 Diagnostic::new(
2443 DiagnosticCode::I004ProjectStatus,
2444 format!(
2445 "project {}% complete, {} {}",
2446 schedule.project_progress, variance_indicator, status_emoji
2447 ),
2448 )
2449 .with_file(config.file.clone().unwrap_or_default())
2450 .with_note(format!(
2451 "baseline finish: {}, forecast finish: {}",
2452 schedule.project_baseline_finish, schedule.project_forecast_finish
2453 )),
2454 );
2455}
2456
2457fn check_earned_value(
2459 schedule: &Schedule,
2460 config: &AnalysisConfig,
2461 emitter: &mut dyn DiagnosticEmitter,
2462) {
2463 let (spi_status, spi_emoji) = if schedule.spi >= 1.0 {
2465 ("on schedule", "🟢")
2466 } else if schedule.spi >= 0.95 {
2467 ("slightly behind", "🟡")
2468 } else {
2469 ("behind schedule", "🔴")
2470 };
2471
2472 emitter.emit(
2473 Diagnostic::new(
2474 DiagnosticCode::I005EarnedValueSummary,
2475 format!("SPI {:.2}: {} {}", schedule.spi, spi_status, spi_emoji),
2476 )
2477 .with_file(config.file.clone().unwrap_or_default())
2478 .with_note(format!(
2479 "EV {}%, PV {}% (earned vs planned progress)",
2480 schedule.earned_value, schedule.planned_value
2481 )),
2482 );
2483}
2484
2485pub fn classify_scheduling_mode(project: &Project) -> SchedulingMode {
2489 fn collect_leaf_tasks(tasks: &[Task]) -> Vec<&Task> {
2491 let mut leaves = Vec::new();
2492 for task in tasks {
2493 if task.children.is_empty() {
2494 leaves.push(task);
2495 } else {
2496 leaves.extend(collect_leaf_tasks(&task.children));
2497 }
2498 }
2499 leaves
2500 }
2501
2502 let leaf_tasks = collect_leaf_tasks(&project.tasks);
2503
2504 let has_effort = leaf_tasks
2506 .iter()
2507 .any(|t| t.effort.is_some() && !t.assigned.is_empty());
2508
2509 let has_rates = project.resources.iter().any(|r| r.rate.is_some())
2511 || project.profiles.iter().any(|p| p.rate.is_some());
2512
2513 if has_effort && has_rates {
2515 SchedulingMode::ResourceLoaded
2516 } else if has_effort {
2517 SchedulingMode::EffortBased
2518 } else {
2519 SchedulingMode::DurationBased
2520 }
2521}
2522
2523fn emit_project_summary(
2525 project: &Project,
2526 schedule: &Schedule,
2527 _info: &AssignmentInfo,
2528 _config: &AnalysisConfig,
2529 emitter: &mut dyn DiagnosticEmitter,
2530) {
2531 let concrete_count = schedule
2532 .tasks
2533 .values()
2534 .filter(|t| !t.has_abstract_assignments && !t.assignments.is_empty())
2535 .count();
2536 let abstract_count = schedule
2537 .tasks
2538 .values()
2539 .filter(|t| t.has_abstract_assignments)
2540 .count();
2541
2542 let cost_str = if let Some(ref cost) = schedule.total_cost_range {
2543 if cost.is_fixed() {
2544 format!("${}", cost.expected)
2545 } else {
2546 format!(
2547 "${} - ${} (expected: ${})",
2548 cost.min, cost.max, cost.expected
2549 )
2550 }
2551 } else {
2552 "unknown (no cost data)".to_string()
2553 };
2554
2555 let scheduling_mode = classify_scheduling_mode(project);
2557
2558 emitter.emit(
2559 Diagnostic::new(
2560 DiagnosticCode::I001ProjectCostSummary,
2561 format!("project '{}' scheduled successfully", project.name),
2562 )
2563 .with_note(format!(
2564 "duration: {} days ({} to {})",
2565 schedule.project_duration.as_days() as i64,
2566 project.start,
2567 schedule.project_end
2568 ))
2569 .with_note(format!("cost: {}", cost_str))
2570 .with_note(format!(
2571 "tasks: {} ({} concrete, {} abstract assignments)",
2572 schedule.tasks.len(),
2573 concrete_count,
2574 abstract_count
2575 ))
2576 .with_note(format!(
2577 "critical path: {} tasks",
2578 schedule.critical_path.len()
2579 ))
2580 .with_note(format!("scheduling: {}", scheduling_mode)),
2581 );
2582}
2583
2584impl Scheduler for CpmSolver {
2589 fn schedule(&self, project: &Project) -> Result<Schedule, ScheduleError> {
2590 let (task_map, context_map) = flatten_tasks_with_context(&project.tasks);
2592
2593 if task_map.is_empty() {
2594 return Ok(Schedule {
2596 tasks: HashMap::new(),
2597 critical_path: Vec::new(),
2598 project_duration: Duration::zero(),
2599 project_end: project.start,
2600 total_cost: None,
2601 total_cost_range: None,
2602 project_progress: 0,
2603 project_baseline_finish: project.start,
2604 project_forecast_finish: project.start,
2605 project_variance_days: 0,
2606 planned_value: 0,
2608 earned_value: 0,
2609 spi: 1.0,
2610 });
2611 }
2612
2613 let topo_result = topological_sort(&task_map, &context_map)?;
2615 let sorted_ids = topo_result.sorted_ids;
2616 let successors_map = topo_result.successors;
2617
2618 let calendar = project
2620 .calendars
2621 .iter()
2622 .find(|c| c.id == project.calendar)
2623 .or_else(|| project.calendars.first())
2624 .cloned()
2625 .unwrap_or_default();
2626
2627 let status_date = self.effective_status_date(project);
2630 let status_date_days = date_to_working_days(project.start, status_date, &calendar);
2631
2632 let mut nodes: HashMap<String, TaskNode> = HashMap::new();
2634 for id in &sorted_ids {
2635 let task = task_map[id];
2636 let duration_days = get_task_duration_days(task);
2637 nodes.insert(
2638 id.clone(),
2639 TaskNode {
2640 task,
2641 original_duration_days: duration_days,
2642 duration_days,
2643 early_start: 0,
2644 early_finish: 0,
2645 late_start: i64::MAX,
2646 late_finish: i64::MAX,
2647 slack: 0,
2648 remaining_days: duration_days, baseline_start_days: 0, baseline_finish_days: 0, },
2652 );
2653 }
2654
2655 let children_map = build_children_map(&task_map);
2657
2658 for id in &sorted_ids {
2661 let task = task_map[id];
2662
2663 if let Some(children) = children_map.get(id) {
2665 let mut min_es = i64::MAX;
2668 let mut max_ef = i64::MIN;
2669 let mut min_baseline_es = i64::MAX;
2670 let mut max_baseline_ef = i64::MIN;
2671
2672 for child_id in children {
2673 if let Some(child_node) = nodes.get(child_id) {
2674 min_es = min_es.min(child_node.early_start);
2675 max_ef = max_ef.max(child_node.early_finish);
2676 min_baseline_es = min_baseline_es.min(child_node.baseline_start_days);
2677 max_baseline_ef = max_baseline_ef.max(child_node.baseline_finish_days);
2678 }
2679 }
2680
2681 if min_es != i64::MAX && max_ef != i64::MIN {
2682 if let Some(node) = nodes.get_mut(id) {
2683 node.early_start = min_es;
2684 node.early_finish = max_ef;
2685 node.duration_days = max_ef - min_es;
2686 node.baseline_start_days = min_baseline_es;
2688 node.baseline_finish_days = max_baseline_ef;
2689 }
2690 }
2691 } else {
2692 let original_duration = nodes[id].original_duration_days;
2694
2695 let mut baseline_es = 0i64;
2699 for dep in &task.depends {
2700 let resolved =
2701 resolve_dependency_path(&dep.predecessor, id, &context_map, &task_map);
2702 if let Some(pred_id) = resolved {
2703 if let Some(pred_node) = nodes.get(&pred_id) {
2704 let lag = dep.lag.map(|d| d.as_days() as i64).unwrap_or(0);
2705 let pred_baseline_ef = pred_node.baseline_finish_days;
2707
2708 let constraint_es = match dep.dep_type {
2709 DependencyType::FinishToStart => {
2710 if lag >= 0 {
2711 pred_baseline_ef + lag
2712 } else {
2713 (pred_baseline_ef - 1 + lag).max(0)
2714 }
2715 }
2716 DependencyType::StartToStart => pred_node.baseline_start_days + lag,
2717 DependencyType::FinishToFinish => {
2718 (pred_baseline_ef + lag - original_duration).max(0)
2719 }
2720 DependencyType::StartToFinish => {
2721 (pred_node.baseline_start_days + lag - original_duration).max(0)
2722 }
2723 };
2724 baseline_es = baseline_es.max(constraint_es);
2725 }
2726 }
2727 }
2728
2729 let mut baseline_min_finish: Option<i64> = None;
2731 for constraint in &task.constraints {
2732 match constraint {
2733 TaskConstraint::MustStartOn(date)
2734 | TaskConstraint::StartNoEarlierThan(date) => {
2735 let constraint_days =
2736 date_to_working_days(project.start, *date, &calendar);
2737 baseline_es = baseline_es.max(constraint_days);
2738 }
2739 TaskConstraint::MustFinishOn(date)
2740 | TaskConstraint::FinishNoEarlierThan(date) => {
2741 let constraint_days =
2742 date_to_working_days(project.start, *date, &calendar);
2743 let exclusive_ef = constraint_days + 1;
2744 baseline_min_finish = Some(
2745 baseline_min_finish.map_or(exclusive_ef, |mf| mf.max(exclusive_ef)),
2746 );
2747 }
2748 _ => {} }
2750 }
2751
2752 let mut baseline_ef = baseline_es + original_duration;
2754
2755 if let Some(mf) = baseline_min_finish {
2757 if mf > baseline_ef {
2758 baseline_ef = mf;
2759 baseline_es = baseline_ef - original_duration;
2760 }
2761 }
2762
2763 let progress_state = classify_progress_state(task, project.start, &calendar);
2765
2766 let mut forecast_es = 0i64;
2770 for dep in &task.depends {
2771 let resolved =
2772 resolve_dependency_path(&dep.predecessor, id, &context_map, &task_map);
2773 if let Some(pred_id) = resolved {
2774 if let Some(pred_node) = nodes.get(&pred_id) {
2775 let lag = dep.lag.map(|d| d.as_days() as i64).unwrap_or(0);
2776 let pred_ef = pred_node.early_finish;
2778
2779 let constraint_es = match dep.dep_type {
2780 DependencyType::FinishToStart => {
2781 if lag >= 0 {
2782 pred_ef + lag
2783 } else {
2784 (pred_ef - 1 + lag).max(0)
2785 }
2786 }
2787 DependencyType::StartToStart => pred_node.early_start + lag,
2788 DependencyType::FinishToFinish => {
2789 let original_dur = nodes[id].original_duration_days;
2790 (pred_ef + lag - original_dur).max(0)
2791 }
2792 DependencyType::StartToFinish => {
2793 let original_dur = nodes[id].original_duration_days;
2794 (pred_node.early_start + lag - original_dur).max(0)
2795 }
2796 };
2797 forecast_es = forecast_es.max(constraint_es);
2798 }
2799 }
2800 }
2801
2802 let (es, ef, remaining) = match progress_state {
2804 ProgressState::Complete {
2805 actual_start_days,
2806 actual_finish_days,
2807 } => {
2808 (actual_start_days, actual_finish_days, 0i64)
2812 }
2813 ProgressState::InProgress {
2814 actual_start_days,
2815 remaining_days,
2816 } => {
2817 let es = actual_start_days;
2821 let ef = status_date_days + remaining_days;
2822 (es, ef, remaining_days)
2823 }
2824 ProgressState::NotStarted { duration_days } => {
2825 let mut es = forecast_es;
2828
2829 let mut min_finish: Option<i64> = None;
2831 for constraint in &task.constraints {
2832 match constraint {
2833 TaskConstraint::MustStartOn(date)
2834 | TaskConstraint::StartNoEarlierThan(date) => {
2835 let constraint_days =
2836 date_to_working_days(project.start, *date, &calendar);
2837 es = es.max(constraint_days);
2838 }
2839 TaskConstraint::MustFinishOn(date)
2840 | TaskConstraint::FinishNoEarlierThan(date) => {
2841 let constraint_days =
2842 date_to_working_days(project.start, *date, &calendar);
2843 let exclusive_ef = constraint_days + 1;
2844 min_finish = Some(
2845 min_finish.map_or(exclusive_ef, |mf| mf.max(exclusive_ef)),
2846 );
2847 }
2848 _ => {} }
2850 }
2851
2852 let mut ef = es + duration_days;
2854
2855 if let Some(mf) = min_finish {
2857 if mf > ef {
2858 ef = mf;
2859 es = ef - duration_days;
2860 }
2861 }
2862
2863 (es, ef, duration_days)
2865 }
2866 };
2867
2868 if let Some(node) = nodes.get_mut(id) {
2869 node.early_start = es;
2870 node.early_finish = ef;
2871 node.duration_days = ef - es;
2873 node.remaining_days = remaining;
2875 node.baseline_start_days = baseline_es;
2877 node.baseline_finish_days = baseline_ef;
2878 }
2879 }
2880 }
2881
2882 let project_end_days = nodes.values().map(|n| n.early_finish).max().unwrap_or(0);
2884
2885 let working_day_cache = WorkingDayCache::new(project.start, project_end_days, &calendar);
2887
2888 for id in sorted_ids.iter().rev() {
2892 if children_map.contains_key(id) {
2894 continue;
2895 }
2896
2897 let duration = nodes[id].duration_days;
2898
2899 let successors = successors_map.get(id);
2901
2902 let lf = match successors {
2909 Some(succs) if !succs.is_empty() => {
2910 let mut min_lf = project_end_days;
2911 for succ_id in succs {
2912 if let Some(succ_node) = nodes.get(succ_id) {
2913 let succ_task = task_map.get(succ_id);
2915 let dep_info = succ_task.and_then(|t| {
2916 t.depends.iter().find(|d| {
2917 let resolved = resolve_dependency_path(
2919 &d.predecessor,
2920 succ_id,
2921 &context_map,
2922 &task_map,
2923 );
2924 resolved.as_ref() == Some(id)
2925 })
2926 });
2927
2928 let constraint_lf = if let Some(dep) = dep_info {
2929 let lag = dep.lag.map(|d| d.as_days() as i64).unwrap_or(0);
2930 match dep.dep_type {
2931 DependencyType::FinishToStart => {
2932 if lag >= 0 {
2936 succ_node.late_start - lag
2937 } else {
2938 succ_node.late_start + 1 - lag
2939 }
2940 }
2941 DependencyType::StartToStart => {
2942 succ_node.late_start - lag + duration
2945 }
2946 DependencyType::FinishToFinish => {
2947 succ_node.late_finish - lag
2949 }
2950 DependencyType::StartToFinish => {
2951 succ_node.late_finish - lag + duration
2954 }
2955 }
2956 } else {
2957 succ_node.late_start
2959 };
2960 min_lf = min_lf.min(constraint_lf);
2961 }
2962 }
2963 min_lf
2964 }
2965 _ => project_end_days,
2966 };
2967
2968 let task = task_map.get(id);
2971 let mut max_finish: Option<i64> = None;
2972 let mut max_start: Option<i64> = None;
2973 if let Some(task) = task {
2974 for constraint in &task.constraints {
2975 match constraint {
2976 TaskConstraint::MustFinishOn(date)
2977 | TaskConstraint::FinishNoLaterThan(date) => {
2978 let constraint_days =
2981 date_to_working_days(project.start, *date, &calendar);
2982 let exclusive_lf = constraint_days + 1;
2983 max_finish =
2984 Some(max_finish.map_or(exclusive_lf, |mf| mf.min(exclusive_lf)));
2985 }
2986 TaskConstraint::MustStartOn(date)
2987 | TaskConstraint::StartNoLaterThan(date) => {
2988 let constraint_days =
2990 date_to_working_days(project.start, *date, &calendar);
2991 max_start = Some(
2992 max_start.map_or(constraint_days, |ms| ms.min(constraint_days)),
2993 );
2994 }
2995 _ => {} }
2997 }
2998 }
2999
3000 let lf = if let Some(mf) = max_finish {
3002 lf.min(mf)
3003 } else {
3004 lf
3005 };
3006
3007 let mut ls = lf - duration;
3009
3010 if let Some(ms) = max_start {
3012 ls = ls.min(ms);
3013 }
3014
3015 let slack = ls - nodes[id].early_start;
3017
3018 if let Some(node) = nodes.get_mut(id) {
3019 node.late_start = ls;
3020 node.late_finish = lf;
3021 node.slack = slack;
3022 }
3023 }
3024
3025 let mut container_ids: Vec<&String> = children_map.keys().collect();
3027 container_ids.sort_by(|a, b| {
3028 let depth_a = a.matches('.').count();
3029 let depth_b = b.matches('.').count();
3030 depth_b.cmp(&depth_a) });
3032
3033 for container_id in container_ids {
3034 if let Some(children) = children_map.get(container_id) {
3035 let mut min_ls = i64::MAX;
3036 let mut max_lf = i64::MIN;
3037
3038 for child_id in children {
3039 if let Some(child_node) = nodes.get(child_id) {
3040 min_ls = min_ls.min(child_node.late_start);
3041 max_lf = max_lf.max(child_node.late_finish);
3042 }
3043 }
3044
3045 if min_ls != i64::MAX && max_lf != i64::MIN {
3046 if let Some(container_node) = nodes.get_mut(container_id) {
3047 container_node.late_start = min_ls;
3048 container_node.late_finish = max_lf;
3049 container_node.slack = min_ls - container_node.early_start;
3050 }
3051 }
3052 }
3053 }
3054
3055 let mut infeasibility_check_ids: Vec<_> = nodes.keys().collect();
3059 infeasibility_check_ids.sort();
3060 for id in infeasibility_check_ids {
3061 let node = &nodes[id];
3062 if node.slack < 0 {
3063 return Err(ScheduleError::Infeasible(format!(
3064 "task '{}' has infeasible constraints: ES ({}) > LS ({}), slack = {} days",
3065 id, node.early_start, node.late_start, node.slack
3066 )));
3067 }
3068 }
3069
3070 let position_map: HashMap<&String, usize> = sorted_ids
3073 .iter()
3074 .enumerate()
3075 .map(|(i, id)| (id, i))
3076 .collect();
3077
3078 let mut critical_path: Vec<TaskId> = nodes
3079 .iter()
3080 .filter(|(_, node)| node.slack == 0 && node.duration_days > 0)
3081 .map(|(id, _)| id.clone())
3082 .collect();
3083
3084 critical_path.sort_by_key(|id| position_map.get(id).copied().unwrap_or(0));
3086
3087 let mut scheduled_tasks: HashMap<TaskId, ScheduledTask> = HashMap::new();
3089
3090 for (id, node) in &nodes {
3091 let start_date = working_day_cache.get(node.early_start);
3092 let finish_date = if node.duration_days > 0 {
3095 working_day_cache.get(node.early_finish - 1)
3097 } else {
3098 start_date };
3100
3101 let mut assignments: Vec<Assignment> = Vec::new();
3103 let mut task_cost_ranges: Vec<CostRange> = Vec::new();
3104 let mut has_abstract = false;
3105
3106 for res_ref in &node.task.assigned {
3107 let (cost_range, is_abstract) = calculate_assignment_cost(
3108 &res_ref.resource_id,
3109 res_ref.units,
3110 node.duration_days,
3111 project,
3112 );
3113
3114 if is_abstract {
3115 has_abstract = true;
3116 }
3117
3118 if let Some(ref range) = cost_range {
3120 task_cost_ranges.push(range.clone());
3121 }
3122
3123 let fixed_cost = if !is_abstract {
3125 cost_range
3126 .as_ref()
3127 .map(|r| Money::new(r.expected, &r.currency))
3128 } else {
3129 None
3130 };
3131
3132 let effort_days = node.task.effort.map(|effort| {
3136 let num_assignments = node.task.assigned.len().max(1) as f64;
3137 effort.as_days() / num_assignments
3138 });
3139
3140 assignments.push(Assignment {
3141 resource_id: res_ref.resource_id.clone(),
3142 start: start_date,
3143 finish: finish_date,
3144 units: res_ref.units,
3145 cost: fixed_cost,
3146 cost_range: cost_range.clone(),
3147 is_abstract,
3148 effort_days,
3149 });
3150 }
3151
3152 let task_cost_range = aggregate_cost_ranges(&task_cost_ranges);
3154
3155 let task = node.task;
3157 let percent_complete = task.effective_progress();
3159 let status = task.derived_status();
3160
3161 let remaining = Duration::days(node.remaining_days);
3164
3165 let forecast_start = task.actual_start.unwrap_or(start_date);
3167
3168 let forecast_finish = if status == TaskStatus::Complete {
3173 task.actual_finish.unwrap_or(finish_date)
3174 } else if node.remaining_days > 0 {
3175 if node.early_finish > 0 {
3178 working_day_cache.get(node.early_finish - 1)
3179 } else {
3180 working_day_cache.get(node.early_finish)
3181 }
3182 } else {
3183 forecast_start };
3185
3186 let baseline_start_date = working_day_cache.get(node.baseline_start_days);
3189 let baseline_finish_date = if node.original_duration_days > 0 {
3190 working_day_cache.get(node.baseline_finish_days - 1)
3191 } else {
3192 working_day_cache.get(node.baseline_finish_days)
3193 };
3194 let start_variance_days = (forecast_start - baseline_start_date).num_days();
3195 let finish_variance_days = (forecast_finish - baseline_finish_date).num_days();
3196
3197 scheduled_tasks.insert(
3198 id.clone(),
3199 ScheduledTask {
3200 task_id: id.clone(),
3201 start: start_date,
3202 finish: finish_date,
3203 duration: Duration::days(node.original_duration_days),
3204 assignments,
3205 slack: Duration::days(node.slack),
3206 is_critical: node.slack == 0 && node.duration_days > 0,
3207 early_start: working_day_cache.get(node.early_start),
3208 early_finish: if node.duration_days > 0 {
3209 working_day_cache.get(node.early_finish - 1)
3210 } else {
3211 working_day_cache.get(node.early_finish)
3212 },
3213 late_start: working_day_cache.get(node.late_start),
3214 late_finish: if node.duration_days > 0 {
3215 working_day_cache.get(node.late_finish - 1)
3216 } else {
3217 working_day_cache.get(node.late_finish)
3218 },
3219 forecast_start,
3221 forecast_finish,
3222 remaining_duration: remaining,
3223 percent_complete,
3224 status,
3225 baseline_start: working_day_cache.get(node.baseline_start_days),
3228 baseline_finish: if node.original_duration_days > 0 {
3229 working_day_cache.get(node.baseline_finish_days - 1)
3230 } else {
3231 working_day_cache.get(node.baseline_finish_days)
3232 },
3233 start_variance_days,
3234 finish_variance_days,
3235 cost_range: task_cost_range,
3237 has_abstract_assignments: has_abstract,
3238 },
3239 );
3240 }
3241
3242 let all_task_cost_ranges: Vec<CostRange> = scheduled_tasks
3244 .values()
3245 .filter_map(|st| st.cost_range.clone())
3246 .collect();
3247 let total_cost_range = aggregate_cost_ranges(&all_task_cost_ranges);
3248
3249 let project_end_date = if project_end_days > 0 {
3252 working_day_cache.get(project_end_days - 1)
3253 } else {
3254 project.start
3255 };
3256
3257 let (project_progress, project_baseline_finish, project_forecast_finish) = {
3261 let mut total_weight: i64 = 0;
3262 let mut weighted_progress: i64 = 0;
3263 let mut max_baseline = project.start;
3264 let mut max_forecast = project.start;
3265
3266 let container_ids: std::collections::HashSet<&str> = task_map
3268 .values()
3269 .filter(|t| !t.children.is_empty())
3270 .map(|t| t.id.as_str())
3271 .collect();
3272
3273 for st in scheduled_tasks.values() {
3274 if st.baseline_finish > max_baseline {
3276 max_baseline = st.baseline_finish;
3277 }
3278 if st.forecast_finish > max_forecast {
3279 max_forecast = st.forecast_finish;
3280 }
3281
3282 if !container_ids.contains(st.task_id.as_str()) {
3284 let duration_days = st.duration.as_days() as i64;
3285 if duration_days > 0 {
3286 total_weight += duration_days;
3287 weighted_progress += (st.percent_complete as i64) * duration_days;
3288 }
3289 }
3290 }
3291
3292 let progress = if total_weight > 0 {
3293 (weighted_progress / total_weight) as u8
3294 } else {
3295 0
3296 };
3297
3298 (progress, max_baseline, max_forecast)
3299 };
3300
3301 let project_variance_days = (project_forecast_finish - project_baseline_finish).num_days();
3302
3303 let status_date = chrono::Local::now().date_naive();
3308
3309 let (planned_value, earned_value, spi) = {
3310 let mut total_weight: i64 = 0;
3311 let mut weighted_pv: f64 = 0.0;
3312
3313 let container_ids: std::collections::HashSet<&str> = task_map
3315 .values()
3316 .filter(|t| !t.children.is_empty())
3317 .map(|t| t.id.as_str())
3318 .collect();
3319
3320 for st in scheduled_tasks.values() {
3321 if !container_ids.contains(st.task_id.as_str()) {
3323 let duration_days = st.duration.as_days() as i64;
3324 if duration_days > 0 {
3325 total_weight += duration_days;
3326
3327 let task_pv = if status_date <= st.baseline_start {
3329 0.0
3331 } else if status_date >= st.baseline_finish {
3332 100.0
3334 } else {
3335 let baseline_duration =
3337 (st.baseline_finish - st.baseline_start).num_days() as f64;
3338 if baseline_duration > 0.0 {
3339 let elapsed = (status_date - st.baseline_start).num_days() as f64;
3340 (elapsed / baseline_duration) * 100.0
3341 } else {
3342 100.0 }
3344 };
3345
3346 weighted_pv += task_pv * (duration_days as f64);
3347 }
3348 }
3349 }
3350
3351 let pv = if total_weight > 0 {
3352 (weighted_pv / total_weight as f64).round() as u8
3353 } else {
3354 0
3355 };
3356
3357 let ev = project_progress;
3358
3359 let spi_value = if pv == 0 {
3361 if ev == 0 {
3362 1.0 } else {
3364 2.0 }
3366 } else {
3367 let raw_spi = (ev as f64) / (pv as f64);
3368 raw_spi.min(2.0) };
3370
3371 (pv, ev, spi_value)
3372 };
3373
3374 let schedule = Schedule {
3375 tasks: scheduled_tasks,
3376 critical_path,
3377 project_duration: Duration::days(project_end_days),
3378 project_end: project_end_date,
3379 total_cost: None, total_cost_range, project_progress,
3382 project_baseline_finish,
3383 project_forecast_finish,
3384 project_variance_days,
3385 planned_value,
3387 earned_value,
3388 spi,
3389 };
3390
3391 if self.resource_leveling {
3393 let result = level_resources(project, &schedule, &calendar);
3394 Ok(result.leveled_schedule)
3395 } else {
3396 Ok(schedule)
3397 }
3398 }
3399
3400 fn is_feasible(&self, project: &Project) -> FeasibilityResult {
3401 use utf8proj_core::{Conflict, ConflictType, ScheduleError, Suggestion};
3402
3403 let (task_map, context_map) = flatten_tasks_with_context(&project.tasks);
3405
3406 if let Err(e) = topological_sort(&task_map, &context_map) {
3407 return FeasibilityResult {
3408 feasible: false,
3409 conflicts: vec![Conflict {
3410 conflict_type: ConflictType::CircularDependency,
3411 description: e.to_string(),
3412 involved_tasks: vec![],
3413 involved_resources: vec![],
3414 }],
3415 suggestions: vec![],
3416 };
3417 }
3418
3419 match Scheduler::schedule(self, project) {
3421 Ok(_schedule) => FeasibilityResult {
3422 feasible: true,
3423 conflicts: vec![],
3424 suggestions: vec![],
3425 },
3426 Err(e) => {
3427 let (conflict_type, involved_tasks, suggestions) = match &e {
3428 ScheduleError::Infeasible(msg) => {
3429 let task_id = extract_task_from_infeasible_message(msg);
3431 let suggestions = if let Some(ref id) = task_id {
3432 vec![Suggestion {
3433 description: format!(
3434 "Review constraints on task '{}' or relax conflicting dependencies",
3435 id
3436 ),
3437 impact: "May allow schedule to be computed".to_string(),
3438 }]
3439 } else {
3440 vec![]
3441 };
3442 (
3443 ConflictType::ImpossibleConstraint,
3444 task_id.into_iter().collect(),
3445 suggestions,
3446 )
3447 }
3448 ScheduleError::CircularDependency(_) => {
3449 (ConflictType::CircularDependency, vec![], vec![])
3450 }
3451 ScheduleError::TaskNotFound(id) => {
3452 (ConflictType::ImpossibleConstraint, vec![id.clone()], vec![])
3453 }
3454 _ => (ConflictType::ImpossibleConstraint, vec![], vec![]),
3455 };
3456
3457 FeasibilityResult {
3458 feasible: false,
3459 conflicts: vec![Conflict {
3460 conflict_type,
3461 description: e.to_string(),
3462 involved_tasks,
3463 involved_resources: vec![],
3464 }],
3465 suggestions,
3466 }
3467 }
3468 }
3469 }
3470
3471 fn explain(&self, project: &Project, task_id: &TaskId) -> Explanation {
3472 let mut task_map: HashMap<String, &Task> = HashMap::new();
3474 flatten_tasks(&project.tasks, &mut task_map);
3475
3476 if let Some(task) = task_map.get(task_id) {
3477 let dependency_constraints: Vec<String> = task
3478 .depends
3479 .iter()
3480 .map(|d| format!("Depends on: {}", d.predecessor))
3481 .collect();
3482
3483 let constraint_effects = self.analyze_constraint_effects(project, task);
3485
3486 let calendar_impact = self.calculate_calendar_impact(project, task);
3488
3489 Explanation {
3490 task_id: task_id.clone(),
3491 reason: if task.depends.is_empty() && task.constraints.is_empty() {
3492 "Scheduled at project start (no dependencies or constraints)".into()
3493 } else if task.depends.is_empty() {
3494 "Scheduled based on constraints".into()
3495 } else {
3496 format!(
3497 "Scheduled after predecessors: {}",
3498 task.depends
3499 .iter()
3500 .map(|d| d.predecessor.as_str())
3501 .collect::<Vec<_>>()
3502 .join(", ")
3503 )
3504 },
3505 constraints_applied: dependency_constraints,
3506 alternatives_considered: vec![],
3507 constraint_effects,
3508 calendar_impact,
3509 related_diagnostics: vec![], }
3511 } else {
3512 Explanation {
3513 task_id: task_id.clone(),
3514 reason: "Task not found".into(),
3515 constraints_applied: vec![],
3516 alternatives_considered: vec![],
3517 constraint_effects: vec![],
3518 calendar_impact: None,
3519 related_diagnostics: vec![],
3520 }
3521 }
3522 }
3523}
3524
3525impl CpmSolver {
3527 fn calculate_calendar_impact(
3529 &self,
3530 project: &Project,
3531 task: &Task,
3532 ) -> Option<utf8proj_core::CalendarImpact> {
3533 use chrono::Datelike;
3534 use utf8proj_core::CalendarImpact;
3535
3536 let schedule = Scheduler::schedule(self, project).ok()?;
3538 let scheduled_task = schedule.tasks.get(&task.id)?;
3539
3540 let calendar = project
3542 .calendars
3543 .iter()
3544 .find(|c| c.id == project.calendar)
3545 .cloned()
3546 .unwrap_or_default();
3547
3548 let start = scheduled_task.start;
3549 let finish = scheduled_task.finish;
3550 let calendar_days = (finish - start).num_days() + 1;
3551
3552 let mut non_working_days = 0u32;
3554 let mut weekend_days = 0u32;
3555 let mut holiday_days = 0u32;
3556
3557 let mut current = start;
3558 while current <= finish {
3559 let weekday = current.weekday().num_days_from_sunday() as u8;
3560
3561 if !calendar.working_days.contains(&weekday) {
3563 non_working_days += 1;
3564 if weekday == 0 || weekday == 6 {
3566 weekend_days += 1;
3567 }
3568 }
3569
3570 if calendar
3572 .holidays
3573 .iter()
3574 .any(|h| h.start <= current && current <= h.end)
3575 {
3576 holiday_days += 1;
3577 if calendar.working_days.contains(&weekday) {
3579 non_working_days += 1;
3580 }
3581 }
3582
3583 current = current.succ_opt()?;
3584 }
3585
3586 let working_days = calendar_days - non_working_days as i64;
3587 let total_delay_days = non_working_days as i64;
3588
3589 let description = if non_working_days > 0 {
3591 let mut parts = vec![];
3592 if weekend_days > 0 {
3593 parts.push(format!("{} weekend day(s)", weekend_days));
3594 }
3595 if holiday_days > 0 {
3596 parts.push(format!("{} holiday(s)", holiday_days));
3597 }
3598 format!(
3599 "Task spans {} calendar days ({} working): {}",
3600 calendar_days,
3601 working_days,
3602 parts.join(", ")
3603 )
3604 } else {
3605 format!(
3606 "Task scheduled entirely within {} working days",
3607 working_days
3608 )
3609 };
3610
3611 Some(CalendarImpact {
3612 calendar_id: calendar.id.clone(),
3613 non_working_days,
3614 weekend_days,
3615 holiday_days,
3616 total_delay_days,
3617 description,
3618 })
3619 }
3620}
3621
3622#[cfg(test)]
3627mod tests {
3628 use super::*;
3629 use utf8proj_core::{Resource, Task};
3630
3631 fn make_test_project() -> Project {
3632 let mut project = Project::new("Test Project");
3633 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(); project.tasks = vec![
3637 Task::new("design")
3638 .name("Design Phase")
3639 .effort(Duration::days(5)),
3640 Task::new("implement")
3641 .name("Implementation")
3642 .effort(Duration::days(10))
3643 .depends_on("design"),
3644 Task::new("test")
3645 .name("Testing")
3646 .effort(Duration::days(3))
3647 .depends_on("implement"),
3648 ];
3649
3650 project.resources = vec![Resource::new("dev").name("Developer")];
3651
3652 project
3653 }
3654
3655 #[test]
3656 fn solver_creation() {
3657 let solver = CpmSolver::new();
3658 assert!(!solver.resource_leveling);
3659 }
3660
3661 #[test]
3662 fn schedule_empty_project() {
3663 let project = Project::new("Empty");
3664 let solver = CpmSolver::new();
3665 let schedule = solver.schedule(&project).unwrap();
3666
3667 assert!(schedule.tasks.is_empty());
3668 assert!(schedule.critical_path.is_empty());
3669 assert_eq!(schedule.project_duration, Duration::zero());
3670 }
3671
3672 #[test]
3673 fn schedule_single_task() {
3674 let mut project = Project::new("Single Task");
3675 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
3676 project.tasks = vec![Task::new("task1").effort(Duration::days(5))];
3677
3678 let solver = CpmSolver::new();
3679 let schedule = solver.schedule(&project).unwrap();
3680
3681 assert_eq!(schedule.tasks.len(), 1);
3682 assert!(schedule.tasks.contains_key("task1"));
3683
3684 let task = &schedule.tasks["task1"];
3685 assert_eq!(task.start, project.start);
3686 assert_eq!(task.duration, Duration::days(5));
3687 assert!(task.is_critical);
3688 }
3689
3690 #[test]
3691 fn schedule_linear_chain() {
3692 let project = make_test_project();
3693 let solver = CpmSolver::new();
3694 let schedule = solver.schedule(&project).unwrap();
3695
3696 assert_eq!(schedule.tasks.len(), 3);
3698
3699 assert_eq!(schedule.project_duration, Duration::days(18));
3701
3702 assert!(schedule.tasks["design"].is_critical);
3704 assert!(schedule.tasks["implement"].is_critical);
3705 assert!(schedule.tasks["test"].is_critical);
3706
3707 assert_eq!(schedule.tasks["design"].early_start, project.start);
3709
3710 let implement_start = schedule.tasks["implement"].early_start;
3712 let design_finish = schedule.tasks["design"].early_finish;
3713 assert!(implement_start >= design_finish);
3714 }
3715
3716 #[test]
3717 fn schedule_parallel_tasks() {
3718 let mut project = Project::new("Parallel");
3719 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
3720
3721 project.tasks = vec![
3726 Task::new("design").effort(Duration::days(5)),
3727 Task::new("implement")
3728 .effort(Duration::days(10))
3729 .depends_on("design"),
3730 Task::new("docs").effort(Duration::days(3)),
3731 Task::new("review")
3732 .effort(Duration::days(2))
3733 .depends_on("docs"),
3734 Task::new("deploy")
3735 .effort(Duration::days(1))
3736 .depends_on("implement")
3737 .depends_on("review"),
3738 ];
3739
3740 let solver = CpmSolver::new();
3741 let schedule = solver.schedule(&project).unwrap();
3742
3743 assert_eq!(schedule.project_duration, Duration::days(16));
3746
3747 assert!(schedule.tasks["design"].is_critical);
3749 assert!(schedule.tasks["implement"].is_critical);
3750 assert!(schedule.tasks["deploy"].is_critical);
3751
3752 assert!(!schedule.tasks["docs"].is_critical);
3754 assert!(!schedule.tasks["review"].is_critical);
3755 assert!(schedule.tasks["docs"].slack.minutes > 0);
3756 }
3757
3758 #[test]
3759 fn detect_circular_dependency() {
3760 let mut project = Project::new("Circular");
3761 project.tasks = vec![
3762 Task::new("a").depends_on("c"),
3763 Task::new("b").depends_on("a"),
3764 Task::new("c").depends_on("b"),
3765 ];
3766
3767 let solver = CpmSolver::new();
3768 let result = solver.schedule(&project);
3769
3770 assert!(result.is_err());
3771 if let Err(ScheduleError::CircularDependency(msg)) = result {
3772 assert!(msg.contains("Cycle"));
3773 } else {
3774 panic!("Expected CircularDependency error");
3775 }
3776 }
3777
3778 #[test]
3779 fn feasibility_check() {
3780 let project = make_test_project();
3781 let solver = CpmSolver::new();
3782 let result = solver.is_feasible(&project);
3783
3784 assert!(result.feasible);
3785 assert!(result.conflicts.is_empty());
3786 }
3787
3788 #[test]
3789 fn explain_task() {
3790 let project = make_test_project();
3791 let solver = CpmSolver::new();
3792
3793 let explanation = solver.explain(&project, &"implement".to_string());
3794 assert_eq!(explanation.task_id, "implement");
3795 assert!(explanation.reason.contains("design"));
3796 }
3797
3798 #[test]
3799 fn milestone_has_zero_duration() {
3800 let mut project = Project::new("Milestone Test");
3801 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
3802 project.tasks = vec![
3803 Task::new("work").effort(Duration::days(5)),
3804 Task::new("done").milestone().depends_on("work"),
3805 ];
3806
3807 let solver = CpmSolver::new();
3808 let schedule = solver.schedule(&project).unwrap();
3809
3810 assert_eq!(schedule.tasks["done"].duration, Duration::zero());
3811 assert_eq!(schedule.tasks["done"].start, schedule.tasks["done"].finish);
3812 }
3813
3814 #[test]
3815 fn nested_tasks_are_flattened() {
3816 let mut project = Project::new("Nested");
3817 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
3818
3819 project.tasks = vec![Task::new("phase1")
3822 .child(Task::new("design").effort(Duration::days(3)))
3823 .child(
3824 Task::new("implement")
3825 .effort(Duration::days(5))
3826 .depends_on("design"), )];
3828
3829 let solver = CpmSolver::new();
3830 let schedule = solver.schedule(&project).unwrap();
3831
3832 assert!(schedule.tasks.contains_key("phase1"));
3834 assert!(schedule.tasks.contains_key("phase1.design"));
3835 assert!(schedule.tasks.contains_key("phase1.implement"));
3836
3837 let design_task = &schedule.tasks["phase1.design"];
3839 let implement_task = &schedule.tasks["phase1.implement"];
3840
3841 println!(
3842 "design: start={}, finish={}",
3843 design_task.start, design_task.finish
3844 );
3845 println!(
3846 "implement: start={}, finish={}",
3847 implement_task.start, implement_task.finish
3848 );
3849
3850 assert!(
3853 implement_task.start > design_task.finish,
3854 "implement should start after design finishes"
3855 );
3856 }
3857
3858 #[test]
3863 fn effort_with_no_resource_assumes_100_percent() {
3864 let mut project = Project::new("Test");
3866 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
3867 project.tasks = vec![Task::new("work").effort(Duration::days(5))];
3868
3869 let solver = CpmSolver::new();
3870 let schedule = solver.schedule(&project).unwrap();
3871
3872 assert_eq!(schedule.tasks["work"].duration.as_days(), 5.0);
3874 }
3875
3876 #[test]
3877 fn effort_with_full_allocation() {
3878 let mut project = Project::new("Test");
3880 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
3881 project.resources = vec![Resource::new("dev")];
3882 project.tasks = vec![Task::new("work").effort(Duration::days(5)).assign("dev")]; let solver = CpmSolver::new();
3885 let schedule = solver.schedule(&project).unwrap();
3886
3887 assert_eq!(schedule.tasks["work"].duration.as_days(), 5.0);
3889 }
3890
3891 #[test]
3892 fn effort_with_partial_allocation() {
3893 let mut project = Project::new("Test");
3895 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
3896 project.resources = vec![Resource::new("dev")];
3897 project.tasks = vec![Task::new("work")
3898 .effort(Duration::days(5))
3899 .assign_with_units("dev", 0.5)]; let solver = CpmSolver::new();
3902 let schedule = solver.schedule(&project).unwrap();
3903
3904 assert_eq!(schedule.tasks["work"].duration.as_days(), 10.0);
3906 }
3907
3908 #[test]
3909 fn effort_with_multiple_resources() {
3910 let mut project = Project::new("Test");
3912 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
3913 project.resources = vec![Resource::new("dev1"), Resource::new("dev2")];
3914 project.tasks = vec![Task::new("work")
3915 .effort(Duration::days(10))
3916 .assign("dev1")
3917 .assign("dev2")]; let solver = CpmSolver::new();
3920 let schedule = solver.schedule(&project).unwrap();
3921
3922 assert_eq!(schedule.tasks["work"].duration.as_days(), 5.0);
3924 }
3925
3926 #[test]
3927 fn effort_with_mixed_allocations() {
3928 let mut project = Project::new("Test");
3930 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
3931 project.resources = vec![Resource::new("dev1"), Resource::new("dev2")];
3932 project.tasks = vec![Task::new("work")
3933 .effort(Duration::days(15))
3934 .assign("dev1") .assign_with_units("dev2", 0.5)]; let solver = CpmSolver::new();
3938 let schedule = solver.schedule(&project).unwrap();
3939
3940 assert_eq!(schedule.tasks["work"].duration.as_days(), 10.0);
3942 }
3943
3944 #[test]
3945 fn fixed_duration_ignores_allocation() {
3946 let mut project = Project::new("Test");
3948 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
3949 project.resources = vec![Resource::new("dev")];
3950 project.tasks = vec![Task::new("meeting")
3951 .duration(Duration::days(1)) .assign_with_units("dev", 0.25)]; let solver = CpmSolver::new();
3955 let schedule = solver.schedule(&project).unwrap();
3956
3957 assert_eq!(schedule.tasks["meeting"].duration.as_days(), 1.0);
3959 }
3960
3961 #[test]
3962 fn effort_chain_with_different_allocations() {
3963 let mut project = Project::new("Test");
3965 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
3966 project.resources = vec![Resource::new("dev")];
3967 project.tasks = vec![
3968 Task::new("phase1").effort(Duration::days(5)).assign("dev"), Task::new("phase2")
3970 .effort(Duration::days(5))
3971 .assign_with_units("dev", 0.5) .depends_on("phase1"),
3973 ];
3974
3975 let solver = CpmSolver::new();
3976 let schedule = solver.schedule(&project).unwrap();
3977
3978 assert_eq!(schedule.project_duration.as_days(), 15.0);
3980 assert_eq!(schedule.tasks["phase1"].duration.as_days(), 5.0);
3981 assert_eq!(schedule.tasks["phase2"].duration.as_days(), 10.0);
3982 }
3983
3984 #[test]
3985 fn solver_default() {
3986 let solver = CpmSolver::default();
3987 assert!(!solver.resource_leveling);
3988 }
3989
3990 #[test]
3991 fn explain_nonexistent_task() {
3992 let project = make_test_project();
3993 let solver = CpmSolver::new();
3994
3995 let explanation = solver.explain(&project, &"nonexistent".to_string());
3996 assert_eq!(explanation.task_id, "nonexistent");
3997 assert!(explanation.reason.contains("not found"));
3998 }
3999
4000 #[test]
4001 fn feasibility_check_circular_dependency() {
4002 use utf8proj_core::Scheduler;
4003
4004 let mut project = Project::new("Circular");
4005 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4006 project.tasks = vec![
4007 Task::new("a").effort(Duration::days(1)).depends_on("c"),
4008 Task::new("b").effort(Duration::days(1)).depends_on("a"),
4009 Task::new("c").effort(Duration::days(1)).depends_on("b"),
4010 ];
4011
4012 let solver = CpmSolver::new();
4013 let result = solver.is_feasible(&project);
4014
4015 assert!(!result.feasible);
4016 assert!(!result.conflicts.is_empty());
4017 }
4018
4019 #[test]
4020 fn feasibility_check_constraint_conflict() {
4021 use utf8proj_core::{ConflictType, Scheduler, TaskConstraint};
4022
4023 let mut project = Project::new("Constraint Conflict");
4024 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4025 project.tasks = vec![
4026 Task::new("blocker").effort(Duration::days(10)),
4027 Task::new("blocked")
4028 .effort(Duration::days(5))
4029 .depends_on("blocker")
4030 .constraint(TaskConstraint::MustStartOn(
4033 NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(),
4034 )),
4035 ];
4036
4037 let solver = CpmSolver::new();
4038 let result = solver.is_feasible(&project);
4039
4040 assert!(!result.feasible);
4041 assert!(!result.conflicts.is_empty());
4042 assert_eq!(
4043 result.conflicts[0].conflict_type,
4044 ConflictType::ImpossibleConstraint
4045 );
4046 assert!(!result.conflicts[0].involved_tasks.is_empty());
4048 assert!(!result.suggestions.is_empty());
4050 }
4051
4052 #[test]
4053 fn feasibility_check_valid_constraints() {
4054 use utf8proj_core::{Scheduler, TaskConstraint};
4055
4056 let mut project = Project::new("Valid Constraints");
4057 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4058 project.tasks = vec![Task::new("task1")
4059 .effort(Duration::days(5))
4060 .constraint(TaskConstraint::StartNoEarlierThan(
4062 NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(),
4063 ))];
4064
4065 let solver = CpmSolver::new();
4066 let result = solver.is_feasible(&project);
4067
4068 assert!(result.feasible);
4069 assert!(result.conflicts.is_empty());
4070 }
4071
4072 #[test]
4073 fn extract_task_from_infeasible_message_works() {
4074 let msg = "task 'my_task' has infeasible constraints: ES (10) > LS (4), slack = -6 days";
4075 let result = super::extract_task_from_infeasible_message(msg);
4076 assert_eq!(result, Some("my_task".to_string()));
4077
4078 let msg2 = "task 'nested.task.id' has infeasible constraints";
4079 let result2 = super::extract_task_from_infeasible_message(msg2);
4080 assert_eq!(result2, Some("nested.task.id".to_string()));
4081
4082 let msg3 = "some other error message";
4083 let result3 = super::extract_task_from_infeasible_message(msg3);
4084 assert_eq!(result3, None);
4085 }
4086
4087 #[test]
4088 fn isolated_task_no_dependencies() {
4089 let mut project = Project::new("Isolated");
4091 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4092 project.tasks = vec![Task::new("alone").effort(Duration::days(3))];
4093
4094 let solver = CpmSolver::new();
4095 let schedule = solver.schedule(&project).unwrap();
4096
4097 assert!(schedule.tasks.contains_key("alone"));
4098 assert_eq!(schedule.tasks["alone"].duration, Duration::days(3));
4099 }
4100
4101 #[test]
4102 fn deeply_nested_tasks() {
4103 let mut project = Project::new("Deep Nesting");
4105 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4106 project.tasks =
4107 vec![Task::new("level1")
4108 .child(Task::new("level2").child(
4109 Task::new("level3").child(Task::new("leaf").effort(Duration::days(2))),
4110 ))];
4111
4112 let solver = CpmSolver::new();
4113 let schedule = solver.schedule(&project).unwrap();
4114
4115 assert!(schedule.tasks.contains_key("level1"));
4117 assert!(schedule.tasks.contains_key("level1.level2"));
4118 assert!(schedule.tasks.contains_key("level1.level2.level3"));
4119 assert!(schedule.tasks.contains_key("level1.level2.level3.leaf"));
4120 }
4121
4122 #[test]
4123 fn explain_task_with_no_dependencies() {
4124 let mut project = Project::new("Simple");
4125 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4126 project.tasks = vec![Task::new("standalone").effort(Duration::days(5))];
4127
4128 let solver = CpmSolver::new();
4129 let explanation = solver.explain(&project, &"standalone".to_string());
4130
4131 assert!(explanation.reason.contains("project start"));
4132 assert!(explanation.constraints_applied.is_empty());
4133 }
4134
4135 #[test]
4136 fn explain_task_with_dependencies_shows_constraints() {
4137 let project = make_test_project();
4139 let solver = CpmSolver::new();
4140
4141 let explanation = solver.explain(&project, &"implement".to_string());
4143
4144 assert_eq!(explanation.task_id, "implement");
4145 assert!(explanation.reason.contains("predecessors"));
4146 assert!(!explanation.constraints_applied.is_empty());
4147 assert!(explanation
4148 .constraints_applied
4149 .iter()
4150 .any(|c| c.contains("design")));
4151 }
4152
4153 #[test]
4154 fn explain_task_with_temporal_constraint_shows_effects() {
4155 use utf8proj_core::{ConstraintEffectType, TaskConstraint};
4156
4157 let mut project = Project::new("Constraint Test");
4158 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4159 project.tasks = vec![Task::new("constrained")
4160 .effort(Duration::days(5))
4161 .constraint(TaskConstraint::StartNoEarlierThan(
4162 NaiveDate::from_ymd_opt(2025, 1, 13).unwrap(),
4163 ))];
4164
4165 let solver = CpmSolver::new();
4166 let explanation = solver.explain(&project, &"constrained".to_string());
4167
4168 assert_eq!(explanation.task_id, "constrained");
4169 assert_eq!(explanation.constraint_effects.len(), 1);
4170
4171 let effect = &explanation.constraint_effects[0];
4172 assert!(matches!(
4173 effect.constraint,
4174 TaskConstraint::StartNoEarlierThan(_)
4175 ));
4176 assert_eq!(effect.effect, ConstraintEffectType::PushedStart);
4177 assert!(effect.description.contains("2025-01-13"));
4178 }
4179
4180 #[test]
4181 fn explain_task_with_pinned_constraint() {
4182 use utf8proj_core::{ConstraintEffectType, TaskConstraint};
4183
4184 let mut project = Project::new("Pin Test");
4185 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4186 project.tasks = vec![Task::new("pinned").effort(Duration::days(3)).constraint(
4187 TaskConstraint::MustStartOn(NaiveDate::from_ymd_opt(2025, 1, 6).unwrap()),
4188 )];
4189
4190 let solver = CpmSolver::new();
4191 let explanation = solver.explain(&project, &"pinned".to_string());
4192
4193 assert_eq!(explanation.constraint_effects.len(), 1);
4194 let effect = &explanation.constraint_effects[0];
4195 assert_eq!(effect.effect, ConstraintEffectType::Pinned);
4196 assert!(effect.description.contains("pinned"));
4197 }
4198
4199 #[test]
4200 fn explain_task_with_redundant_constraint() {
4201 use utf8proj_core::{ConstraintEffectType, TaskConstraint};
4202
4203 let mut project = Project::new("Redundant Test");
4204 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4205 project.tasks = vec![
4206 Task::new("blocker").effort(Duration::days(10)),
4207 Task::new("blocked")
4208 .effort(Duration::days(5))
4209 .depends_on("blocker")
4210 .constraint(TaskConstraint::StartNoEarlierThan(
4212 NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(),
4213 )),
4214 ];
4215
4216 let solver = CpmSolver::new();
4217 let explanation = solver.explain(&project, &"blocked".to_string());
4218
4219 assert_eq!(explanation.constraint_effects.len(), 1);
4220 let effect = &explanation.constraint_effects[0];
4221 assert_eq!(effect.effect, ConstraintEffectType::Redundant);
4222 assert!(effect.description.contains("redundant"));
4223 }
4224
4225 #[test]
4226 fn explain_task_without_constraints_has_empty_effects() {
4227 let project = make_test_project();
4228 let solver = CpmSolver::new();
4229
4230 let explanation = solver.explain(&project, &"design".to_string());
4231
4232 assert!(explanation.constraint_effects.is_empty());
4233 }
4234
4235 #[test]
4236 fn explain_task_with_calendar_impact() {
4237 let mut project = Project::new("Calendar Impact Test");
4239 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(); let mut calendar = utf8proj_core::Calendar::default();
4243 calendar.id = "standard".to_string();
4244 calendar.working_days = vec![1, 2, 3, 4, 5]; project.calendars.push(calendar);
4246 project.calendar = "standard".to_string();
4247
4248 project.tasks = vec![Task::new("work").duration(Duration::days(10))];
4250
4251 let solver = CpmSolver::new();
4252 let explanation = solver.explain(&project, &"work".to_string());
4253
4254 assert!(
4256 explanation.calendar_impact.is_some(),
4257 "Calendar impact should be calculated"
4258 );
4259
4260 let impact = explanation.calendar_impact.unwrap();
4261 assert_eq!(impact.calendar_id, "standard");
4262 assert!(impact.non_working_days > 0, "Should have non-working days");
4263 assert!(impact.weekend_days > 0, "Should have weekend days");
4264 assert!(
4265 impact.description.contains("working"),
4266 "Description should mention working days"
4267 );
4268
4269 eprintln!("Calendar impact: {}", impact.description);
4270 eprintln!(
4271 "Non-working: {}, Weekends: {}, Holidays: {}",
4272 impact.non_working_days, impact.weekend_days, impact.holiday_days
4273 );
4274 }
4275
4276 #[test]
4277 fn explain_task_with_holiday_impact() {
4278 use utf8proj_core::Holiday;
4279
4280 let mut project = Project::new("Holiday Impact Test");
4281 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(); let mut calendar = utf8proj_core::Calendar::default();
4285 calendar.id = "with_holiday".to_string();
4286 calendar.working_days = vec![1, 2, 3, 4, 5]; calendar.holidays = vec![Holiday {
4288 name: "Test Holiday".to_string(),
4289 start: NaiveDate::from_ymd_opt(2025, 1, 8).unwrap(), end: NaiveDate::from_ymd_opt(2025, 1, 8).unwrap(),
4291 }];
4292 project.calendars.push(calendar);
4293 project.calendar = "with_holiday".to_string();
4294
4295 project.tasks = vec![Task::new("work").duration(Duration::days(5))];
4297
4298 let solver = CpmSolver::new();
4299 let explanation = solver.explain(&project, &"work".to_string());
4300
4301 assert!(explanation.calendar_impact.is_some());
4302 let impact = explanation.calendar_impact.unwrap();
4303
4304 assert!(
4306 impact.holiday_days > 0,
4307 "Should detect holiday: {}",
4308 impact.description
4309 );
4310 eprintln!("Holiday impact: {}", impact.description);
4311 }
4312
4313 #[test]
4314 fn explain_includes_related_diagnostics_field() {
4315 let project = make_test_project();
4317 let solver = CpmSolver::new();
4318
4319 let explanation = solver.explain(&project, &"design".to_string());
4320
4321 assert!(
4323 explanation.related_diagnostics.is_empty(),
4324 "related_diagnostics should start empty"
4325 );
4326 }
4327
4328 #[test]
4329 fn schedule_with_dependency_on_container() {
4330 let mut project = Project::new("Container Dep");
4332 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4333 project.tasks = vec![
4334 Task::new("phase1")
4335 .child(Task::new("a").effort(Duration::days(3)))
4336 .child(Task::new("b").effort(Duration::days(2)).depends_on("a")),
4337 Task::new("phase2")
4338 .effort(Duration::days(4))
4339 .depends_on("phase1"), ];
4341
4342 let solver = CpmSolver::new();
4343 let schedule = solver.schedule(&project).unwrap();
4344
4345 let phase1_b = &schedule.tasks["phase1.b"];
4347 let phase2 = &schedule.tasks["phase2"];
4348 assert!(phase2.start > phase1_b.finish);
4349 }
4350
4351 #[test]
4352 fn schedule_with_relative_sibling_dependency() {
4353 let mut project = Project::new("Sibling Deps");
4355 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4356 project.tasks = vec![Task::new("container")
4357 .child(Task::new("first").effort(Duration::days(3)))
4358 .child(
4359 Task::new("second")
4360 .effort(Duration::days(2))
4361 .depends_on("first"),
4362 )
4363 .child(
4364 Task::new("third")
4365 .effort(Duration::days(1))
4366 .depends_on("second"),
4367 )];
4368
4369 let solver = CpmSolver::new();
4370 let schedule = solver.schedule(&project).unwrap();
4371
4372 assert!(
4374 schedule.tasks["container.second"].start > schedule.tasks["container.first"].finish
4375 );
4376 assert!(
4377 schedule.tasks["container.third"].start > schedule.tasks["container.second"].finish
4378 );
4379 }
4380
4381 #[test]
4382 fn working_day_cache_beyond_limit() {
4383 let mut project = Project::new("Long Project");
4385 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4386 project.tasks = vec![
4387 Task::new("long_task").duration(Duration::days(500)), ];
4389
4390 let solver = CpmSolver::new();
4391 let schedule = solver.schedule(&project).unwrap();
4392
4393 assert!(schedule.tasks.contains_key("long_task"));
4395 assert_eq!(schedule.tasks["long_task"].duration, Duration::days(500));
4396 }
4397
4398 #[test]
4403 fn trait_multiplier_single() {
4404 use utf8proj_core::Trait;
4405
4406 let mut project = Project::new("Test");
4407 project
4408 .traits
4409 .push(Trait::new("senior").rate_multiplier(1.3));
4410
4411 let multiplier = calculate_trait_multiplier(&["senior".to_string()], &project);
4412 assert!((multiplier - 1.3).abs() < 0.001);
4413 }
4414
4415 #[test]
4416 fn trait_multiplier_multiplicative() {
4417 use utf8proj_core::Trait;
4418
4419 let mut project = Project::new("Test");
4420 project
4421 .traits
4422 .push(Trait::new("senior").rate_multiplier(1.3));
4423 project
4424 .traits
4425 .push(Trait::new("contractor").rate_multiplier(1.2));
4426
4427 let multiplier =
4428 calculate_trait_multiplier(&["senior".to_string(), "contractor".to_string()], &project);
4429 assert!((multiplier - 1.56).abs() < 0.001);
4431 }
4432
4433 #[test]
4434 fn trait_multiplier_unknown_trait_ignored() {
4435 use utf8proj_core::Trait;
4436
4437 let mut project = Project::new("Test");
4438 project
4439 .traits
4440 .push(Trait::new("senior").rate_multiplier(1.3));
4441
4442 let multiplier =
4443 calculate_trait_multiplier(&["senior".to_string(), "unknown".to_string()], &project);
4444 assert!((multiplier - 1.3).abs() < 0.001);
4446 }
4447
4448 #[test]
4449 fn resolve_profile_rate_basic() {
4450 let mut project = Project::new("Test");
4451 project.profiles.push(
4452 ResourceProfile::new("developer")
4453 .rate_range(RateRange::new(Decimal::from(50), Decimal::from(100))),
4454 );
4455
4456 let profile = project.get_profile("developer").unwrap();
4457 let rate = resolve_profile_rate(profile, &project).unwrap();
4458
4459 assert_eq!(rate.min, Decimal::from(50));
4460 assert_eq!(rate.max, Decimal::from(100));
4461 }
4462
4463 #[test]
4464 fn resolve_profile_rate_with_trait_multiplier() {
4465 use utf8proj_core::Trait;
4466
4467 let mut project = Project::new("Test");
4468 project
4469 .traits
4470 .push(Trait::new("senior").rate_multiplier(1.5));
4471 project.profiles.push(
4472 ResourceProfile::new("developer")
4473 .rate_range(RateRange::new(Decimal::from(100), Decimal::from(200)))
4474 .with_trait("senior"),
4475 );
4476
4477 let profile = project.get_profile("developer").unwrap();
4478 let rate = resolve_profile_rate(profile, &project).unwrap();
4479
4480 assert_eq!(rate.min, Decimal::from(150));
4482 assert_eq!(rate.max, Decimal::from(300));
4483 }
4484
4485 #[test]
4486 fn resolve_profile_rate_inherited() {
4487 let mut project = Project::new("Test");
4488 project.profiles.push(
4489 ResourceProfile::new("developer")
4490 .rate_range(RateRange::new(Decimal::from(80), Decimal::from(120))),
4491 );
4492 project
4493 .profiles
4494 .push(ResourceProfile::new("senior_developer").specializes("developer"));
4495
4496 let profile = project.get_profile("senior_developer").unwrap();
4497 let rate = resolve_profile_rate(profile, &project).unwrap();
4498
4499 assert_eq!(rate.min, Decimal::from(80));
4501 assert_eq!(rate.max, Decimal::from(120));
4502 }
4503
4504 #[test]
4505 fn resolve_assignment_concrete_resource() {
4506 use utf8proj_core::Resource;
4507
4508 let mut project = Project::new("Test");
4509 project
4510 .resources
4511 .push(Resource::new("alice").rate(Money::new(Decimal::from(75), "USD")));
4512
4513 match resolve_assignment("alice", &project) {
4514 ResolvedAssignment::Concrete { rate, resource_id } => {
4515 assert_eq!(resource_id, "alice");
4516 assert!(rate.is_some());
4517 assert_eq!(rate.unwrap().amount, Decimal::from(75));
4518 }
4519 _ => panic!("Expected concrete assignment"),
4520 }
4521 }
4522
4523 #[test]
4524 fn resolve_assignment_abstract_profile() {
4525 let mut project = Project::new("Test");
4526 project.profiles.push(
4527 ResourceProfile::new("developer")
4528 .rate_range(RateRange::new(Decimal::from(60), Decimal::from(100))),
4529 );
4530
4531 match resolve_assignment("developer", &project) {
4532 ResolvedAssignment::Abstract {
4533 rate_range,
4534 profile_id,
4535 } => {
4536 assert_eq!(profile_id, "developer");
4537 let range = rate_range.unwrap();
4538 assert_eq!(range.min, Decimal::from(60));
4539 assert_eq!(range.max, Decimal::from(100));
4540 }
4541 _ => panic!("Expected abstract assignment"),
4542 }
4543 }
4544
4545 #[test]
4546 fn calculate_cost_concrete() {
4547 use utf8proj_core::Resource;
4548
4549 let mut project = Project::new("Test");
4550 project
4551 .resources
4552 .push(Resource::new("alice").rate(Money::new(Decimal::from(100), "USD")));
4553
4554 let (cost, is_abstract) = calculate_assignment_cost("alice", 1.0, 5, &project);
4555
4556 assert!(!is_abstract);
4557 let cost = cost.unwrap();
4558 assert_eq!(cost.min, Decimal::from(500));
4560 assert_eq!(cost.max, Decimal::from(500));
4561 assert_eq!(cost.expected, Decimal::from(500));
4562 }
4563
4564 #[test]
4565 fn calculate_cost_abstract() {
4566 let mut project = Project::new("Test");
4567 project.profiles.push(
4568 ResourceProfile::new("developer")
4569 .rate_range(RateRange::new(Decimal::from(50), Decimal::from(100))),
4570 );
4571
4572 let (cost, is_abstract) = calculate_assignment_cost("developer", 1.0, 10, &project);
4573
4574 assert!(is_abstract);
4575 let cost = cost.unwrap();
4576 assert_eq!(cost.min, Decimal::from(500));
4578 assert_eq!(cost.max, Decimal::from(1000));
4579 assert_eq!(cost.expected, Decimal::from(750));
4581 }
4582
4583 #[test]
4584 fn calculate_cost_with_partial_allocation() {
4585 use utf8proj_core::Resource;
4586
4587 let mut project = Project::new("Test");
4588 project
4589 .resources
4590 .push(Resource::new("bob").rate(Money::new(Decimal::from(200), "EUR")));
4591
4592 let (cost, is_abstract) = calculate_assignment_cost("bob", 0.5, 4, &project);
4593
4594 assert!(!is_abstract);
4595 let cost = cost.unwrap();
4596 assert_eq!(cost.min, Decimal::from(400));
4598 assert_eq!(cost.currency, "EUR");
4599 }
4600
4601 #[test]
4602 fn aggregate_cost_ranges_single() {
4603 let ranges = vec![CostRange::fixed(Decimal::from(100), "USD")];
4604 let total = aggregate_cost_ranges(&ranges).unwrap();
4605
4606 assert_eq!(total.min, Decimal::from(100));
4607 assert_eq!(total.max, Decimal::from(100));
4608 }
4609
4610 #[test]
4611 fn aggregate_cost_ranges_multiple() {
4612 let ranges = vec![
4613 CostRange::new(
4614 Decimal::from(100),
4615 Decimal::from(150),
4616 Decimal::from(200),
4617 "USD".to_string(),
4618 ),
4619 CostRange::new(
4620 Decimal::from(50),
4621 Decimal::from(75),
4622 Decimal::from(100),
4623 "USD".to_string(),
4624 ),
4625 ];
4626 let total = aggregate_cost_ranges(&ranges).unwrap();
4627
4628 assert_eq!(total.min, Decimal::from(150));
4629 assert_eq!(total.expected, Decimal::from(225));
4630 assert_eq!(total.max, Decimal::from(300));
4631 }
4632
4633 #[test]
4634 fn schedule_with_profile_assignment() {
4635 let mut project = Project::new("RFC-0001 Test");
4636 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4637 project.profiles.push(
4638 ResourceProfile::new("developer")
4639 .rate_range(RateRange::new(Decimal::from(100), Decimal::from(200))),
4640 );
4641 project.tasks = vec![Task::new("task1")
4642 .duration(Duration::days(5))
4643 .assign("developer")];
4644
4645 let solver = CpmSolver::new();
4646 let schedule = solver.schedule(&project).unwrap();
4647
4648 let task = &schedule.tasks["task1"];
4649 assert!(task.has_abstract_assignments);
4650 assert!(task.cost_range.is_some());
4651
4652 let cost = task.cost_range.as_ref().unwrap();
4653 assert_eq!(cost.min, Decimal::from(500));
4655 assert_eq!(cost.max, Decimal::from(1000));
4656 }
4657
4658 #[test]
4659 fn schedule_with_concrete_assignment() {
4660 use utf8proj_core::Resource;
4661
4662 let mut project = Project::new("Concrete Test");
4663 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4664 project
4665 .resources
4666 .push(Resource::new("alice").rate(Money::new(Decimal::from(150), "USD")));
4667 project.tasks = vec![Task::new("task1")
4668 .duration(Duration::days(4))
4669 .assign("alice")];
4670
4671 let solver = CpmSolver::new();
4672 let schedule = solver.schedule(&project).unwrap();
4673
4674 let task = &schedule.tasks["task1"];
4675 assert!(!task.has_abstract_assignments);
4676 assert!(task.cost_range.is_some());
4677
4678 let cost = task.cost_range.as_ref().unwrap();
4679 assert_eq!(cost.min, Decimal::from(600));
4681 assert_eq!(cost.max, Decimal::from(600));
4682 }
4683
4684 #[test]
4685 fn schedule_aggregates_total_cost_range() {
4686 let mut project = Project::new("Aggregate Test");
4687 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4688 project.profiles.push(
4689 ResourceProfile::new("developer")
4690 .rate_range(RateRange::new(Decimal::from(100), Decimal::from(150))),
4691 );
4692 project.tasks = vec![
4693 Task::new("task1")
4694 .duration(Duration::days(2))
4695 .assign("developer"),
4696 Task::new("task2")
4697 .duration(Duration::days(3))
4698 .assign("developer")
4699 .depends_on("task1"),
4700 ];
4701
4702 let solver = CpmSolver::new();
4703 let schedule = solver.schedule(&project).unwrap();
4704
4705 assert!(schedule.total_cost_range.is_some());
4706 let total = schedule.total_cost_range.as_ref().unwrap();
4707 assert_eq!(total.min, Decimal::from(500));
4711 assert_eq!(total.max, Decimal::from(750));
4712 }
4713
4714 #[test]
4719 fn analyze_detects_circular_specialization() {
4720 use utf8proj_core::CollectingEmitter;
4721
4722 let mut project = Project::new("Cycle Test");
4723 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4724 project
4725 .profiles
4726 .push(ResourceProfile::new("a").specializes("b"));
4727 project
4728 .profiles
4729 .push(ResourceProfile::new("b").specializes("c"));
4730 project
4731 .profiles
4732 .push(ResourceProfile::new("c").specializes("a"));
4733
4734 let mut emitter = CollectingEmitter::new();
4735 let config = AnalysisConfig::default();
4736 analyze_project(&project, None, &config, &mut emitter);
4737
4738 assert!(emitter.has_errors());
4739 assert!(emitter
4740 .diagnostics
4741 .iter()
4742 .any(|d| d.code == DiagnosticCode::E001CircularSpecialization));
4743 }
4744
4745 #[test]
4746 fn analyze_detects_unknown_trait() {
4747 use utf8proj_core::CollectingEmitter;
4748
4749 let mut project = Project::new("Unknown Trait Test");
4750 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4751 project.profiles.push(
4752 ResourceProfile::new("dev")
4753 .rate_range(RateRange::new(Decimal::from(100), Decimal::from(200)))
4754 .with_trait("nonexistent"),
4755 );
4756
4757 let mut emitter = CollectingEmitter::new();
4758 let config = AnalysisConfig::default();
4759 analyze_project(&project, None, &config, &mut emitter);
4760
4761 assert!(emitter
4762 .diagnostics
4763 .iter()
4764 .any(|d| d.code == DiagnosticCode::W003UnknownTrait));
4765 }
4766
4767 #[test]
4768 fn analyze_detects_profile_without_rate() {
4769 use utf8proj_core::CollectingEmitter;
4770
4771 let mut project = Project::new("No Rate Test");
4772 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4773 project.profiles.push(ResourceProfile::new("dev")); project.tasks = vec![Task::new("task1").duration(Duration::days(5)).assign("dev")];
4775
4776 let mut emitter = CollectingEmitter::new();
4777 let config = AnalysisConfig::default();
4778 analyze_project(&project, None, &config, &mut emitter);
4779
4780 assert!(emitter
4781 .diagnostics
4782 .iter()
4783 .any(|d| d.code == DiagnosticCode::E002ProfileWithoutRate));
4784 }
4785
4786 #[test]
4787 fn analyze_detects_abstract_assignment() {
4788 use utf8proj_core::CollectingEmitter;
4789
4790 let mut project = Project::new("Abstract Test");
4791 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4792 project.profiles.push(
4793 ResourceProfile::new("developer")
4794 .rate_range(RateRange::new(Decimal::from(100), Decimal::from(200))),
4795 );
4796 project.tasks = vec![Task::new("task1")
4797 .duration(Duration::days(5))
4798 .assign("developer")];
4799
4800 let mut emitter = CollectingEmitter::new();
4801 let config = AnalysisConfig::default();
4802 analyze_project(&project, None, &config, &mut emitter);
4803
4804 assert!(emitter
4805 .diagnostics
4806 .iter()
4807 .any(|d| d.code == DiagnosticCode::W001AbstractAssignment));
4808 }
4809
4810 #[test]
4811 fn analyze_detects_mixed_abstraction() {
4812 use utf8proj_core::{CollectingEmitter, Resource};
4813
4814 let mut project = Project::new("Mixed Test");
4815 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4816 project
4817 .resources
4818 .push(Resource::new("alice").rate(Money::new(Decimal::from(100), "USD")));
4819 project.profiles.push(
4820 ResourceProfile::new("developer")
4821 .rate_range(RateRange::new(Decimal::from(100), Decimal::from(200))),
4822 );
4823 project.tasks = vec![Task::new("task1")
4824 .duration(Duration::days(5))
4825 .assign("alice")
4826 .assign("developer")];
4827
4828 let mut emitter = CollectingEmitter::new();
4829 let config = AnalysisConfig::default();
4830 analyze_project(&project, None, &config, &mut emitter);
4831
4832 assert!(emitter
4833 .diagnostics
4834 .iter()
4835 .any(|d| d.code == DiagnosticCode::H001MixedAbstraction));
4836 }
4837
4838 #[test]
4839 fn analyze_detects_unused_profile() {
4840 use utf8proj_core::CollectingEmitter;
4841
4842 let mut project = Project::new("Unused Profile Test");
4843 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4844 project.profiles.push(
4845 ResourceProfile::new("designer")
4846 .rate_range(RateRange::new(Decimal::from(80), Decimal::from(120))),
4847 );
4848 let mut emitter = CollectingEmitter::new();
4851 let config = AnalysisConfig::default();
4852 analyze_project(&project, None, &config, &mut emitter);
4853
4854 assert!(emitter
4855 .diagnostics
4856 .iter()
4857 .any(|d| d.code == DiagnosticCode::H002UnusedProfile));
4858 }
4859
4860 #[test]
4861 fn analyze_detects_unused_trait() {
4862 use utf8proj_core::{CollectingEmitter, Trait};
4863
4864 let mut project = Project::new("Unused Trait Test");
4865 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4866 project
4867 .traits
4868 .push(Trait::new("senior").rate_multiplier(1.3));
4869 let mut emitter = CollectingEmitter::new();
4872 let config = AnalysisConfig::default();
4873 analyze_project(&project, None, &config, &mut emitter);
4874
4875 assert!(emitter
4876 .diagnostics
4877 .iter()
4878 .any(|d| d.code == DiagnosticCode::H003UnusedTrait));
4879 }
4880
4881 #[test]
4882 fn analyze_emits_project_summary() {
4883 use utf8proj_core::{CollectingEmitter, Resource};
4884
4885 let mut project = Project::new("Summary Test");
4886 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4887 project
4888 .resources
4889 .push(Resource::new("alice").rate(Money::new(Decimal::from(100), "USD")));
4890 project.tasks = vec![Task::new("task1")
4891 .duration(Duration::days(5))
4892 .assign("alice")];
4893
4894 let solver = CpmSolver::new();
4895 let schedule = solver.schedule(&project).unwrap();
4896
4897 let mut emitter = CollectingEmitter::new();
4898 let config = AnalysisConfig::default();
4899 analyze_project(&project, Some(&schedule), &config, &mut emitter);
4900
4901 assert!(emitter
4902 .diagnostics
4903 .iter()
4904 .any(|d| d.code == DiagnosticCode::I001ProjectCostSummary));
4905 }
4906
4907 #[test]
4908 fn analyze_detects_wide_cost_range() {
4909 use utf8proj_core::CollectingEmitter;
4910
4911 let mut project = Project::new("Wide Range Test");
4912 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4913 project.profiles.push(
4915 ResourceProfile::new("developer")
4916 .rate_range(RateRange::new(Decimal::from(50), Decimal::from(200))),
4917 );
4918 project.tasks = vec![Task::new("task1")
4919 .duration(Duration::days(10))
4920 .assign("developer")];
4921
4922 let solver = CpmSolver::new();
4923 let schedule = solver.schedule(&project).unwrap();
4924
4925 let mut emitter = CollectingEmitter::new();
4926 let config = AnalysisConfig::default().with_cost_spread_threshold(50.0);
4927 analyze_project(&project, Some(&schedule), &config, &mut emitter);
4928
4929 assert!(emitter
4930 .diagnostics
4931 .iter()
4932 .any(|d| d.code == DiagnosticCode::W002WideCostRange));
4933 }
4934
4935 #[test]
4936 fn analyze_no_wide_cost_range_under_threshold() {
4937 use utf8proj_core::CollectingEmitter;
4938
4939 let mut project = Project::new("Narrow Range Test");
4940 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4941 project.profiles.push(
4943 ResourceProfile::new("developer")
4944 .rate_range(RateRange::new(Decimal::from(90), Decimal::from(110))),
4945 );
4946 project.tasks = vec![Task::new("task1")
4947 .duration(Duration::days(10))
4948 .assign("developer")];
4949
4950 let solver = CpmSolver::new();
4951 let schedule = solver.schedule(&project).unwrap();
4952
4953 let mut emitter = CollectingEmitter::new();
4954 let config = AnalysisConfig::default();
4955 analyze_project(&project, Some(&schedule), &config, &mut emitter);
4956
4957 assert!(!emitter
4958 .diagnostics
4959 .iter()
4960 .any(|d| d.code == DiagnosticCode::W002WideCostRange));
4961 }
4962
4963 #[test]
4964 fn analysis_config_builder() {
4965 let config = AnalysisConfig::new()
4966 .with_file("test.proj")
4967 .with_cost_spread_threshold(75.0);
4968
4969 assert_eq!(config.file, Some(PathBuf::from("test.proj")));
4970 assert_eq!(config.cost_spread_threshold, 75.0);
4971 }
4972
4973 #[test]
4978 fn summary_unknown_cost_when_no_rate_data() {
4979 use utf8proj_core::CollectingEmitter;
4980
4981 let mut project = Project::new("No Cost Data Test");
4983 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
4984 project.tasks = vec![Task::new("task1").duration(Duration::days(5))];
4985
4986 let solver = CpmSolver::new();
4987 let schedule = solver.schedule(&project).unwrap();
4988
4989 assert!(schedule.total_cost_range.is_none());
4991
4992 let mut emitter = CollectingEmitter::new();
4993 let config = AnalysisConfig::default();
4994 analyze_project(&project, Some(&schedule), &config, &mut emitter);
4995
4996 let summary = emitter
4998 .diagnostics
4999 .iter()
5000 .find(|d| d.code == DiagnosticCode::I001ProjectCostSummary)
5001 .expect("Should have I001");
5002 assert!(summary.notes.iter().any(|n| n.contains("unknown")));
5003 }
5004
5005 #[test]
5006 fn e002_with_many_tasks_truncates_list() {
5007 use utf8proj_core::CollectingEmitter;
5008
5009 let mut project = Project::new("Many Tasks Test");
5011 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5012 project.profiles.push(ResourceProfile::new("dev")); project.tasks = vec![
5014 Task::new("task1").duration(Duration::days(1)).assign("dev"),
5015 Task::new("task2").duration(Duration::days(1)).assign("dev"),
5016 Task::new("task3").duration(Duration::days(1)).assign("dev"),
5017 Task::new("task4").duration(Duration::days(1)).assign("dev"),
5018 Task::new("task5").duration(Duration::days(1)).assign("dev"),
5019 ];
5020
5021 let mut emitter = CollectingEmitter::new();
5022 let config = AnalysisConfig::default();
5023 analyze_project(&project, None, &config, &mut emitter);
5024
5025 let e002 = emitter
5026 .diagnostics
5027 .iter()
5028 .find(|d| d.code == DiagnosticCode::E002ProfileWithoutRate)
5029 .expect("Should have E002");
5030 assert!(e002.notes.iter().any(|n| n.contains("5 tasks")));
5032 }
5033
5034 #[test]
5035 fn w002_with_trait_multiplier_contributor() {
5036 use utf8proj_core::{CollectingEmitter, Trait};
5037
5038 let mut project = Project::new("Trait Contributor Test");
5040 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5041 project
5043 .traits
5044 .push(Trait::new("senior").rate_multiplier(1.5));
5045 project.profiles.push(
5047 ResourceProfile::new("developer")
5048 .rate_range(RateRange::new(Decimal::from(50), Decimal::from(200)))
5049 .with_trait("senior"),
5050 );
5051 project.tasks = vec![Task::new("task1")
5052 .duration(Duration::days(10))
5053 .assign("developer")];
5054
5055 let solver = CpmSolver::new();
5056 let schedule = solver.schedule(&project).unwrap();
5057
5058 let mut emitter = CollectingEmitter::new();
5059 let config = AnalysisConfig::default().with_cost_spread_threshold(50.0);
5060 analyze_project(&project, Some(&schedule), &config, &mut emitter);
5061
5062 let w002 = emitter
5063 .diagnostics
5064 .iter()
5065 .find(|d| d.code == DiagnosticCode::W002WideCostRange)
5066 .expect("Should have W002");
5067 assert!(w002
5069 .notes
5070 .iter()
5071 .any(|n| n.contains("senior") && n.contains("multiplier")));
5072 }
5073
5074 #[test]
5075 fn profile_with_fixed_rate_converts_to_range() {
5076 use utf8proj_core::CollectingEmitter;
5077
5078 let mut project = Project::new("Fixed Rate Test");
5080 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5081 project
5082 .profiles
5083 .push(ResourceProfile::new("developer").rate(Money::new(Decimal::from(100), "USD")));
5084 project.tasks = vec![Task::new("task1")
5085 .duration(Duration::days(5))
5086 .assign("developer")];
5087
5088 let solver = CpmSolver::new();
5089 let schedule = solver.schedule(&project).unwrap();
5090
5091 let mut emitter = CollectingEmitter::new();
5092 let config = AnalysisConfig::default();
5093 analyze_project(&project, Some(&schedule), &config, &mut emitter);
5094
5095 assert!(emitter
5097 .diagnostics
5098 .iter()
5099 .any(|d| d.code == DiagnosticCode::W001AbstractAssignment));
5100 let summary = emitter
5102 .diagnostics
5103 .iter()
5104 .find(|d| d.code == DiagnosticCode::I001ProjectCostSummary)
5105 .expect("Should have I001");
5106 assert!(summary.notes.iter().any(|n| n.contains("$500")));
5107 }
5108
5109 #[test]
5110 fn specializes_inherits_rate_from_parent() {
5111 use utf8proj_core::CollectingEmitter;
5112
5113 let mut project = Project::new("Specialization Test");
5115 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5116 project.profiles.push(
5117 ResourceProfile::new("developer")
5118 .rate_range(RateRange::new(Decimal::from(100), Decimal::from(200))),
5119 );
5120 project
5121 .profiles
5122 .push(ResourceProfile::new("senior_developer").specializes("developer"));
5123 project.tasks = vec![Task::new("task1")
5124 .duration(Duration::days(5))
5125 .assign("senior_developer")];
5126
5127 let solver = CpmSolver::new();
5128 let schedule = solver.schedule(&project).unwrap();
5129
5130 let mut emitter = CollectingEmitter::new();
5131 let config = AnalysisConfig::default();
5132 analyze_project(&project, Some(&schedule), &config, &mut emitter);
5133
5134 assert!(!emitter
5136 .diagnostics
5137 .iter()
5138 .any(|d| d.code == DiagnosticCode::E002ProfileWithoutRate));
5139 let summary = emitter
5141 .diagnostics
5142 .iter()
5143 .find(|d| d.code == DiagnosticCode::I001ProjectCostSummary)
5144 .expect("Should have I001");
5145 assert!(summary
5146 .notes
5147 .iter()
5148 .any(|n| n.contains("$") && n.contains("-")));
5149 }
5150
5151 #[test]
5152 fn nested_child_task_with_profile_assignment() {
5153 use utf8proj_core::CollectingEmitter;
5154
5155 let mut project = Project::new("Nested Assignment Test");
5157 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5158 project.profiles.push(
5159 ResourceProfile::new("developer")
5160 .rate_range(RateRange::new(Decimal::from(100), Decimal::from(200))),
5161 );
5162 project.tasks = vec![Task::new("phase1")
5163 .child(
5164 Task::new("design")
5165 .duration(Duration::days(3))
5166 .assign("developer"),
5167 )
5168 .child(
5169 Task::new("implement")
5170 .duration(Duration::days(5))
5171 .assign("developer"),
5172 )];
5173
5174 let mut emitter = CollectingEmitter::new();
5175 let config = AnalysisConfig::default();
5176 analyze_project(&project, None, &config, &mut emitter);
5177
5178 let w001_count = emitter
5180 .diagnostics
5181 .iter()
5182 .filter(|d| d.code == DiagnosticCode::W001AbstractAssignment)
5183 .count();
5184 assert_eq!(w001_count, 2);
5185 }
5186
5187 #[test]
5188 fn abstract_assignment_without_rate_shows_unknown_cost() {
5189 use utf8proj_core::CollectingEmitter;
5190
5191 let mut project = Project::new("Unknown Cost Range Test");
5193 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5194 project.profiles.push(ResourceProfile::new("developer")); project.tasks = vec![Task::new("task1")
5196 .duration(Duration::days(5))
5197 .assign("developer")];
5198
5199 let mut emitter = CollectingEmitter::new();
5200 let config = AnalysisConfig::default();
5201 analyze_project(&project, None, &config, &mut emitter);
5202
5203 let w001 = emitter
5204 .diagnostics
5205 .iter()
5206 .find(|d| d.code == DiagnosticCode::W001AbstractAssignment)
5207 .expect("Should have W001");
5208 assert!(w001.notes.iter().any(|n| n.contains("unknown")));
5209 }
5210
5211 #[test]
5216 fn isolated_task_no_predecessors_or_successors() {
5217 let mut project = Project::new("Isolated Task Test");
5220 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5221 project.tasks = vec![Task::new("alone").duration(Duration::days(3))];
5222
5223 let solver = CpmSolver::new();
5224 let schedule = solver.schedule(&project).unwrap();
5225
5226 assert_eq!(schedule.tasks.len(), 1);
5228 assert!(schedule.tasks["alone"].is_critical);
5229 assert_eq!(schedule.tasks["alone"].slack, Duration::zero());
5230 }
5231
5232 #[test]
5233 fn parallel_tasks_no_dependencies() {
5234 let mut project = Project::new("Parallel Test");
5237 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5238 project.tasks = vec![
5239 Task::new("a").duration(Duration::days(5)),
5240 Task::new("b").duration(Duration::days(3)),
5241 Task::new("c").duration(Duration::days(7)),
5242 ];
5243
5244 let solver = CpmSolver::new();
5245 let schedule = solver.schedule(&project).unwrap();
5246
5247 let start = project.start;
5249 assert_eq!(schedule.tasks["a"].start, start);
5250 assert_eq!(schedule.tasks["b"].start, start);
5251 assert_eq!(schedule.tasks["c"].start, start);
5252
5253 assert!(schedule.tasks["c"].is_critical);
5255 assert!(schedule.tasks["a"].slack.minutes > 0);
5257 assert!(schedule.tasks["b"].slack.minutes > 0);
5258 }
5259
5260 #[test]
5261 fn task_with_no_successors_uses_project_end() {
5262 let mut project = Project::new("Terminal Task Test");
5265 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5266 project.tasks = vec![
5267 Task::new("first").duration(Duration::days(5)),
5268 Task::new("last")
5269 .duration(Duration::days(3))
5270 .depends_on("first"),
5271 ];
5272
5273 let solver = CpmSolver::new();
5274 let schedule = solver.schedule(&project).unwrap();
5275
5276 assert!(schedule.tasks["first"].is_critical);
5278 assert!(schedule.tasks["last"].is_critical);
5279
5280 let last_task = &schedule.tasks["last"];
5282 assert_eq!(last_task.slack, Duration::zero()); }
5284
5285 #[test]
5286 fn relative_resolution_empty_container() {
5287 let mut project = Project::new("Root Level Test");
5290 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5291 project.tasks = vec![
5292 Task::new("root_a").duration(Duration::days(3)),
5293 Task::new("root_b")
5294 .duration(Duration::days(2))
5295 .depends_on("root_a"),
5296 ];
5297
5298 let solver = CpmSolver::new();
5299 let schedule = solver.schedule(&project).unwrap();
5300
5301 assert!(schedule.tasks["root_b"].start >= schedule.tasks["root_a"].finish);
5303 }
5304
5305 #[test]
5306 fn resolve_assignment_unknown_id() {
5307 let mut project = Project::new("Unknown Assignment Test");
5310 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5311 project.tasks = vec![Task::new("task1")
5312 .duration(Duration::days(5))
5313 .assign("nonexistent_entity")];
5314
5315 let solver = CpmSolver::new();
5316 let schedule = solver.schedule(&project).unwrap();
5317
5318 assert_eq!(schedule.tasks.len(), 1);
5320 }
5321
5322 #[test]
5323 fn abstract_profile_with_no_rate_in_cost_calculation() {
5324 let mut project = Project::new("No Rate Profile Test");
5327 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5328
5329 project.profiles.push(ResourceProfile::new("bare_profile"));
5331
5332 project.tasks = vec![Task::new("work")
5333 .duration(Duration::days(5))
5334 .assign("bare_profile")];
5335
5336 let solver = CpmSolver::new();
5337 let schedule = solver.schedule(&project).unwrap();
5338
5339 assert!(schedule.total_cost_range.is_none());
5341 }
5342
5343 #[test]
5344 fn working_day_cache_large_project() {
5345 let mut project = Project::new("Large Duration Test");
5348 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5349
5350 project.tasks = vec![Task::new("marathon").duration(Duration::days(2500))];
5352
5353 let solver = CpmSolver::new();
5354 let schedule = solver.schedule(&project).unwrap();
5355
5356 assert_eq!(schedule.tasks.len(), 1);
5358 assert!(schedule.project_duration.minutes >= Duration::days(2500).minutes);
5359 }
5360
5361 #[test]
5362 fn explain_constraint_must_finish_on_pinned() {
5363 let mut project = Project::new("MFO Pinned");
5365 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5366
5367 let mut task = Task::new("pinned_task");
5368 task.duration = Some(Duration::days(5));
5369 task.constraints.push(TaskConstraint::MustFinishOn(
5370 NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(),
5371 ));
5372 project.tasks = vec![task.clone()];
5373
5374 let solver = CpmSolver::new();
5375 let effects = solver.analyze_constraint_effects(&project, &task);
5376 assert_eq!(effects.len(), 1);
5377 assert!(
5378 effects[0].description.contains("pinned") || effects[0].description.contains("finish")
5379 );
5380 }
5381
5382 #[test]
5383 fn explain_constraint_start_no_later_than_capped() {
5384 let mut project = Project::new("SNLT Test");
5386 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5387
5388 let mut task = Task::new("capped_task");
5389 task.duration = Some(Duration::days(5));
5390 task.constraints.push(TaskConstraint::StartNoLaterThan(
5391 NaiveDate::from_ymd_opt(2025, 1, 20).unwrap(),
5392 ));
5393 project.tasks = vec![task.clone()];
5394
5395 let solver = CpmSolver::new();
5396 let effects = solver.analyze_constraint_effects(&project, &task);
5397 assert_eq!(effects.len(), 1);
5398 assert!(!effects[0].description.is_empty());
5400 }
5401
5402 #[test]
5403 fn explain_constraint_finish_no_earlier_than_pushed() {
5404 let mut project = Project::new("FNET Test");
5406 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5407
5408 let mut task = Task::new("pushed_task");
5409 task.duration = Some(Duration::days(5));
5410 task.constraints.push(TaskConstraint::FinishNoEarlierThan(
5411 NaiveDate::from_ymd_opt(2025, 1, 20).unwrap(),
5412 ));
5413 project.tasks = vec![task.clone()];
5414
5415 let solver = CpmSolver::new();
5416 let effects = solver.analyze_constraint_effects(&project, &task);
5417 assert_eq!(effects.len(), 1);
5418 assert!(!effects[0].description.is_empty());
5420 }
5421
5422 #[test]
5423 fn explain_constraint_finish_no_later_than_capped() {
5424 let mut project = Project::new("FNLT Test");
5426 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5427
5428 let mut task = Task::new("fnlt_task");
5429 task.duration = Some(Duration::days(5));
5430 task.constraints.push(TaskConstraint::FinishNoLaterThan(
5431 NaiveDate::from_ymd_opt(2025, 1, 20).unwrap(),
5432 ));
5433 project.tasks = vec![task.clone()];
5434
5435 let solver = CpmSolver::new();
5436 let effects = solver.analyze_constraint_effects(&project, &task);
5437 assert_eq!(effects.len(), 1);
5438 assert!(!effects[0].description.is_empty());
5439 }
5440
5441 #[test]
5442 fn explain_constraint_multiple_constraints() {
5443 let mut project = Project::new("Multi Constraint");
5445 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5446
5447 let mut task = Task::new("multi");
5448 task.duration = Some(Duration::days(5));
5449 task.constraints.push(TaskConstraint::StartNoEarlierThan(
5450 NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(),
5451 ));
5452 task.constraints.push(TaskConstraint::FinishNoLaterThan(
5453 NaiveDate::from_ymd_opt(2025, 1, 20).unwrap(),
5454 ));
5455 project.tasks = vec![task.clone()];
5456
5457 let solver = CpmSolver::new();
5458 let effects = solver.analyze_constraint_effects(&project, &task);
5459 assert_eq!(effects.len(), 2);
5460 }
5461
5462 #[test]
5463 fn explain_constraint_redundant_snet() {
5464 use utf8proj_core::Dependency;
5465 let mut project = Project::new("Redundant SNET");
5467 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5468
5469 let predecessor = Task::new("pred").duration(Duration::days(10));
5470 let mut successor = Task::new("succ");
5471 successor.duration = Some(Duration::days(5));
5472 successor.depends.push(Dependency {
5473 predecessor: "pred".to_string(),
5474 dep_type: DependencyType::FinishToStart,
5475 lag: None,
5476 });
5477 successor
5479 .constraints
5480 .push(TaskConstraint::StartNoEarlierThan(
5481 NaiveDate::from_ymd_opt(2025, 1, 8).unwrap(),
5482 ));
5483
5484 project.tasks = vec![predecessor, successor.clone()];
5485
5486 let solver = CpmSolver::new();
5487 let effects = solver.analyze_constraint_effects(&project, &successor);
5488 assert_eq!(effects.len(), 1);
5489 assert!(
5491 effects[0].description.contains("redundant")
5492 || effects[0].description.contains("superseded")
5493 || effects[0].description.contains("already")
5494 );
5495 }
5496
5497 #[test]
5498 fn explain_constraint_format_all_types() {
5499 use TaskConstraint::*;
5501 let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
5502
5503 let formatted = CpmSolver::format_constraint(&MustStartOn(date));
5504 assert!(formatted.contains("MustStartOn"));
5505
5506 let formatted = CpmSolver::format_constraint(&MustFinishOn(date));
5507 assert!(formatted.contains("MustFinishOn"));
5508
5509 let formatted = CpmSolver::format_constraint(&StartNoEarlierThan(date));
5510 assert!(formatted.contains("StartNoEarlierThan"));
5511
5512 let formatted = CpmSolver::format_constraint(&StartNoLaterThan(date));
5513 assert!(formatted.contains("StartNoLaterThan"));
5514
5515 let formatted = CpmSolver::format_constraint(&FinishNoEarlierThan(date));
5516 assert!(formatted.contains("FinishNoEarlierThan"));
5517
5518 let formatted = CpmSolver::format_constraint(&FinishNoLaterThan(date));
5519 assert!(formatted.contains("FinishNoLaterThan"));
5520 }
5521
5522 #[test]
5527 fn classify_duration_only_project() {
5528 let mut project = Project::new("Duration Only");
5529 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5530
5531 let mut task = Task::new("simple_task");
5533 task.duration = Some(Duration::days(5));
5534 project.tasks = vec![task];
5535
5536 let mode = classify_scheduling_mode(&project);
5537 assert_eq!(mode, SchedulingMode::DurationBased);
5538 }
5539
5540 #[test]
5541 fn classify_effort_based_project() {
5542 use std::collections::HashMap;
5543 use utf8proj_core::Resource;
5544
5545 let mut project = Project::new("Effort Based");
5546 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5547
5548 let resource = Resource {
5550 id: "dev".to_string(),
5551 name: "Developer".to_string(),
5552 rate: None,
5553 calendar: None,
5554 capacity: 1.0,
5555 efficiency: 1.0,
5556 attributes: HashMap::new(),
5557 specializes: None,
5558 availability: None,
5559 };
5560 project.resources = vec![resource];
5561
5562 let mut task = Task::new("effort_task");
5564 task.effort = Some(Duration::days(5));
5565 task.assigned = vec![utf8proj_core::ResourceRef {
5566 resource_id: "dev".to_string(),
5567 units: 1.0,
5568 }];
5569 project.tasks = vec![task];
5570
5571 let mode = classify_scheduling_mode(&project);
5572 assert_eq!(mode, SchedulingMode::EffortBased);
5573 }
5574
5575 #[test]
5576 fn classify_resource_loaded_project() {
5577 use std::collections::HashMap;
5578 use utf8proj_core::Resource;
5579
5580 let mut project = Project::new("Resource Loaded");
5581 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5582
5583 let resource = Resource {
5585 id: "dev".to_string(),
5586 name: "Developer".to_string(),
5587 rate: Some(Money::new(100, "USD")),
5588 calendar: None,
5589 capacity: 1.0,
5590 efficiency: 1.0,
5591 attributes: HashMap::new(),
5592 specializes: None,
5593 availability: None,
5594 };
5595 project.resources = vec![resource];
5596
5597 let mut task = Task::new("costed_task");
5599 task.effort = Some(Duration::days(5));
5600 task.assigned = vec![utf8proj_core::ResourceRef {
5601 resource_id: "dev".to_string(),
5602 units: 1.0,
5603 }];
5604 project.tasks = vec![task];
5605
5606 let mode = classify_scheduling_mode(&project);
5607 assert_eq!(mode, SchedulingMode::ResourceLoaded);
5608 }
5609
5610 #[test]
5611 fn classify_empty_project_is_duration_based() {
5612 let mut project = Project::new("Empty");
5613 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5614 project.tasks = vec![];
5615
5616 let mode = classify_scheduling_mode(&project);
5617 assert_eq!(mode, SchedulingMode::DurationBased);
5618 }
5619
5620 #[test]
5625 fn analyze_detects_inverted_rate_range() {
5626 use utf8proj_core::CollectingEmitter;
5628
5629 let mut project = Project::new("Inverted Rate Test");
5630 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5631 project.profiles.push(
5633 ResourceProfile::new("bad_profile")
5634 .rate_range(RateRange::new(Decimal::from(500), Decimal::from(100))),
5635 );
5636
5637 let mut emitter = CollectingEmitter::new();
5638 let config = AnalysisConfig::default();
5639 analyze_project(&project, None, &config, &mut emitter);
5640
5641 assert!(
5642 emitter
5643 .diagnostics
5644 .iter()
5645 .any(|d| d.code == DiagnosticCode::R102InvertedRateRange),
5646 "Expected R102 diagnostic for inverted rate range"
5647 );
5648 assert!(
5649 emitter.has_errors(),
5650 "Inverted rate range should be an error"
5651 );
5652 }
5653
5654 #[test]
5655 fn analyze_detects_unknown_profile_reference() {
5656 use utf8proj_core::CollectingEmitter;
5658
5659 let mut project = Project::new("Unknown Profile Test");
5660 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5661 project.profiles.push(
5663 ResourceProfile::new("orphan_profile")
5664 .specializes("nonexistent_parent")
5665 .rate_range(RateRange::new(Decimal::from(100), Decimal::from(200))),
5666 );
5667
5668 let mut emitter = CollectingEmitter::new();
5669 let config = AnalysisConfig::default();
5670 analyze_project(&project, None, &config, &mut emitter);
5671
5672 assert!(
5673 emitter
5674 .diagnostics
5675 .iter()
5676 .any(|d| d.code == DiagnosticCode::R104UnknownProfile),
5677 "Expected R104 diagnostic for unknown profile reference"
5678 );
5679 assert!(
5680 emitter.has_errors(),
5681 "Unknown profile reference should be an error"
5682 );
5683 }
5684
5685 #[test]
5686 fn analyze_detects_trait_multiplier_stack_exceeds_threshold() {
5687 use utf8proj_core::{CollectingEmitter, Trait};
5689
5690 let mut project = Project::new("High Multiplier Test");
5691 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5692
5693 project
5695 .traits
5696 .push(Trait::new("senior").rate_multiplier(1.5));
5697 project
5698 .traits
5699 .push(Trait::new("contractor").rate_multiplier(1.4));
5700
5701 project.profiles.push(
5703 ResourceProfile::new("expensive_dev")
5704 .rate_range(RateRange::new(Decimal::from(100), Decimal::from(200)))
5705 .with_trait("senior")
5706 .with_trait("contractor"),
5707 );
5708
5709 let mut emitter = CollectingEmitter::new();
5710 let config = AnalysisConfig::default();
5711 analyze_project(&project, None, &config, &mut emitter);
5712
5713 assert!(
5714 emitter
5715 .diagnostics
5716 .iter()
5717 .any(|d| d.code == DiagnosticCode::R012TraitMultiplierStack),
5718 "Expected R012 diagnostic for trait multiplier stack > 2.0"
5719 );
5720 }
5721
5722 #[test]
5723 fn analyze_no_warning_for_acceptable_trait_multiplier_stack() {
5724 use utf8proj_core::{CollectingEmitter, Trait};
5726
5727 let mut project = Project::new("Acceptable Multiplier Test");
5728 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5729
5730 project
5732 .traits
5733 .push(Trait::new("senior").rate_multiplier(1.3));
5734 project
5735 .traits
5736 .push(Trait::new("experienced").rate_multiplier(1.2));
5737
5738 project.profiles.push(
5740 ResourceProfile::new("reasonable_dev")
5741 .rate_range(RateRange::new(Decimal::from(100), Decimal::from(200)))
5742 .with_trait("senior")
5743 .with_trait("experienced"),
5744 );
5745
5746 let mut emitter = CollectingEmitter::new();
5747 let config = AnalysisConfig::default();
5748 analyze_project(&project, None, &config, &mut emitter);
5749
5750 assert!(
5751 !emitter
5752 .diagnostics
5753 .iter()
5754 .any(|d| d.code == DiagnosticCode::R012TraitMultiplierStack),
5755 "Should not emit R012 when multiplier stack is under 2.0"
5756 );
5757 }
5758
5759 #[test]
5760 fn analyze_valid_rate_range_no_error() {
5761 use utf8proj_core::CollectingEmitter;
5763
5764 let mut project = Project::new("Valid Rate Test");
5765 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5766 project.profiles.push(
5767 ResourceProfile::new("good_profile")
5768 .rate_range(RateRange::new(Decimal::from(100), Decimal::from(500))),
5769 );
5770
5771 let mut emitter = CollectingEmitter::new();
5772 let config = AnalysisConfig::default();
5773 analyze_project(&project, None, &config, &mut emitter);
5774
5775 assert!(
5776 !emitter
5777 .diagnostics
5778 .iter()
5779 .any(|d| d.code == DiagnosticCode::R102InvertedRateRange),
5780 "Valid rate range should not trigger R102"
5781 );
5782 }
5783
5784 #[test]
5785 fn analyze_equal_rate_range_no_error() {
5786 use utf8proj_core::CollectingEmitter;
5788
5789 let mut project = Project::new("Collapsed Rate Test");
5790 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5791 project.profiles.push(
5792 ResourceProfile::new("fixed_rate_profile")
5793 .rate_range(RateRange::new(Decimal::from(500), Decimal::from(500))),
5794 );
5795
5796 let mut emitter = CollectingEmitter::new();
5797 let config = AnalysisConfig::default();
5798 analyze_project(&project, None, &config, &mut emitter);
5799
5800 assert!(
5801 !emitter
5802 .diagnostics
5803 .iter()
5804 .any(|d| d.code == DiagnosticCode::R102InvertedRateRange),
5805 "Collapsed (equal) rate range should not trigger R102"
5806 );
5807 }
5808
5809 #[test]
5810 fn analyze_valid_profile_specialization_no_error() {
5811 use utf8proj_core::CollectingEmitter;
5813
5814 let mut project = Project::new("Valid Specialization Test");
5815 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
5816 project.profiles.push(
5817 ResourceProfile::new("parent")
5818 .rate_range(RateRange::new(Decimal::from(100), Decimal::from(200))),
5819 );
5820 project.profiles.push(
5821 ResourceProfile::new("child")
5822 .specializes("parent")
5823 .rate_range(RateRange::new(Decimal::from(150), Decimal::from(200))),
5824 );
5825
5826 let mut emitter = CollectingEmitter::new();
5827 let config = AnalysisConfig::default();
5828 analyze_project(&project, None, &config, &mut emitter);
5829
5830 assert!(
5831 !emitter
5832 .diagnostics
5833 .iter()
5834 .any(|d| d.code == DiagnosticCode::R104UnknownProfile),
5835 "Valid specialization should not trigger R104"
5836 );
5837 }
5838}