1use chrono::{Datelike, NaiveDate};
31use rust_decimal::Decimal;
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use thiserror::Error;
35
36pub type TaskId = String;
42
43pub type ResourceId = String;
45
46pub type CalendarId = String;
48
49pub type ProfileId = String;
51
52pub type TraitId = String;
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
57pub struct Duration {
58 pub minutes: i64,
60}
61
62impl Duration {
63 pub const fn zero() -> Self {
64 Self { minutes: 0 }
65 }
66
67 pub const fn minutes(m: i64) -> Self {
68 Self { minutes: m }
69 }
70
71 pub const fn hours(h: i64) -> Self {
72 Self { minutes: h * 60 }
73 }
74
75 pub const fn days(d: i64) -> Self {
76 Self {
77 minutes: d * 8 * 60,
78 } }
80
81 pub const fn weeks(w: i64) -> Self {
82 Self {
83 minutes: w * 5 * 8 * 60,
84 } }
86
87 pub fn as_days(&self) -> f64 {
88 self.minutes as f64 / (8.0 * 60.0)
89 }
90
91 pub fn as_hours(&self) -> f64 {
92 self.minutes as f64 / 60.0
93 }
94}
95
96impl std::ops::Add for Duration {
97 type Output = Self;
98 fn add(self, rhs: Self) -> Self {
99 Self {
100 minutes: self.minutes + rhs.minutes,
101 }
102 }
103}
104
105impl std::ops::Sub for Duration {
106 type Output = Self;
107 fn sub(self, rhs: Self) -> Self {
108 Self {
109 minutes: self.minutes - rhs.minutes,
110 }
111 }
112}
113
114#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
116pub struct Money {
117 pub amount: Decimal,
118 pub currency: String,
119}
120
121impl Money {
122 pub fn new(amount: impl Into<Decimal>, currency: impl Into<String>) -> Self {
123 Self {
124 amount: amount.into(),
125 currency: currency.into(),
126 }
127 }
128}
129
130#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
151pub struct Trait {
152 pub id: TraitId,
154 pub name: String,
156 pub description: Option<String>,
158 pub rate_multiplier: f64,
160}
161
162impl Trait {
163 pub fn new(id: impl Into<String>) -> Self {
165 let id = id.into();
166 Self {
167 name: id.clone(),
168 id,
169 description: None,
170 rate_multiplier: 1.0,
171 }
172 }
173
174 pub fn description(mut self, desc: impl Into<String>) -> Self {
176 self.description = Some(desc.into());
177 self
178 }
179
180 pub fn rate_multiplier(mut self, multiplier: f64) -> Self {
182 self.rate_multiplier = multiplier;
183 self
184 }
185}
186
187#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
192pub struct RateRange {
193 pub min: Decimal,
195 pub max: Decimal,
197 pub currency: Option<String>,
199}
200
201impl RateRange {
202 pub fn new(min: impl Into<Decimal>, max: impl Into<Decimal>) -> Self {
204 Self {
205 min: min.into(),
206 max: max.into(),
207 currency: None,
208 }
209 }
210
211 pub fn currency(mut self, currency: impl Into<String>) -> Self {
213 self.currency = Some(currency.into());
214 self
215 }
216
217 pub fn expected(&self) -> Decimal {
219 (self.min + self.max) / Decimal::from(2)
220 }
221
222 pub fn spread_percent(&self) -> f64 {
224 let expected = self.expected();
225 if expected.is_zero() {
226 return 0.0;
227 }
228 let spread = self.max - self.min;
229 use rust_decimal::prelude::ToPrimitive;
231 (spread / expected).to_f64().unwrap_or(0.0) * 100.0
232 }
233
234 pub fn is_collapsed(&self) -> bool {
236 self.min == self.max
237 }
238
239 pub fn is_inverted(&self) -> bool {
241 self.min > self.max
242 }
243
244 pub fn apply_multiplier(&self, multiplier: f64) -> Self {
246 use rust_decimal::prelude::FromPrimitive;
247 let mult = Decimal::from_f64(multiplier).unwrap_or(Decimal::ONE);
248 Self {
249 min: self.min * mult,
250 max: self.max * mult,
251 currency: self.currency.clone(),
252 }
253 }
254}
255
256#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
258pub enum ResourceRate {
259 Fixed(Money),
261 Range(RateRange),
263}
264
265impl ResourceRate {
266 pub fn expected(&self) -> Decimal {
268 match self {
269 ResourceRate::Fixed(money) => money.amount,
270 ResourceRate::Range(range) => range.expected(),
271 }
272 }
273
274 pub fn is_range(&self) -> bool {
276 matches!(self, ResourceRate::Range(_))
277 }
278
279 pub fn is_fixed(&self) -> bool {
281 matches!(self, ResourceRate::Fixed(_))
282 }
283}
284
285#[derive(Clone, Debug, Serialize, Deserialize)]
305pub struct ResourceProfile {
306 pub id: ProfileId,
308 pub name: String,
310 pub description: Option<String>,
312 pub specializes: Option<ProfileId>,
314 pub skills: Vec<String>,
316 pub traits: Vec<TraitId>,
318 pub rate: Option<ResourceRate>,
320 pub calendar: Option<CalendarId>,
322 pub efficiency: Option<f32>,
324}
325
326impl ResourceProfile {
327 pub fn new(id: impl Into<String>) -> Self {
329 let id = id.into();
330 Self {
331 name: id.clone(),
332 id,
333 description: None,
334 specializes: None,
335 skills: Vec::new(),
336 traits: Vec::new(),
337 rate: None,
338 calendar: None,
339 efficiency: None,
340 }
341 }
342
343 pub fn name(mut self, name: impl Into<String>) -> Self {
345 self.name = name.into();
346 self
347 }
348
349 pub fn description(mut self, desc: impl Into<String>) -> Self {
351 self.description = Some(desc.into());
352 self
353 }
354
355 pub fn specializes(mut self, parent: impl Into<String>) -> Self {
357 self.specializes = Some(parent.into());
358 self
359 }
360
361 pub fn skill(mut self, skill: impl Into<String>) -> Self {
363 self.skills.push(skill.into());
364 self
365 }
366
367 pub fn skills(mut self, skills: impl IntoIterator<Item = impl Into<String>>) -> Self {
369 self.skills.extend(skills.into_iter().map(|s| s.into()));
370 self
371 }
372
373 pub fn with_trait(mut self, trait_id: impl Into<String>) -> Self {
375 self.traits.push(trait_id.into());
376 self
377 }
378
379 pub fn with_traits(mut self, traits: impl IntoIterator<Item = impl Into<String>>) -> Self {
381 self.traits.extend(traits.into_iter().map(|t| t.into()));
382 self
383 }
384
385 pub fn rate_range(mut self, range: RateRange) -> Self {
387 self.rate = Some(ResourceRate::Range(range));
388 self
389 }
390
391 pub fn rate(mut self, rate: Money) -> Self {
393 self.rate = Some(ResourceRate::Fixed(rate));
394 self
395 }
396
397 pub fn calendar(mut self, calendar: impl Into<String>) -> Self {
399 self.calendar = Some(calendar.into());
400 self
401 }
402
403 pub fn efficiency(mut self, efficiency: f32) -> Self {
405 self.efficiency = Some(efficiency);
406 self
407 }
408
409 pub fn is_abstract(&self) -> bool {
411 match &self.rate {
412 None => true,
413 Some(ResourceRate::Range(_)) => true,
414 Some(ResourceRate::Fixed(_)) => false,
415 }
416 }
417}
418
419#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
424pub struct CostRange {
425 pub min: Decimal,
427 pub expected: Decimal,
429 pub max: Decimal,
431 pub currency: String,
433}
434
435impl CostRange {
436 pub fn new(min: Decimal, expected: Decimal, max: Decimal, currency: impl Into<String>) -> Self {
438 Self {
439 min,
440 expected,
441 max,
442 currency: currency.into(),
443 }
444 }
445
446 pub fn fixed(amount: Decimal, currency: impl Into<String>) -> Self {
448 Self {
449 min: amount,
450 expected: amount,
451 max: amount,
452 currency: currency.into(),
453 }
454 }
455
456 pub fn spread_percent(&self) -> f64 {
458 if self.expected.is_zero() {
459 return 0.0;
460 }
461 let half_spread = (self.max - self.min) / Decimal::from(2);
462 use rust_decimal::prelude::ToPrimitive;
463 (half_spread / self.expected).to_f64().unwrap_or(0.0) * 100.0
464 }
465
466 pub fn is_fixed(&self) -> bool {
468 self.min == self.max
469 }
470
471 pub fn add(&self, other: &CostRange) -> Self {
473 Self {
474 min: self.min + other.min,
475 expected: self.expected + other.expected,
476 max: self.max + other.max,
477 currency: self.currency.clone(),
478 }
479 }
480}
481
482#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
484pub enum CostPolicy {
485 #[default]
487 Midpoint,
488 Optimistic,
490 Pessimistic,
492}
493
494impl CostPolicy {
495 pub fn expected(&self, min: Decimal, max: Decimal) -> Decimal {
497 match self {
498 CostPolicy::Midpoint => (min + max) / Decimal::from(2),
499 CostPolicy::Optimistic => min,
500 CostPolicy::Pessimistic => max,
501 }
502 }
503}
504
505#[derive(Clone, Debug, Serialize, Deserialize)]
511pub struct Project {
512 pub id: String,
514 pub name: String,
516 pub start: NaiveDate,
518 pub end: Option<NaiveDate>,
520 pub status_date: Option<NaiveDate>,
523 pub calendar: CalendarId,
525 pub currency: String,
527 pub tasks: Vec<Task>,
529 pub resources: Vec<Resource>,
531 pub calendars: Vec<Calendar>,
533 pub scenarios: Vec<Scenario>,
535 pub attributes: HashMap<String, String>,
537
538 pub profiles: Vec<ResourceProfile>,
541 pub traits: Vec<Trait>,
543 pub cost_policy: CostPolicy,
545}
546
547impl Project {
548 pub fn new(name: impl Into<String>) -> Self {
550 Self {
551 id: String::new(),
552 name: name.into(),
553 start: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
554 end: None,
555 status_date: None,
556 calendar: "default".into(),
557 currency: "USD".into(),
558 tasks: Vec::new(),
559 resources: Vec::new(),
560 calendars: vec![Calendar::default()],
561 scenarios: Vec::new(),
562 attributes: HashMap::new(),
563 profiles: Vec::new(),
564 traits: Vec::new(),
565 cost_policy: CostPolicy::default(),
566 }
567 }
568
569 pub fn get_task(&self, id: &str) -> Option<&Task> {
571 fn find_task<'a>(tasks: &'a [Task], id: &str) -> Option<&'a Task> {
572 for task in tasks {
573 if task.id == id {
574 return Some(task);
575 }
576 if let Some(found) = find_task(&task.children, id) {
577 return Some(found);
578 }
579 }
580 None
581 }
582 find_task(&self.tasks, id)
583 }
584
585 pub fn get_resource(&self, id: &str) -> Option<&Resource> {
587 self.resources.iter().find(|r| r.id == id)
588 }
589
590 pub fn get_profile(&self, id: &str) -> Option<&ResourceProfile> {
592 self.profiles.iter().find(|p| p.id == id)
593 }
594
595 pub fn get_trait(&self, id: &str) -> Option<&Trait> {
597 self.traits.iter().find(|t| t.id == id)
598 }
599
600 pub fn leaf_tasks(&self) -> Vec<&Task> {
602 fn collect_leaves<'a>(tasks: &'a [Task], result: &mut Vec<&'a Task>) {
603 for task in tasks {
604 if task.children.is_empty() {
605 result.push(task);
606 } else {
607 collect_leaves(&task.children, result);
608 }
609 }
610 }
611 let mut leaves = Vec::new();
612 collect_leaves(&self.tasks, &mut leaves);
613 leaves
614 }
615}
616
617#[derive(Clone, Debug, Serialize, Deserialize)]
623pub struct Task {
624 pub id: TaskId,
626 pub name: String,
628 pub summary: Option<String>,
630 pub effort: Option<Duration>,
632 pub duration: Option<Duration>,
634 pub depends: Vec<Dependency>,
636 pub assigned: Vec<ResourceRef>,
638 pub priority: u32,
640 pub constraints: Vec<TaskConstraint>,
642 pub milestone: bool,
644 pub children: Vec<Task>,
646 pub complete: Option<f32>,
648 pub actual_start: Option<NaiveDate>,
650 pub actual_finish: Option<NaiveDate>,
652 pub explicit_remaining: Option<Duration>,
655 pub status: Option<TaskStatus>,
657 pub attributes: HashMap<String, String>,
659}
660
661impl Task {
662 pub fn new(id: impl Into<String>) -> Self {
664 let id = id.into();
665 Self {
666 name: id.clone(),
667 id,
668 summary: None,
669 effort: None,
670 duration: None,
671 depends: Vec::new(),
672 assigned: Vec::new(),
673 priority: 500,
674 constraints: Vec::new(),
675 milestone: false,
676 children: Vec::new(),
677 complete: None,
678 actual_start: None,
679 actual_finish: None,
680 explicit_remaining: None,
681 status: None,
682 attributes: HashMap::new(),
683 }
684 }
685
686 pub fn name(mut self, name: impl Into<String>) -> Self {
688 self.name = name.into();
689 self
690 }
691
692 pub fn summary(mut self, summary: impl Into<String>) -> Self {
694 self.summary = Some(summary.into());
695 self
696 }
697
698 pub fn effort(mut self, effort: Duration) -> Self {
700 self.effort = Some(effort);
701 self
702 }
703
704 pub fn duration(mut self, duration: Duration) -> Self {
706 self.duration = Some(duration);
707 self
708 }
709
710 pub fn depends_on(mut self, predecessor: impl Into<String>) -> Self {
712 self.depends.push(Dependency {
713 predecessor: predecessor.into(),
714 dep_type: DependencyType::FinishToStart,
715 lag: None,
716 });
717 self
718 }
719
720 pub fn with_dependency(mut self, dep: Dependency) -> Self {
722 self.depends.push(dep);
723 self
724 }
725
726 pub fn assign(mut self, resource: impl Into<String>) -> Self {
728 self.assigned.push(ResourceRef {
729 resource_id: resource.into(),
730 units: 1.0,
731 });
732 self
733 }
734
735 pub fn assign_with_units(mut self, resource: impl Into<String>, units: f32) -> Self {
741 self.assigned.push(ResourceRef {
742 resource_id: resource.into(),
743 units,
744 });
745 self
746 }
747
748 pub fn priority(mut self, priority: u32) -> Self {
750 self.priority = priority;
751 self
752 }
753
754 pub fn milestone(mut self) -> Self {
756 self.milestone = true;
757 self.duration = Some(Duration::zero());
758 self
759 }
760
761 pub fn child(mut self, child: Task) -> Self {
763 self.children.push(child);
764 self
765 }
766
767 pub fn constraint(mut self, constraint: TaskConstraint) -> Self {
769 self.constraints.push(constraint);
770 self
771 }
772
773 pub fn is_summary(&self) -> bool {
775 !self.children.is_empty()
776 }
777
778 pub fn remaining_duration(&self) -> Duration {
785 let original = self.duration.or(self.effort).unwrap_or(Duration::zero());
786 let pct = self.effective_percent_complete() as f64;
787 let remaining_minutes = (original.minutes as f64 * (1.0 - pct / 100.0)).round() as i64;
788 Duration::minutes(remaining_minutes.max(0))
789 }
790
791 pub fn effective_percent_complete(&self) -> u8 {
794 self.complete
795 .map(|c| c.clamp(0.0, 100.0) as u8)
796 .unwrap_or(0)
797 }
798
799 pub fn derived_status(&self) -> TaskStatus {
803 if let Some(ref status) = self.status {
805 return status.clone();
806 }
807
808 let pct = self.effective_progress();
810 if pct >= 100 || self.actual_finish.is_some() {
811 TaskStatus::Complete
812 } else if pct > 0 || self.actual_start.is_some() {
813 TaskStatus::InProgress
814 } else {
815 TaskStatus::NotStarted
816 }
817 }
818
819 pub fn complete(mut self, pct: f32) -> Self {
821 self.complete = Some(pct);
822 self
823 }
824
825 pub fn is_container(&self) -> bool {
827 !self.children.is_empty()
828 }
829
830 pub fn container_progress(&self) -> Option<u8> {
834 if self.children.is_empty() {
835 return None;
836 }
837
838 let (total_weighted, total_duration) = self.calculate_weighted_progress();
839
840 if total_duration == 0 {
841 return None;
842 }
843
844 Some((total_weighted as f64 / total_duration as f64).round() as u8)
845 }
846
847 fn calculate_weighted_progress(&self) -> (i64, i64) {
850 let mut total_weighted: i64 = 0;
851 let mut total_duration: i64 = 0;
852
853 for child in &self.children {
854 if child.is_container() {
855 let (child_weighted, child_duration) = child.calculate_weighted_progress();
857 total_weighted += child_weighted;
858 total_duration += child_duration;
859 } else {
860 let duration = child.duration.or(child.effort).unwrap_or(Duration::zero());
862 let duration_mins = duration.minutes;
863 let pct = child.effective_percent_complete() as i64;
864
865 total_weighted += pct * duration_mins;
866 total_duration += duration_mins;
867 }
868 }
869
870 (total_weighted, total_duration)
871 }
872
873 pub fn effective_progress(&self) -> u8 {
877 if let Some(pct) = self.complete {
879 return pct.clamp(0.0, 100.0) as u8;
880 }
881
882 if let Some(derived) = self.container_progress() {
884 return derived;
885 }
886
887 0
889 }
890
891 pub fn progress_mismatch(&self, threshold: u8) -> Option<(u8, u8)> {
894 if !self.is_container() {
895 return None;
896 }
897
898 let manual = self.complete.map(|c| c.clamp(0.0, 100.0) as u8)?;
899 let derived = self.container_progress()?;
900
901 let diff = (manual as i16 - derived as i16).unsigned_abs() as u8;
902 if diff > threshold {
903 Some((manual, derived))
904 } else {
905 None
906 }
907 }
908
909 pub fn actual_start(mut self, date: NaiveDate) -> Self {
911 self.actual_start = Some(date);
912 self
913 }
914
915 pub fn actual_finish(mut self, date: NaiveDate) -> Self {
917 self.actual_finish = Some(date);
918 self
919 }
920
921 pub fn explicit_remaining(mut self, remaining: Duration) -> Self {
924 self.explicit_remaining = Some(remaining);
925 self
926 }
927
928 pub fn with_status(mut self, status: TaskStatus) -> Self {
930 self.status = Some(status);
931 self
932 }
933}
934
935#[derive(Clone, Debug, Serialize, Deserialize)]
937pub struct Dependency {
938 pub predecessor: TaskId,
940 pub dep_type: DependencyType,
942 pub lag: Option<Duration>,
944}
945
946#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
948pub enum DependencyType {
949 #[default]
951 FinishToStart,
952 StartToStart,
954 FinishToFinish,
956 StartToFinish,
958}
959
960#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
962pub enum TaskStatus {
963 #[default]
964 NotStarted,
965 InProgress,
966 Complete,
967 Blocked,
968 AtRisk,
969 OnHold,
970}
971
972impl std::fmt::Display for TaskStatus {
973 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
974 match self {
975 TaskStatus::NotStarted => write!(f, "Not Started"),
976 TaskStatus::InProgress => write!(f, "In Progress"),
977 TaskStatus::Complete => write!(f, "Complete"),
978 TaskStatus::Blocked => write!(f, "Blocked"),
979 TaskStatus::AtRisk => write!(f, "At Risk"),
980 TaskStatus::OnHold => write!(f, "On Hold"),
981 }
982 }
983}
984
985#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
991pub enum SchedulingMode {
992 #[default]
996 DurationBased,
997 EffortBased,
1001 ResourceLoaded,
1005}
1006
1007impl SchedulingMode {
1008 pub fn description(&self) -> &'static str {
1010 match self {
1011 SchedulingMode::DurationBased => "duration-based (no effort tracking)",
1012 SchedulingMode::EffortBased => "effort-based (no cost tracking)",
1013 SchedulingMode::ResourceLoaded => "resource-loaded (full tracking)",
1014 }
1015 }
1016
1017 pub fn capabilities(&self) -> SchedulingCapabilities {
1019 match self {
1020 SchedulingMode::DurationBased => SchedulingCapabilities {
1021 timeline: true,
1022 utilization: false,
1023 cost_tracking: false,
1024 },
1025 SchedulingMode::EffortBased => SchedulingCapabilities {
1026 timeline: true,
1027 utilization: true,
1028 cost_tracking: false,
1029 },
1030 SchedulingMode::ResourceLoaded => SchedulingCapabilities {
1031 timeline: true,
1032 utilization: true,
1033 cost_tracking: true,
1034 },
1035 }
1036 }
1037}
1038
1039impl std::fmt::Display for SchedulingMode {
1040 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1041 write!(f, "{}", self.description())
1042 }
1043}
1044
1045#[derive(Clone, Debug, Default, PartialEq, Eq)]
1047pub struct SchedulingCapabilities {
1048 pub timeline: bool,
1050 pub utilization: bool,
1052 pub cost_tracking: bool,
1054}
1055
1056#[derive(Clone, Debug, Serialize, Deserialize)]
1058pub struct ResourceRef {
1059 pub resource_id: ResourceId,
1061 pub units: f32,
1063}
1064
1065#[derive(Clone, Debug, Serialize, Deserialize)]
1067pub enum TaskConstraint {
1068 MustStartOn(NaiveDate),
1070 MustFinishOn(NaiveDate),
1072 StartNoEarlierThan(NaiveDate),
1074 StartNoLaterThan(NaiveDate),
1076 FinishNoEarlierThan(NaiveDate),
1078 FinishNoLaterThan(NaiveDate),
1080}
1081
1082#[derive(Clone, Debug, Serialize, Deserialize)]
1088pub struct Resource {
1089 pub id: ResourceId,
1091 pub name: String,
1093 pub rate: Option<Money>,
1095 pub capacity: f32,
1097 pub calendar: Option<CalendarId>,
1099 pub efficiency: f32,
1101 pub attributes: HashMap<String, String>,
1103
1104 pub specializes: Option<ProfileId>,
1107 pub availability: Option<f32>,
1110}
1111
1112impl Resource {
1113 pub fn new(id: impl Into<String>) -> Self {
1115 let id = id.into();
1116 Self {
1117 name: id.clone(),
1118 id,
1119 rate: None,
1120 capacity: 1.0,
1121 calendar: None,
1122 efficiency: 1.0,
1123 attributes: HashMap::new(),
1124 specializes: None,
1125 availability: None,
1126 }
1127 }
1128
1129 pub fn name(mut self, name: impl Into<String>) -> Self {
1131 self.name = name.into();
1132 self
1133 }
1134
1135 pub fn capacity(mut self, capacity: f32) -> Self {
1137 self.capacity = capacity;
1138 self
1139 }
1140
1141 pub fn rate(mut self, rate: Money) -> Self {
1143 self.rate = Some(rate);
1144 self
1145 }
1146
1147 pub fn efficiency(mut self, efficiency: f32) -> Self {
1149 self.efficiency = efficiency;
1150 self
1151 }
1152
1153 pub fn specializes(mut self, profile: impl Into<String>) -> Self {
1155 self.specializes = Some(profile.into());
1156 self
1157 }
1158
1159 pub fn availability(mut self, availability: f32) -> Self {
1164 self.availability = Some(availability);
1165 self
1166 }
1167
1168 pub fn effective_availability(&self) -> f32 {
1170 self.availability.unwrap_or(1.0)
1171 }
1172
1173 pub fn is_specialized(&self) -> bool {
1175 self.specializes.is_some()
1176 }
1177}
1178
1179#[derive(Clone, Debug, Serialize, Deserialize)]
1185pub struct Calendar {
1186 pub id: CalendarId,
1188 pub name: String,
1190 pub working_hours: Vec<TimeRange>,
1192 pub working_days: Vec<u8>,
1194 pub holidays: Vec<Holiday>,
1196 pub exceptions: Vec<CalendarException>,
1198}
1199
1200impl Default for Calendar {
1201 fn default() -> Self {
1202 Self {
1203 id: "default".into(),
1204 name: "Standard".into(),
1205 working_hours: vec![
1206 TimeRange {
1207 start: 9 * 60,
1208 end: 12 * 60,
1209 },
1210 TimeRange {
1211 start: 13 * 60,
1212 end: 17 * 60,
1213 },
1214 ],
1215 working_days: vec![1, 2, 3, 4, 5], holidays: Vec::new(),
1217 exceptions: Vec::new(),
1218 }
1219 }
1220}
1221
1222impl Calendar {
1223 pub fn hours_per_day(&self) -> f64 {
1225 self.working_hours.iter().map(|r| r.duration_hours()).sum()
1226 }
1227
1228 pub fn is_working_day(&self, date: NaiveDate) -> bool {
1230 let weekday = date.weekday().num_days_from_sunday() as u8;
1231 if !self.working_days.contains(&weekday) {
1232 return false;
1233 }
1234 if self.holidays.iter().any(|h| h.contains(date)) {
1235 return false;
1236 }
1237 true
1238 }
1239}
1240
1241#[derive(Clone, Debug, Serialize, Deserialize)]
1243pub struct TimeRange {
1244 pub start: u16, pub end: u16,
1246}
1247
1248impl TimeRange {
1249 pub fn duration_hours(&self) -> f64 {
1250 (self.end - self.start) as f64 / 60.0
1251 }
1252}
1253
1254#[derive(Clone, Debug, Serialize, Deserialize)]
1256pub struct Holiday {
1257 pub name: String,
1258 pub start: NaiveDate,
1259 pub end: NaiveDate,
1260}
1261
1262impl Holiday {
1263 pub fn contains(&self, date: NaiveDate) -> bool {
1264 date >= self.start && date <= self.end
1265 }
1266}
1267
1268#[derive(Clone, Debug, Serialize, Deserialize)]
1270pub struct CalendarException {
1271 pub date: NaiveDate,
1272 pub working_hours: Option<Vec<TimeRange>>, }
1274
1275#[derive(Clone, Debug, Serialize, Deserialize)]
1281pub struct Scenario {
1282 pub id: String,
1283 pub name: String,
1284 pub parent: Option<String>,
1285 pub overrides: Vec<ScenarioOverride>,
1286}
1287
1288#[derive(Clone, Debug, Serialize, Deserialize)]
1290pub enum ScenarioOverride {
1291 TaskEffort {
1292 task_id: TaskId,
1293 effort: Duration,
1294 },
1295 TaskDuration {
1296 task_id: TaskId,
1297 duration: Duration,
1298 },
1299 ResourceCapacity {
1300 resource_id: ResourceId,
1301 capacity: f32,
1302 },
1303}
1304
1305#[derive(Clone, Debug, Serialize, Deserialize)]
1311pub struct Schedule {
1312 pub tasks: HashMap<TaskId, ScheduledTask>,
1314 pub critical_path: Vec<TaskId>,
1316 pub project_duration: Duration,
1318 pub project_end: NaiveDate,
1320 pub total_cost: Option<Money>,
1322 pub total_cost_range: Option<CostRange>,
1324
1325 pub project_progress: u8,
1328 pub project_baseline_finish: NaiveDate,
1330 pub project_forecast_finish: NaiveDate,
1332 pub project_variance_days: i64,
1334
1335 pub planned_value: u8,
1338 pub earned_value: u8,
1340 pub spi: f64,
1342}
1343
1344#[derive(Clone, Debug, Serialize, Deserialize)]
1346pub struct ScheduledTask {
1347 pub task_id: TaskId,
1349 pub start: NaiveDate,
1351 pub finish: NaiveDate,
1353 pub duration: Duration,
1355 pub assignments: Vec<Assignment>,
1357 pub slack: Duration,
1359 pub is_critical: bool,
1361 pub early_start: NaiveDate,
1363 pub early_finish: NaiveDate,
1365 pub late_start: NaiveDate,
1367 pub late_finish: NaiveDate,
1369
1370 pub forecast_start: NaiveDate,
1375 pub forecast_finish: NaiveDate,
1377 pub remaining_duration: Duration,
1379 pub percent_complete: u8,
1381 pub status: TaskStatus,
1383
1384 pub baseline_start: NaiveDate,
1389 pub baseline_finish: NaiveDate,
1391 pub start_variance_days: i64,
1393 pub finish_variance_days: i64,
1395
1396 pub cost_range: Option<CostRange>,
1401 pub has_abstract_assignments: bool,
1403}
1404
1405impl ScheduledTask {
1406 #[cfg(test)]
1409 pub fn test_new(
1410 task_id: impl Into<String>,
1411 start: NaiveDate,
1412 finish: NaiveDate,
1413 duration: Duration,
1414 slack: Duration,
1415 is_critical: bool,
1416 ) -> Self {
1417 let task_id = task_id.into();
1418 Self {
1419 task_id,
1420 start,
1421 finish,
1422 duration,
1423 assignments: Vec::new(),
1424 slack,
1425 is_critical,
1426 early_start: start,
1427 early_finish: finish,
1428 late_start: start,
1429 late_finish: finish,
1430 forecast_start: start,
1431 forecast_finish: finish,
1432 remaining_duration: duration,
1433 percent_complete: 0,
1434 status: TaskStatus::NotStarted,
1435 baseline_start: start,
1436 baseline_finish: finish,
1437 start_variance_days: 0,
1438 finish_variance_days: 0,
1439 cost_range: None,
1440 has_abstract_assignments: false,
1441 }
1442 }
1443}
1444
1445#[derive(Clone, Debug, Serialize, Deserialize)]
1447pub struct Assignment {
1448 pub resource_id: ResourceId,
1449 pub start: NaiveDate,
1450 pub finish: NaiveDate,
1451 pub units: f32,
1452 pub cost: Option<Money>,
1453 pub cost_range: Option<CostRange>,
1455 pub is_abstract: bool,
1457 pub effort_days: Option<f64>,
1461}
1462
1463pub trait Scheduler: Send + Sync {
1469 fn schedule(&self, project: &Project) -> Result<Schedule, ScheduleError>;
1471
1472 fn is_feasible(&self, project: &Project) -> FeasibilityResult;
1474
1475 fn explain(&self, project: &Project, task: &TaskId) -> Explanation;
1477}
1478
1479pub trait WhatIfAnalysis {
1481 fn what_if(&self, project: &Project, change: &Constraint) -> WhatIfReport;
1483
1484 fn count_solutions(&self, project: &Project) -> num_bigint::BigUint;
1486
1487 fn critical_constraints(&self, project: &Project) -> Vec<Constraint>;
1489}
1490
1491pub trait Renderer {
1493 type Output;
1494
1495 fn render(&self, project: &Project, schedule: &Schedule) -> Result<Self::Output, RenderError>;
1497}
1498
1499#[derive(Clone, Debug)]
1505pub struct FeasibilityResult {
1506 pub feasible: bool,
1507 pub conflicts: Vec<Conflict>,
1508 pub suggestions: Vec<Suggestion>,
1509}
1510
1511#[derive(Clone, Debug)]
1513pub struct Conflict {
1514 pub conflict_type: ConflictType,
1515 pub description: String,
1516 pub involved_tasks: Vec<TaskId>,
1517 pub involved_resources: Vec<ResourceId>,
1518}
1519
1520#[derive(Clone, Debug, PartialEq)]
1521pub enum ConflictType {
1522 CircularDependency,
1523 ResourceOverallocation,
1524 ImpossibleConstraint,
1525 DeadlineMissed,
1526}
1527
1528#[derive(Clone, Debug)]
1530pub struct Suggestion {
1531 pub description: String,
1532 pub impact: String,
1533}
1534
1535#[derive(Clone, Debug, PartialEq)]
1537pub enum ConstraintEffectType {
1538 PushedStart,
1540 CappedLate,
1542 Pinned,
1544 Redundant,
1546}
1547
1548#[derive(Clone, Debug)]
1550pub struct ConstraintEffect {
1551 pub constraint: TaskConstraint,
1553 pub effect: ConstraintEffectType,
1555 pub description: String,
1557}
1558
1559#[derive(Clone, Debug, Default)]
1561pub struct CalendarImpact {
1562 pub calendar_id: CalendarId,
1564 pub non_working_days: u32,
1566 pub weekend_days: u32,
1568 pub holiday_days: u32,
1570 pub total_delay_days: i64,
1572 pub description: String,
1574}
1575
1576#[derive(Clone, Debug)]
1578pub struct Explanation {
1579 pub task_id: TaskId,
1580 pub reason: String,
1581 pub constraints_applied: Vec<String>,
1582 pub alternatives_considered: Vec<String>,
1583 pub constraint_effects: Vec<ConstraintEffect>,
1585 pub calendar_impact: Option<CalendarImpact>,
1587 pub related_diagnostics: Vec<DiagnosticCode>,
1589}
1590
1591#[derive(Clone, Debug)]
1593pub enum Constraint {
1594 TaskEffort {
1595 task_id: TaskId,
1596 effort: Duration,
1597 },
1598 TaskDuration {
1599 task_id: TaskId,
1600 duration: Duration,
1601 },
1602 ResourceCapacity {
1603 resource_id: ResourceId,
1604 capacity: f32,
1605 },
1606 Deadline {
1607 date: NaiveDate,
1608 },
1609}
1610
1611#[derive(Clone, Debug)]
1613pub struct WhatIfReport {
1614 pub still_feasible: bool,
1615 pub solutions_before: num_bigint::BigUint,
1616 pub solutions_after: num_bigint::BigUint,
1617 pub newly_critical: Vec<TaskId>,
1618 pub schedule_delta: Option<Duration>,
1619 pub cost_delta: Option<Money>,
1620}
1621
1622#[derive(Debug, Error)]
1628pub enum ScheduleError {
1629 #[error("Circular dependency detected: {0}")]
1630 CircularDependency(String),
1631
1632 #[error("Resource not found: {0}")]
1633 ResourceNotFound(ResourceId),
1634
1635 #[error("Task not found: {0}")]
1636 TaskNotFound(TaskId),
1637
1638 #[error("Calendar not found: {0}")]
1639 CalendarNotFound(CalendarId),
1640
1641 #[error("Infeasible schedule: {0}")]
1642 Infeasible(String),
1643
1644 #[error("Constraint violation: {0}")]
1645 ConstraintViolation(String),
1646
1647 #[error("Internal error: {0}")]
1648 Internal(String),
1649}
1650
1651#[derive(Debug, Error)]
1653pub enum RenderError {
1654 #[error("IO error: {0}")]
1655 Io(#[from] std::io::Error),
1656
1657 #[error("Format error: {0}")]
1658 Format(String),
1659
1660 #[error("Invalid data: {0}")]
1661 InvalidData(String),
1662}
1663
1664#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1676pub enum Severity {
1677 Error,
1678 Warning,
1679 Hint,
1680 Info,
1681}
1682
1683impl Severity {
1684 pub fn as_str(&self) -> &'static str {
1686 match self {
1687 Severity::Error => "error",
1688 Severity::Warning => "warning",
1689 Severity::Hint => "hint",
1690 Severity::Info => "info",
1691 }
1692 }
1693}
1694
1695impl std::fmt::Display for Severity {
1696 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1697 write!(f, "{}", self.as_str())
1698 }
1699}
1700
1701#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1710pub enum DiagnosticCode {
1711 E001CircularSpecialization,
1714 E002ProfileWithoutRate,
1716 E003InfeasibleConstraint,
1718 R102InvertedRateRange,
1720 R104UnknownProfile,
1722
1723 C001ZeroWorkingHours,
1726 C002NoWorkingDays,
1728
1729 W001AbstractAssignment,
1732 W002WideCostRange,
1734 W003UnknownTrait,
1736 R012TraitMultiplierStack,
1738 W004ApproximateLeveling,
1740 W005ConstraintZeroSlack,
1742 W006ScheduleVariance,
1744 W007UnresolvedDependency,
1746 W014ContainerDependency,
1748
1749 C010NonWorkingDay,
1752 C011CalendarMismatch,
1754
1755 H001MixedAbstraction,
1758 H002UnusedProfile,
1760 H003UnusedTrait,
1762 H004TaskUnconstrained,
1764
1765 C020LowAvailability,
1768 C021MissingCommonHoliday,
1770 C022SuspiciousHours,
1772 C023RedundantHoliday,
1774
1775 I001ProjectCostSummary,
1778 I002RefinementProgress,
1780 I003ResourceUtilization,
1782 I004ProjectStatus,
1784 I005EarnedValueSummary,
1786
1787 L001OverallocationResolved,
1790 L002UnresolvableConflict,
1792 L003DurationIncreased,
1794 L004MilestoneDelayed,
1796
1797 P005RemainingCompleteConflict,
1800 P006ContainerProgressMismatch,
1802}
1803
1804impl DiagnosticCode {
1805 pub fn as_str(&self) -> &'static str {
1807 match self {
1808 DiagnosticCode::E001CircularSpecialization => "E001",
1809 DiagnosticCode::E002ProfileWithoutRate => "E002",
1810 DiagnosticCode::E003InfeasibleConstraint => "E003",
1811 DiagnosticCode::R102InvertedRateRange => "R102",
1812 DiagnosticCode::R104UnknownProfile => "R104",
1813 DiagnosticCode::C001ZeroWorkingHours => "C001",
1814 DiagnosticCode::C002NoWorkingDays => "C002",
1815 DiagnosticCode::W001AbstractAssignment => "W001",
1816 DiagnosticCode::W002WideCostRange => "W002",
1817 DiagnosticCode::W003UnknownTrait => "W003",
1818 DiagnosticCode::R012TraitMultiplierStack => "R012",
1819 DiagnosticCode::W004ApproximateLeveling => "W004",
1820 DiagnosticCode::W005ConstraintZeroSlack => "W005",
1821 DiagnosticCode::W006ScheduleVariance => "W006",
1822 DiagnosticCode::W007UnresolvedDependency => "W007",
1823 DiagnosticCode::W014ContainerDependency => "W014",
1824 DiagnosticCode::C010NonWorkingDay => "C010",
1825 DiagnosticCode::C011CalendarMismatch => "C011",
1826 DiagnosticCode::H001MixedAbstraction => "H001",
1827 DiagnosticCode::H002UnusedProfile => "H002",
1828 DiagnosticCode::H003UnusedTrait => "H003",
1829 DiagnosticCode::H004TaskUnconstrained => "H004",
1830 DiagnosticCode::C020LowAvailability => "C020",
1831 DiagnosticCode::C021MissingCommonHoliday => "C021",
1832 DiagnosticCode::C022SuspiciousHours => "C022",
1833 DiagnosticCode::C023RedundantHoliday => "C023",
1834 DiagnosticCode::I001ProjectCostSummary => "I001",
1835 DiagnosticCode::I002RefinementProgress => "I002",
1836 DiagnosticCode::I003ResourceUtilization => "I003",
1837 DiagnosticCode::I004ProjectStatus => "I004",
1838 DiagnosticCode::I005EarnedValueSummary => "I005",
1839 DiagnosticCode::L001OverallocationResolved => "L001",
1840 DiagnosticCode::L002UnresolvableConflict => "L002",
1841 DiagnosticCode::L003DurationIncreased => "L003",
1842 DiagnosticCode::L004MilestoneDelayed => "L004",
1843 DiagnosticCode::P005RemainingCompleteConflict => "P005",
1844 DiagnosticCode::P006ContainerProgressMismatch => "P006",
1845 }
1846 }
1847
1848 pub fn default_severity(&self) -> Severity {
1850 match self {
1851 DiagnosticCode::E001CircularSpecialization => Severity::Error,
1852 DiagnosticCode::E002ProfileWithoutRate => Severity::Warning, DiagnosticCode::E003InfeasibleConstraint => Severity::Error,
1854 DiagnosticCode::R102InvertedRateRange => Severity::Error,
1855 DiagnosticCode::R104UnknownProfile => Severity::Error,
1856 DiagnosticCode::C001ZeroWorkingHours => Severity::Error,
1857 DiagnosticCode::C002NoWorkingDays => Severity::Error,
1858 DiagnosticCode::W001AbstractAssignment => Severity::Warning,
1859 DiagnosticCode::W002WideCostRange => Severity::Warning,
1860 DiagnosticCode::W003UnknownTrait => Severity::Warning,
1861 DiagnosticCode::R012TraitMultiplierStack => Severity::Warning,
1862 DiagnosticCode::W004ApproximateLeveling => Severity::Warning,
1863 DiagnosticCode::W005ConstraintZeroSlack => Severity::Warning,
1864 DiagnosticCode::W006ScheduleVariance => Severity::Warning,
1865 DiagnosticCode::W007UnresolvedDependency => Severity::Warning,
1866 DiagnosticCode::W014ContainerDependency => Severity::Warning,
1867 DiagnosticCode::C010NonWorkingDay => Severity::Warning,
1868 DiagnosticCode::C011CalendarMismatch => Severity::Warning,
1869 DiagnosticCode::H001MixedAbstraction => Severity::Hint,
1870 DiagnosticCode::H002UnusedProfile => Severity::Hint,
1871 DiagnosticCode::H003UnusedTrait => Severity::Hint,
1872 DiagnosticCode::H004TaskUnconstrained => Severity::Hint,
1873 DiagnosticCode::C020LowAvailability => Severity::Hint,
1874 DiagnosticCode::C021MissingCommonHoliday => Severity::Hint,
1875 DiagnosticCode::C022SuspiciousHours => Severity::Hint,
1876 DiagnosticCode::C023RedundantHoliday => Severity::Hint,
1877 DiagnosticCode::I001ProjectCostSummary => Severity::Info,
1878 DiagnosticCode::I002RefinementProgress => Severity::Info,
1879 DiagnosticCode::I003ResourceUtilization => Severity::Info,
1880 DiagnosticCode::I004ProjectStatus => Severity::Info,
1881 DiagnosticCode::I005EarnedValueSummary => Severity::Info,
1882 DiagnosticCode::L001OverallocationResolved => Severity::Hint,
1884 DiagnosticCode::L002UnresolvableConflict => Severity::Warning,
1885 DiagnosticCode::L003DurationIncreased => Severity::Hint,
1886 DiagnosticCode::L004MilestoneDelayed => Severity::Warning,
1887 DiagnosticCode::P005RemainingCompleteConflict => Severity::Warning,
1889 DiagnosticCode::P006ContainerProgressMismatch => Severity::Warning,
1890 }
1891 }
1892
1893 pub fn ordering_priority(&self) -> u8 {
1897 match self {
1898 DiagnosticCode::E001CircularSpecialization => 0,
1900 DiagnosticCode::E002ProfileWithoutRate => 1,
1901 DiagnosticCode::E003InfeasibleConstraint => 2,
1902 DiagnosticCode::R102InvertedRateRange => 3,
1903 DiagnosticCode::R104UnknownProfile => 4,
1904 DiagnosticCode::C001ZeroWorkingHours => 5,
1906 DiagnosticCode::C002NoWorkingDays => 6,
1907 DiagnosticCode::W002WideCostRange => 10,
1909 DiagnosticCode::R012TraitMultiplierStack => 11,
1910 DiagnosticCode::W004ApproximateLeveling => 12,
1911 DiagnosticCode::W005ConstraintZeroSlack => 12,
1913 DiagnosticCode::W006ScheduleVariance => 13,
1915 DiagnosticCode::W007UnresolvedDependency => 14,
1917 DiagnosticCode::W014ContainerDependency => 15,
1919 DiagnosticCode::C010NonWorkingDay => 15,
1921 DiagnosticCode::C011CalendarMismatch => 16,
1922 DiagnosticCode::W001AbstractAssignment => 20,
1924 DiagnosticCode::W003UnknownTrait => 21,
1925 DiagnosticCode::H001MixedAbstraction => 30,
1927 DiagnosticCode::H002UnusedProfile => 31,
1928 DiagnosticCode::H003UnusedTrait => 32,
1929 DiagnosticCode::H004TaskUnconstrained => 33,
1930 DiagnosticCode::C020LowAvailability => 34,
1932 DiagnosticCode::C021MissingCommonHoliday => 35,
1933 DiagnosticCode::C022SuspiciousHours => 36,
1934 DiagnosticCode::C023RedundantHoliday => 37,
1935 DiagnosticCode::I001ProjectCostSummary => 40,
1937 DiagnosticCode::I002RefinementProgress => 41,
1938 DiagnosticCode::I003ResourceUtilization => 42,
1939 DiagnosticCode::I004ProjectStatus => 43,
1940 DiagnosticCode::I005EarnedValueSummary => 44,
1941 DiagnosticCode::L001OverallocationResolved => 50,
1943 DiagnosticCode::L002UnresolvableConflict => 51,
1944 DiagnosticCode::L003DurationIncreased => 52,
1945 DiagnosticCode::L004MilestoneDelayed => 53,
1946 DiagnosticCode::P005RemainingCompleteConflict => 17,
1948 DiagnosticCode::P006ContainerProgressMismatch => 18,
1949 }
1950 }
1951}
1952
1953impl std::fmt::Display for DiagnosticCode {
1954 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1955 write!(f, "{}", self.as_str())
1956 }
1957}
1958
1959#[derive(Debug, Clone, PartialEq, Eq)]
1961pub struct SourceSpan {
1962 pub line: usize,
1964 pub column: usize,
1966 pub length: usize,
1968 pub label: Option<String>,
1970}
1971
1972impl SourceSpan {
1973 pub fn new(line: usize, column: usize, length: usize) -> Self {
1974 Self {
1975 line,
1976 column,
1977 length,
1978 label: None,
1979 }
1980 }
1981
1982 pub fn with_label(mut self, label: impl Into<String>) -> Self {
1983 self.label = Some(label.into());
1984 self
1985 }
1986}
1987
1988#[derive(Debug, Clone)]
2008pub struct Diagnostic {
2009 pub code: DiagnosticCode,
2011 pub severity: Severity,
2013 pub message: String,
2015 pub file: Option<std::path::PathBuf>,
2017 pub span: Option<SourceSpan>,
2019 pub secondary_spans: Vec<SourceSpan>,
2021 pub notes: Vec<String>,
2023 pub hints: Vec<String>,
2025}
2026
2027impl Diagnostic {
2028 pub fn new(code: DiagnosticCode, message: impl Into<String>) -> Self {
2032 Self {
2033 severity: code.default_severity(),
2034 code,
2035 message: message.into(),
2036 file: None,
2037 span: None,
2038 secondary_spans: Vec::new(),
2039 notes: Vec::new(),
2040 hints: Vec::new(),
2041 }
2042 }
2043
2044 pub fn error(code: DiagnosticCode, message: impl Into<String>) -> Self {
2046 Self {
2047 severity: Severity::Error,
2048 code,
2049 message: message.into(),
2050 file: None,
2051 span: None,
2052 secondary_spans: Vec::new(),
2053 notes: Vec::new(),
2054 hints: Vec::new(),
2055 }
2056 }
2057
2058 pub fn warning(code: DiagnosticCode, message: impl Into<String>) -> Self {
2060 Self {
2061 severity: Severity::Warning,
2062 code,
2063 message: message.into(),
2064 file: None,
2065 span: None,
2066 secondary_spans: Vec::new(),
2067 notes: Vec::new(),
2068 hints: Vec::new(),
2069 }
2070 }
2071
2072 pub fn with_file(mut self, file: impl Into<std::path::PathBuf>) -> Self {
2074 self.file = Some(file.into());
2075 self
2076 }
2077
2078 pub fn with_span(mut self, span: SourceSpan) -> Self {
2080 self.span = Some(span);
2081 self
2082 }
2083
2084 pub fn with_secondary_span(mut self, span: SourceSpan) -> Self {
2086 self.secondary_spans.push(span);
2087 self
2088 }
2089
2090 pub fn with_note(mut self, note: impl Into<String>) -> Self {
2092 self.notes.push(note.into());
2093 self
2094 }
2095
2096 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
2098 self.hints.push(hint.into());
2099 self
2100 }
2101
2102 pub fn is_error(&self) -> bool {
2104 self.severity == Severity::Error
2105 }
2106
2107 pub fn is_warning(&self) -> bool {
2109 self.severity == Severity::Warning
2110 }
2111}
2112
2113pub trait DiagnosticEmitter {
2135 fn emit(&mut self, diagnostic: Diagnostic);
2137}
2138
2139#[derive(Debug, Default)]
2143pub struct CollectingEmitter {
2144 pub diagnostics: Vec<Diagnostic>,
2145}
2146
2147impl CollectingEmitter {
2148 pub fn new() -> Self {
2149 Self::default()
2150 }
2151
2152 pub fn has_errors(&self) -> bool {
2154 self.diagnostics.iter().any(|d| d.is_error())
2155 }
2156
2157 pub fn error_count(&self) -> usize {
2159 self.diagnostics.iter().filter(|d| d.is_error()).count()
2160 }
2161
2162 pub fn warning_count(&self) -> usize {
2164 self.diagnostics.iter().filter(|d| d.is_warning()).count()
2165 }
2166
2167 pub fn sorted(&self) -> Vec<&Diagnostic> {
2169 let mut sorted: Vec<_> = self.diagnostics.iter().collect();
2170 sorted.sort_by(|a, b| {
2171 let priority_cmp = a.code.ordering_priority().cmp(&b.code.ordering_priority());
2173 if priority_cmp != std::cmp::Ordering::Equal {
2174 return priority_cmp;
2175 }
2176 let file_cmp = a.file.cmp(&b.file);
2178 if file_cmp != std::cmp::Ordering::Equal {
2179 return file_cmp;
2180 }
2181 let line_a = a.span.as_ref().map(|s| s.line).unwrap_or(0);
2183 let line_b = b.span.as_ref().map(|s| s.line).unwrap_or(0);
2184 let line_cmp = line_a.cmp(&line_b);
2185 if line_cmp != std::cmp::Ordering::Equal {
2186 return line_cmp;
2187 }
2188 let col_a = a.span.as_ref().map(|s| s.column).unwrap_or(0);
2190 let col_b = b.span.as_ref().map(|s| s.column).unwrap_or(0);
2191 col_a.cmp(&col_b)
2192 });
2193 sorted
2194 }
2195}
2196
2197impl DiagnosticEmitter for CollectingEmitter {
2198 fn emit(&mut self, diagnostic: Diagnostic) {
2199 self.diagnostics.push(diagnostic);
2200 }
2201}
2202
2203#[cfg(test)]
2208mod tests {
2209 use super::*;
2210
2211 #[test]
2212 fn duration_arithmetic() {
2213 let d1 = Duration::days(5);
2214 let d2 = Duration::days(3);
2215 assert_eq!((d1 + d2).as_days(), 8.0);
2216 assert_eq!((d1 - d2).as_days(), 2.0);
2217 }
2218
2219 #[test]
2220 fn task_builder() {
2221 let task = Task::new("impl")
2222 .name("Implementation")
2223 .effort(Duration::days(10))
2224 .depends_on("design")
2225 .assign("dev")
2226 .priority(700);
2227
2228 assert_eq!(task.id, "impl");
2229 assert_eq!(task.name, "Implementation");
2230 assert_eq!(task.effort, Some(Duration::days(10)));
2231 assert_eq!(task.depends.len(), 1);
2232 assert_eq!(task.assigned.len(), 1);
2233 assert_eq!(task.priority, 700);
2234 }
2235
2236 #[test]
2237 fn calendar_working_day() {
2238 let cal = Calendar::default();
2239
2240 let monday = NaiveDate::from_ymd_opt(2025, 2, 3).unwrap();
2242 assert!(cal.is_working_day(monday));
2243
2244 let saturday = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
2246 assert!(!cal.is_working_day(saturday));
2247 }
2248
2249 #[test]
2250 fn project_leaf_tasks() {
2251 let project = Project {
2252 id: "test".into(),
2253 name: "Test".into(),
2254 start: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
2255 end: None,
2256 status_date: None,
2257 calendar: "default".into(),
2258 currency: "USD".into(),
2259 tasks: vec![
2260 Task::new("parent")
2261 .child(Task::new("child1"))
2262 .child(Task::new("child2")),
2263 Task::new("standalone"),
2264 ],
2265 resources: Vec::new(),
2266 calendars: vec![Calendar::default()],
2267 scenarios: Vec::new(),
2268 attributes: HashMap::new(),
2269 profiles: Vec::new(),
2270 traits: Vec::new(),
2271 cost_policy: CostPolicy::default(),
2272 };
2273
2274 let leaves = project.leaf_tasks();
2275 assert_eq!(leaves.len(), 3);
2276 assert!(leaves.iter().any(|t| t.id == "child1"));
2277 assert!(leaves.iter().any(|t| t.id == "child2"));
2278 assert!(leaves.iter().any(|t| t.id == "standalone"));
2279 }
2280
2281 #[test]
2282 fn duration_constructors() {
2283 let d_min = Duration::minutes(120);
2285 assert_eq!(d_min.minutes, 120);
2286 assert_eq!(d_min.as_hours(), 2.0);
2287
2288 let d_hours = Duration::hours(3);
2290 assert_eq!(d_hours.minutes, 180);
2291 assert_eq!(d_hours.as_hours(), 3.0);
2292
2293 let d_weeks = Duration::weeks(1);
2295 assert_eq!(d_weeks.minutes, 5 * 8 * 60);
2296 assert_eq!(d_weeks.as_days(), 5.0);
2297 }
2298
2299 #[test]
2300 fn project_get_task_nested() {
2301 let project = Project {
2302 id: "test".into(),
2303 name: "Test".into(),
2304 start: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
2305 end: None,
2306 status_date: None,
2307 calendar: "default".into(),
2308 currency: "USD".into(),
2309 tasks: vec![
2310 Task::new("parent")
2311 .name("Parent Task")
2312 .child(Task::new("child1").name("Child 1"))
2313 .child(
2314 Task::new("child2")
2315 .name("Child 2")
2316 .child(Task::new("grandchild").name("Grandchild")),
2317 ),
2318 Task::new("standalone").name("Standalone"),
2319 ],
2320 resources: Vec::new(),
2321 calendars: vec![Calendar::default()],
2322 scenarios: Vec::new(),
2323 attributes: HashMap::new(),
2324 profiles: Vec::new(),
2325 traits: Vec::new(),
2326 cost_policy: CostPolicy::default(),
2327 };
2328
2329 let standalone = project.get_task("standalone");
2331 assert!(standalone.is_some());
2332 assert_eq!(standalone.unwrap().name, "Standalone");
2333
2334 let child1 = project.get_task("child1");
2336 assert!(child1.is_some());
2337 assert_eq!(child1.unwrap().name, "Child 1");
2338
2339 let grandchild = project.get_task("grandchild");
2341 assert!(grandchild.is_some());
2342 assert_eq!(grandchild.unwrap().name, "Grandchild");
2343
2344 let missing = project.get_task("nonexistent");
2346 assert!(missing.is_none());
2347 }
2348
2349 #[test]
2350 fn project_get_resource() {
2351 let project = Project {
2352 id: "test".into(),
2353 name: "Test".into(),
2354 start: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
2355 end: None,
2356 status_date: None,
2357 calendar: "default".into(),
2358 currency: "USD".into(),
2359 tasks: Vec::new(),
2360 resources: vec![
2361 Resource::new("dev1").name("Developer 1"),
2362 Resource::new("pm").name("Project Manager"),
2363 ],
2364 calendars: vec![Calendar::default()],
2365 scenarios: Vec::new(),
2366 attributes: HashMap::new(),
2367 profiles: Vec::new(),
2368 traits: Vec::new(),
2369 cost_policy: CostPolicy::default(),
2370 };
2371
2372 let dev = project.get_resource("dev1");
2373 assert!(dev.is_some());
2374 assert_eq!(dev.unwrap().name, "Developer 1");
2375
2376 let pm = project.get_resource("pm");
2377 assert!(pm.is_some());
2378 assert_eq!(pm.unwrap().name, "Project Manager");
2379
2380 let missing = project.get_resource("nonexistent");
2381 assert!(missing.is_none());
2382 }
2383
2384 #[test]
2385 fn task_is_summary() {
2386 let leaf_task = Task::new("leaf").name("Leaf Task");
2387 assert!(!leaf_task.is_summary());
2388
2389 let summary_task = Task::new("summary")
2390 .name("Summary Task")
2391 .child(Task::new("child1"))
2392 .child(Task::new("child2"));
2393 assert!(summary_task.is_summary());
2394 }
2395
2396 #[test]
2397 fn task_assign_with_units() {
2398 let task = Task::new("task1")
2399 .assign("dev1")
2400 .assign_with_units("dev2", 0.5)
2401 .assign_with_units("contractor", 0.25);
2402
2403 assert_eq!(task.assigned.len(), 3);
2404 assert_eq!(task.assigned[0].units, 1.0); assert_eq!(task.assigned[1].units, 0.5); assert_eq!(task.assigned[2].units, 0.25); }
2408
2409 #[test]
2410 fn resource_efficiency() {
2411 let resource = Resource::new("dev").name("Developer").efficiency(0.8);
2412
2413 assert_eq!(resource.efficiency, 0.8);
2414 }
2415
2416 #[test]
2417 fn calendar_hours_per_day() {
2418 let cal = Calendar::default();
2419 assert_eq!(cal.hours_per_day(), 7.0);
2421 }
2422
2423 #[test]
2424 fn time_range_duration() {
2425 let range = TimeRange {
2426 start: 9 * 60, end: 17 * 60, };
2429 assert_eq!(range.duration_hours(), 8.0);
2430
2431 let half_day = TimeRange {
2432 start: 9 * 60,
2433 end: 13 * 60,
2434 };
2435 assert_eq!(half_day.duration_hours(), 4.0);
2436 }
2437
2438 #[test]
2439 fn holiday_contains_date() {
2440 let holiday = Holiday {
2441 name: "Winter Break".into(),
2442 start: NaiveDate::from_ymd_opt(2025, 12, 24).unwrap(),
2443 end: NaiveDate::from_ymd_opt(2025, 12, 26).unwrap(),
2444 };
2445
2446 assert!(!holiday.contains(NaiveDate::from_ymd_opt(2025, 12, 23).unwrap()));
2448
2449 assert!(holiday.contains(NaiveDate::from_ymd_opt(2025, 12, 24).unwrap()));
2451
2452 assert!(holiday.contains(NaiveDate::from_ymd_opt(2025, 12, 25).unwrap()));
2454
2455 assert!(holiday.contains(NaiveDate::from_ymd_opt(2025, 12, 26).unwrap()));
2457
2458 assert!(!holiday.contains(NaiveDate::from_ymd_opt(2025, 12, 27).unwrap()));
2460 }
2461
2462 #[test]
2463 fn task_milestone() {
2464 let milestone = Task::new("ms1").name("Phase Complete").milestone();
2465
2466 assert!(milestone.milestone);
2467 assert_eq!(milestone.duration, Some(Duration::zero()));
2468 }
2469
2470 #[test]
2471 fn depends_on_creates_fs_dependency() {
2472 let task = Task::new("task").depends_on("pred");
2473
2474 assert_eq!(task.depends.len(), 1);
2475 let dep = &task.depends[0];
2476 assert_eq!(dep.predecessor, "pred");
2477 assert_eq!(dep.dep_type, DependencyType::FinishToStart);
2478 assert!(dep.lag.is_none());
2479 }
2480
2481 #[test]
2482 fn with_dependency_preserves_all_fields() {
2483 let dep = Dependency {
2484 predecessor: "other".into(),
2485 dep_type: DependencyType::StartToStart,
2486 lag: Some(Duration::days(2)),
2487 };
2488 let task = Task::new("task").with_dependency(dep);
2489
2490 assert_eq!(task.depends.len(), 1);
2491 let d = &task.depends[0];
2492 assert_eq!(d.predecessor, "other");
2493 assert_eq!(d.dep_type, DependencyType::StartToStart);
2494 assert_eq!(d.lag, Some(Duration::days(2)));
2495 }
2496
2497 #[test]
2498 fn assign_sets_full_allocation() {
2499 let task = Task::new("task").assign("dev");
2500
2501 assert_eq!(task.assigned.len(), 1);
2502 assert_eq!(task.assigned[0].resource_id, "dev");
2503 assert_eq!(task.assigned[0].units, 1.0);
2504 }
2505
2506 #[test]
2507 fn assign_with_units_sets_custom_allocation() {
2508 let task = Task::new("task").assign_with_units("dev", 0.75);
2509
2510 assert_eq!(task.assigned.len(), 1);
2511 assert_eq!(task.assigned[0].resource_id, "dev");
2512 assert_eq!(task.assigned[0].units, 0.75);
2513 }
2514
2515 #[test]
2520 fn remaining_duration_linear_interpolation() {
2521 let task = Task::new("task")
2523 .duration(Duration::days(10))
2524 .complete(60.0);
2525
2526 let remaining = task.remaining_duration();
2527 assert_eq!(remaining.as_days(), 4.0);
2528 }
2529
2530 #[test]
2531 fn remaining_duration_zero_complete() {
2532 let task = Task::new("task").duration(Duration::days(10));
2533
2534 let remaining = task.remaining_duration();
2535 assert_eq!(remaining.as_days(), 10.0);
2536 }
2537
2538 #[test]
2539 fn remaining_duration_fully_complete() {
2540 let task = Task::new("task")
2541 .duration(Duration::days(10))
2542 .complete(100.0);
2543
2544 let remaining = task.remaining_duration();
2545 assert_eq!(remaining.as_days(), 0.0);
2546 }
2547
2548 #[test]
2549 fn remaining_duration_uses_effort_if_no_duration() {
2550 let task = Task::new("task").effort(Duration::days(20)).complete(50.0);
2551
2552 let remaining = task.remaining_duration();
2553 assert_eq!(remaining.as_days(), 10.0);
2554 }
2555
2556 #[test]
2557 fn effective_percent_complete_default() {
2558 let task = Task::new("task");
2559 assert_eq!(task.effective_percent_complete(), 0);
2560 }
2561
2562 #[test]
2563 fn effective_percent_complete_clamped() {
2564 let task = Task::new("task").complete(150.0);
2566 assert_eq!(task.effective_percent_complete(), 100);
2567
2568 let task = Task::new("task").complete(-10.0);
2570 assert_eq!(task.effective_percent_complete(), 0);
2571 }
2572
2573 #[test]
2574 fn derived_status_not_started() {
2575 let task = Task::new("task");
2576 assert_eq!(task.derived_status(), TaskStatus::NotStarted);
2577 }
2578
2579 #[test]
2580 fn derived_status_in_progress_from_percent() {
2581 let task = Task::new("task").complete(50.0);
2582 assert_eq!(task.derived_status(), TaskStatus::InProgress);
2583 }
2584
2585 #[test]
2586 fn derived_status_in_progress_from_actual_start() {
2587 let task = Task::new("task").actual_start(NaiveDate::from_ymd_opt(2026, 1, 15).unwrap());
2588 assert_eq!(task.derived_status(), TaskStatus::InProgress);
2589 }
2590
2591 #[test]
2592 fn derived_status_complete_from_percent() {
2593 let task = Task::new("task").complete(100.0);
2594 assert_eq!(task.derived_status(), TaskStatus::Complete);
2595 }
2596
2597 #[test]
2598 fn derived_status_complete_from_actual_finish() {
2599 let task = Task::new("task").actual_finish(NaiveDate::from_ymd_opt(2026, 1, 20).unwrap());
2600 assert_eq!(task.derived_status(), TaskStatus::Complete);
2601 }
2602
2603 #[test]
2604 fn derived_status_explicit_overrides() {
2605 let task = Task::new("task")
2607 .complete(100.0)
2608 .with_status(TaskStatus::Blocked);
2609 assert_eq!(task.derived_status(), TaskStatus::Blocked);
2610 }
2611
2612 #[test]
2613 fn task_status_display() {
2614 assert_eq!(format!("{}", TaskStatus::NotStarted), "Not Started");
2615 assert_eq!(format!("{}", TaskStatus::InProgress), "In Progress");
2616 assert_eq!(format!("{}", TaskStatus::Complete), "Complete");
2617 assert_eq!(format!("{}", TaskStatus::Blocked), "Blocked");
2618 assert_eq!(format!("{}", TaskStatus::AtRisk), "At Risk");
2619 assert_eq!(format!("{}", TaskStatus::OnHold), "On Hold");
2620 }
2621
2622 #[test]
2623 fn task_builder_with_progress_fields() {
2624 let date_start = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
2625 let date_finish = NaiveDate::from_ymd_opt(2026, 1, 20).unwrap();
2626
2627 let task = Task::new("task")
2628 .duration(Duration::days(5))
2629 .complete(75.0)
2630 .actual_start(date_start)
2631 .actual_finish(date_finish)
2632 .with_status(TaskStatus::Complete);
2633
2634 assert_eq!(task.complete, Some(75.0));
2635 assert_eq!(task.actual_start, Some(date_start));
2636 assert_eq!(task.actual_finish, Some(date_finish));
2637 assert_eq!(task.status, Some(TaskStatus::Complete));
2638 }
2639
2640 #[test]
2645 fn container_progress_weighted_average() {
2646 let container = Task::new("development")
2650 .child(
2651 Task::new("backend")
2652 .duration(Duration::days(20))
2653 .complete(60.0),
2654 )
2655 .child(
2656 Task::new("frontend")
2657 .duration(Duration::days(15))
2658 .complete(30.0),
2659 )
2660 .child(Task::new("testing").duration(Duration::days(10)));
2661
2662 assert!(container.is_container());
2663 assert_eq!(container.container_progress(), Some(37));
2664 }
2665
2666 #[test]
2667 fn container_progress_empty_container() {
2668 let container = Task::new("empty");
2669 assert!(!container.is_container());
2670 assert_eq!(container.container_progress(), None);
2671 }
2672
2673 #[test]
2674 fn container_progress_all_complete() {
2675 let container = Task::new("done")
2676 .child(Task::new("a").duration(Duration::days(5)).complete(100.0))
2677 .child(Task::new("b").duration(Duration::days(5)).complete(100.0));
2678
2679 assert_eq!(container.container_progress(), Some(100));
2680 }
2681
2682 #[test]
2683 fn container_progress_none_started() {
2684 let container = Task::new("pending")
2685 .child(Task::new("a").duration(Duration::days(5)))
2686 .child(Task::new("b").duration(Duration::days(5)));
2687
2688 assert_eq!(container.container_progress(), Some(0));
2689 }
2690
2691 #[test]
2692 fn container_progress_nested_containers() {
2693 let project = Task::new("project")
2704 .child(
2705 Task::new("phase1")
2706 .child(
2707 Task::new("task_a")
2708 .duration(Duration::days(10))
2709 .complete(100.0),
2710 )
2711 .child(
2712 Task::new("task_b")
2713 .duration(Duration::days(10))
2714 .complete(50.0),
2715 ),
2716 )
2717 .child(
2718 Task::new("phase2").child(
2719 Task::new("task_c")
2720 .duration(Duration::days(20))
2721 .complete(25.0),
2722 ),
2723 );
2724
2725 let phase1 = &project.children[0];
2727 assert_eq!(phase1.container_progress(), Some(75));
2728
2729 assert_eq!(project.container_progress(), Some(50));
2731 }
2732
2733 #[test]
2734 fn container_progress_effective_with_override() {
2735 let container = Task::new("dev")
2737 .complete(80.0) .child(Task::new("a").duration(Duration::days(10)).complete(50.0))
2739 .child(Task::new("b").duration(Duration::days(10)).complete(50.0));
2740
2741 assert_eq!(container.container_progress(), Some(50));
2743 assert_eq!(container.effective_progress(), 80); }
2745
2746 #[test]
2747 fn container_progress_mismatch_detection() {
2748 let container = Task::new("dev")
2749 .complete(80.0) .child(Task::new("a").duration(Duration::days(10)).complete(30.0))
2751 .child(Task::new("b").duration(Duration::days(10)).complete(30.0));
2752
2753 let mismatch = container.progress_mismatch(20);
2755 assert!(mismatch.is_some());
2756 let (manual, derived) = mismatch.unwrap();
2757 assert_eq!(manual, 80);
2758 assert_eq!(derived, 30);
2759
2760 assert!(container.progress_mismatch(60).is_none());
2762 }
2763
2764 #[test]
2765 fn container_progress_uses_effort_fallback() {
2766 let container = Task::new("dev")
2768 .child(Task::new("a").effort(Duration::days(5)).complete(100.0))
2769 .child(Task::new("b").effort(Duration::days(5)).complete(0.0));
2770
2771 assert_eq!(container.container_progress(), Some(50));
2772 }
2773
2774 #[test]
2775 fn container_progress_zero_duration_children() {
2776 let container = Task::new("dev")
2778 .child(Task::new("a").complete(50.0)) .child(Task::new("b").complete(100.0)); assert_eq!(container.container_progress(), None);
2782 }
2783
2784 #[test]
2785 fn effective_progress_container_no_override() {
2786 let container = Task::new("dev")
2788 .child(Task::new("a").duration(Duration::days(10)).complete(100.0))
2789 .child(Task::new("b").duration(Duration::days(10)).complete(0.0));
2790
2791 assert_eq!(container.effective_progress(), 50);
2793 }
2794
2795 #[test]
2796 fn effective_progress_leaf_no_complete() {
2797 let task = Task::new("leaf").duration(Duration::days(5));
2799 assert_eq!(task.effective_progress(), 0);
2800 }
2801
2802 #[test]
2803 fn progress_mismatch_leaf_returns_none() {
2804 let task = Task::new("leaf").duration(Duration::days(5)).complete(50.0);
2806 assert!(task.progress_mismatch(10).is_none());
2807 }
2808
2809 #[test]
2810 fn money_new() {
2811 use rust_decimal::Decimal;
2812 use std::str::FromStr;
2813 let money = Money::new(Decimal::from_str("100.50").unwrap(), "EUR");
2814 assert_eq!(money.amount, Decimal::from_str("100.50").unwrap());
2815 assert_eq!(money.currency, "EUR");
2816 }
2817
2818 #[test]
2819 fn resource_rate() {
2820 use rust_decimal::Decimal;
2821 use std::str::FromStr;
2822 let resource = Resource::new("dev")
2823 .name("Developer")
2824 .rate(Money::new(Decimal::from_str("500").unwrap(), "USD"));
2825
2826 assert!(resource.rate.is_some());
2827 assert_eq!(
2828 resource.rate.unwrap().amount,
2829 Decimal::from_str("500").unwrap()
2830 );
2831 }
2832
2833 #[test]
2834 fn calendar_with_holiday() {
2835 let mut cal = Calendar::default();
2836 cal.holidays.push(Holiday {
2837 name: "New Year".into(),
2838 start: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
2839 end: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
2840 });
2841
2842 let jan1 = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
2844 assert!(!cal.is_working_day(jan1));
2845
2846 let jan2 = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
2848 assert!(cal.is_working_day(jan2));
2849 }
2850
2851 #[test]
2852 fn scheduled_task_test_new() {
2853 let start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
2854 let finish = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
2855 let st = ScheduledTask::test_new(
2856 "task1",
2857 start,
2858 finish,
2859 Duration::days(5),
2860 Duration::zero(),
2861 true,
2862 );
2863
2864 assert_eq!(st.task_id, "task1");
2865 assert_eq!(st.start, start);
2866 assert_eq!(st.finish, finish);
2867 assert!(st.is_critical);
2868 assert_eq!(st.assignments.len(), 0);
2869 assert_eq!(st.percent_complete, 0);
2870 assert_eq!(st.status, TaskStatus::NotStarted);
2871 }
2872
2873 #[test]
2878 fn trait_builder() {
2879 let senior = Trait::new("senior")
2880 .description("5+ years experience")
2881 .rate_multiplier(1.3);
2882
2883 assert_eq!(senior.id, "senior");
2884 assert_eq!(senior.name, "senior");
2885 assert_eq!(senior.description, Some("5+ years experience".into()));
2886 assert_eq!(senior.rate_multiplier, 1.3);
2887 }
2888
2889 #[test]
2890 fn trait_default_multiplier() {
2891 let t = Trait::new("generic");
2892 assert_eq!(t.rate_multiplier, 1.0);
2893 }
2894
2895 #[test]
2896 fn rate_range_expected_midpoint() {
2897 use rust_decimal::Decimal;
2898 let range = RateRange::new(Decimal::from(450), Decimal::from(700));
2899 assert_eq!(range.expected(), Decimal::from(575));
2900 }
2901
2902 #[test]
2903 fn rate_range_spread_percent() {
2904 use rust_decimal::Decimal;
2905 let range = RateRange::new(Decimal::from(500), Decimal::from(700));
2907 let spread = range.spread_percent();
2908 assert!((spread - 33.33).abs() < 0.1);
2909 }
2910
2911 #[test]
2912 fn rate_range_collapsed() {
2913 use rust_decimal::Decimal;
2914 let range = RateRange::new(Decimal::from(500), Decimal::from(500));
2915 assert!(range.is_collapsed());
2916 assert_eq!(range.spread_percent(), 0.0);
2917 }
2918
2919 #[test]
2920 fn rate_range_inverted() {
2921 use rust_decimal::Decimal;
2922 let range = RateRange::new(Decimal::from(700), Decimal::from(500));
2923 assert!(range.is_inverted());
2924 }
2925
2926 #[test]
2927 fn rate_range_apply_multiplier() {
2928 use rust_decimal::Decimal;
2929 let range = RateRange::new(Decimal::from(500), Decimal::from(700));
2930 let scaled = range.apply_multiplier(1.3);
2931
2932 assert_eq!(scaled.min, Decimal::from(650));
2933 assert_eq!(scaled.max, Decimal::from(910));
2934 }
2935
2936 #[test]
2937 fn rate_range_with_currency() {
2938 use rust_decimal::Decimal;
2939 let range = RateRange::new(Decimal::from(500), Decimal::from(700)).currency("EUR");
2940
2941 assert_eq!(range.currency, Some("EUR".into()));
2942 }
2943
2944 #[test]
2945 fn resource_rate_fixed() {
2946 use rust_decimal::Decimal;
2947 let rate = ResourceRate::Fixed(Money::new(Decimal::from(500), "USD"));
2948
2949 assert!(rate.is_fixed());
2950 assert!(!rate.is_range());
2951 assert_eq!(rate.expected(), Decimal::from(500));
2952 }
2953
2954 #[test]
2955 fn resource_rate_range() {
2956 use rust_decimal::Decimal;
2957 let rate = ResourceRate::Range(RateRange::new(Decimal::from(450), Decimal::from(700)));
2958
2959 assert!(rate.is_range());
2960 assert!(!rate.is_fixed());
2961 assert_eq!(rate.expected(), Decimal::from(575));
2962 }
2963
2964 #[test]
2965 fn resource_profile_builder() {
2966 use rust_decimal::Decimal;
2967 let profile = ResourceProfile::new("backend_dev")
2968 .name("Backend Developer")
2969 .description("Server-side development")
2970 .specializes("developer")
2971 .skill("java")
2972 .skill("sql")
2973 .rate_range(RateRange::new(Decimal::from(550), Decimal::from(800)));
2974
2975 assert_eq!(profile.id, "backend_dev");
2976 assert_eq!(profile.name, "Backend Developer");
2977 assert_eq!(profile.description, Some("Server-side development".into()));
2978 assert_eq!(profile.specializes, Some("developer".into()));
2979 assert_eq!(profile.skills, vec!["java", "sql"]);
2980 assert!(profile.rate.is_some());
2981 }
2982
2983 #[test]
2984 fn resource_profile_with_traits() {
2985 let profile = ResourceProfile::new("senior_dev")
2986 .with_trait("senior")
2987 .with_traits(["contractor", "remote"]);
2988
2989 assert_eq!(profile.traits.len(), 3);
2990 assert!(profile.traits.contains(&"senior".into()));
2991 assert!(profile.traits.contains(&"contractor".into()));
2992 assert!(profile.traits.contains(&"remote".into()));
2993 }
2994
2995 #[test]
2996 fn resource_profile_is_abstract() {
2997 use rust_decimal::Decimal;
2998 let no_rate = ResourceProfile::new("dev");
3000 assert!(no_rate.is_abstract());
3001
3002 let with_range = ResourceProfile::new("dev")
3004 .rate_range(RateRange::new(Decimal::from(500), Decimal::from(700)));
3005 assert!(with_range.is_abstract());
3006
3007 let with_fixed = ResourceProfile::new("dev").rate(Money::new(Decimal::from(600), "USD"));
3009 assert!(!with_fixed.is_abstract());
3010 }
3011
3012 #[test]
3013 fn resource_profile_skills_batch() {
3014 let profile = ResourceProfile::new("dev").skills(["rust", "python", "go"]);
3015
3016 assert_eq!(profile.skills.len(), 3);
3017 assert!(profile.skills.contains(&"rust".into()));
3018 }
3019
3020 #[test]
3021 fn cost_range_fixed() {
3022 use rust_decimal::Decimal;
3023 let cost = CostRange::fixed(Decimal::from(50000), "USD");
3024
3025 assert!(cost.is_fixed());
3026 assert_eq!(cost.spread_percent(), 0.0);
3027 assert_eq!(cost.min, cost.max);
3028 assert_eq!(cost.expected, cost.min);
3029 }
3030
3031 #[test]
3032 fn cost_range_spread() {
3033 use rust_decimal::Decimal;
3034 let cost = CostRange::new(
3037 Decimal::from(40000),
3038 Decimal::from(50000),
3039 Decimal::from(60000),
3040 "USD",
3041 );
3042
3043 let spread = cost.spread_percent();
3044 assert!((spread - 20.0).abs() < 0.1);
3045 }
3046
3047 #[test]
3048 fn cost_range_add() {
3049 use rust_decimal::Decimal;
3050 let cost1 = CostRange::new(
3051 Decimal::from(10000),
3052 Decimal::from(15000),
3053 Decimal::from(20000),
3054 "USD",
3055 );
3056 let cost2 = CostRange::new(
3057 Decimal::from(5000),
3058 Decimal::from(7500),
3059 Decimal::from(10000),
3060 "USD",
3061 );
3062
3063 let total = cost1.add(&cost2);
3064 assert_eq!(total.min, Decimal::from(15000));
3065 assert_eq!(total.expected, Decimal::from(22500));
3066 assert_eq!(total.max, Decimal::from(30000));
3067 }
3068
3069 #[test]
3070 fn cost_policy_midpoint() {
3071 use rust_decimal::Decimal;
3072 let policy = CostPolicy::Midpoint;
3073 let expected = policy.expected(Decimal::from(100), Decimal::from(200));
3074 assert_eq!(expected, Decimal::from(150));
3075 }
3076
3077 #[test]
3078 fn cost_policy_optimistic() {
3079 use rust_decimal::Decimal;
3080 let policy = CostPolicy::Optimistic;
3081 let expected = policy.expected(Decimal::from(100), Decimal::from(200));
3082 assert_eq!(expected, Decimal::from(100));
3083 }
3084
3085 #[test]
3086 fn cost_policy_pessimistic() {
3087 use rust_decimal::Decimal;
3088 let policy = CostPolicy::Pessimistic;
3089 let expected = policy.expected(Decimal::from(100), Decimal::from(200));
3090 assert_eq!(expected, Decimal::from(200));
3091 }
3092
3093 #[test]
3094 fn cost_policy_default_is_midpoint() {
3095 assert_eq!(CostPolicy::default(), CostPolicy::Midpoint);
3096 }
3097
3098 #[test]
3099 fn resource_specializes() {
3100 let resource = Resource::new("alice")
3101 .name("Alice")
3102 .specializes("backend_senior")
3103 .availability(0.8);
3104
3105 assert_eq!(resource.specializes, Some("backend_senior".into()));
3106 assert_eq!(resource.availability, Some(0.8));
3107 assert!(resource.is_specialized());
3108 }
3109
3110 #[test]
3111 fn resource_effective_availability() {
3112 let full_time = Resource::new("dev1");
3113 assert_eq!(full_time.effective_availability(), 1.0);
3114
3115 let part_time = Resource::new("dev2").availability(0.5);
3116 assert_eq!(part_time.effective_availability(), 0.5);
3117 }
3118
3119 #[test]
3120 fn project_get_profile() {
3121 use rust_decimal::Decimal;
3122 let mut project = Project::new("Test");
3123 project.profiles.push(
3124 ResourceProfile::new("developer")
3125 .rate_range(RateRange::new(Decimal::from(500), Decimal::from(700))),
3126 );
3127 project.profiles.push(
3128 ResourceProfile::new("designer")
3129 .rate_range(RateRange::new(Decimal::from(400), Decimal::from(600))),
3130 );
3131
3132 let dev = project.get_profile("developer");
3133 assert!(dev.is_some());
3134 assert_eq!(dev.unwrap().id, "developer");
3135
3136 let missing = project.get_profile("manager");
3137 assert!(missing.is_none());
3138 }
3139
3140 #[test]
3141 fn project_get_trait() {
3142 let mut project = Project::new("Test");
3143 project
3144 .traits
3145 .push(Trait::new("senior").rate_multiplier(1.3));
3146 project
3147 .traits
3148 .push(Trait::new("junior").rate_multiplier(0.8));
3149
3150 let senior = project.get_trait("senior");
3151 assert!(senior.is_some());
3152 assert_eq!(senior.unwrap().rate_multiplier, 1.3);
3153
3154 let missing = project.get_trait("contractor");
3155 assert!(missing.is_none());
3156 }
3157
3158 #[test]
3159 fn project_has_rfc0001_fields() {
3160 let project = Project::new("Test");
3161
3162 assert!(project.profiles.is_empty());
3164 assert!(project.traits.is_empty());
3165 assert_eq!(project.cost_policy, CostPolicy::Midpoint);
3166 }
3167
3168 #[test]
3173 fn severity_as_str() {
3174 assert_eq!(Severity::Error.as_str(), "error");
3175 assert_eq!(Severity::Warning.as_str(), "warning");
3176 assert_eq!(Severity::Hint.as_str(), "hint");
3177 assert_eq!(Severity::Info.as_str(), "info");
3178 }
3179
3180 #[test]
3181 fn severity_display() {
3182 assert_eq!(format!("{}", Severity::Error), "error");
3183 assert_eq!(format!("{}", Severity::Warning), "warning");
3184 }
3185
3186 #[test]
3187 fn diagnostic_code_as_str() {
3188 assert_eq!(DiagnosticCode::E001CircularSpecialization.as_str(), "E001");
3189 assert_eq!(DiagnosticCode::W001AbstractAssignment.as_str(), "W001");
3190 assert_eq!(DiagnosticCode::H001MixedAbstraction.as_str(), "H001");
3191 assert_eq!(DiagnosticCode::I001ProjectCostSummary.as_str(), "I001");
3192 }
3193
3194 #[test]
3195 fn diagnostic_code_default_severity() {
3196 assert_eq!(
3197 DiagnosticCode::E001CircularSpecialization.default_severity(),
3198 Severity::Error
3199 );
3200 assert_eq!(
3201 DiagnosticCode::W001AbstractAssignment.default_severity(),
3202 Severity::Warning
3203 );
3204 assert_eq!(
3205 DiagnosticCode::H001MixedAbstraction.default_severity(),
3206 Severity::Hint
3207 );
3208 assert_eq!(
3209 DiagnosticCode::I001ProjectCostSummary.default_severity(),
3210 Severity::Info
3211 );
3212 }
3213
3214 #[test]
3215 fn diagnostic_code_ordering_priority() {
3216 assert!(
3218 DiagnosticCode::E001CircularSpecialization.ordering_priority()
3219 < DiagnosticCode::W001AbstractAssignment.ordering_priority()
3220 );
3221 assert!(
3223 DiagnosticCode::W001AbstractAssignment.ordering_priority()
3224 < DiagnosticCode::H001MixedAbstraction.ordering_priority()
3225 );
3226 assert!(
3228 DiagnosticCode::H001MixedAbstraction.ordering_priority()
3229 < DiagnosticCode::I001ProjectCostSummary.ordering_priority()
3230 );
3231 }
3232
3233 #[test]
3234 fn diagnostic_new_derives_severity() {
3235 let d = Diagnostic::new(DiagnosticCode::W001AbstractAssignment, "test message");
3236 assert_eq!(d.severity, Severity::Warning);
3237 assert_eq!(d.code, DiagnosticCode::W001AbstractAssignment);
3238 assert_eq!(d.message, "test message");
3239 }
3240
3241 #[test]
3242 fn diagnostic_builder_pattern() {
3243 let d = Diagnostic::new(DiagnosticCode::W001AbstractAssignment, "test")
3244 .with_file("test.proj")
3245 .with_span(SourceSpan::new(10, 5, 15))
3246 .with_note("additional info")
3247 .with_hint("try this instead");
3248
3249 assert_eq!(d.file, Some(std::path::PathBuf::from("test.proj")));
3250 assert!(d.span.is_some());
3251 assert_eq!(d.span.as_ref().unwrap().line, 10);
3252 assert_eq!(d.notes.len(), 1);
3253 assert_eq!(d.hints.len(), 1);
3254 }
3255
3256 #[test]
3257 fn diagnostic_is_error() {
3258 let error = Diagnostic::error(DiagnosticCode::E001CircularSpecialization, "cycle");
3259 let warning = Diagnostic::warning(DiagnosticCode::W001AbstractAssignment, "abstract");
3260
3261 assert!(error.is_error());
3262 assert!(!error.is_warning());
3263 assert!(!warning.is_error());
3264 assert!(warning.is_warning());
3265 }
3266
3267 #[test]
3268 fn source_span_with_label() {
3269 let span = SourceSpan::new(5, 10, 8).with_label("here");
3270 assert_eq!(span.line, 5);
3271 assert_eq!(span.column, 10);
3272 assert_eq!(span.length, 8);
3273 assert_eq!(span.label, Some("here".to_string()));
3274 }
3275
3276 #[test]
3277 fn collecting_emitter_basic() {
3278 let mut emitter = CollectingEmitter::new();
3279
3280 emitter.emit(Diagnostic::error(
3281 DiagnosticCode::E001CircularSpecialization,
3282 "error1",
3283 ));
3284 emitter.emit(Diagnostic::warning(
3285 DiagnosticCode::W001AbstractAssignment,
3286 "warn1",
3287 ));
3288 emitter.emit(Diagnostic::warning(
3289 DiagnosticCode::W002WideCostRange,
3290 "warn2",
3291 ));
3292
3293 assert_eq!(emitter.diagnostics.len(), 3);
3294 assert!(emitter.has_errors());
3295 assert_eq!(emitter.error_count(), 1);
3296 assert_eq!(emitter.warning_count(), 2);
3297 }
3298
3299 #[test]
3300 fn collecting_emitter_sorted() {
3301 let mut emitter = CollectingEmitter::new();
3302
3303 emitter.emit(Diagnostic::new(
3305 DiagnosticCode::I001ProjectCostSummary,
3306 "info",
3307 ));
3308 emitter.emit(Diagnostic::new(
3309 DiagnosticCode::W001AbstractAssignment,
3310 "warn",
3311 ));
3312 emitter.emit(Diagnostic::new(
3313 DiagnosticCode::E001CircularSpecialization,
3314 "error",
3315 ));
3316 emitter.emit(Diagnostic::new(
3317 DiagnosticCode::H001MixedAbstraction,
3318 "hint",
3319 ));
3320
3321 let sorted = emitter.sorted();
3322
3323 assert_eq!(sorted[0].code, DiagnosticCode::E001CircularSpecialization);
3325 assert_eq!(sorted[1].code, DiagnosticCode::W001AbstractAssignment);
3326 assert_eq!(sorted[2].code, DiagnosticCode::H001MixedAbstraction);
3327 assert_eq!(sorted[3].code, DiagnosticCode::I001ProjectCostSummary);
3328 }
3329
3330 #[test]
3331 fn collecting_emitter_sorted_by_location() {
3332 let mut emitter = CollectingEmitter::new();
3333
3334 emitter.emit(
3336 Diagnostic::new(DiagnosticCode::W001AbstractAssignment, "second")
3337 .with_file("a.proj")
3338 .with_span(SourceSpan::new(20, 1, 5)),
3339 );
3340 emitter.emit(
3341 Diagnostic::new(DiagnosticCode::W001AbstractAssignment, "first")
3342 .with_file("a.proj")
3343 .with_span(SourceSpan::new(10, 1, 5)),
3344 );
3345
3346 let sorted = emitter.sorted();
3347
3348 assert_eq!(sorted[0].message, "first");
3349 assert_eq!(sorted[1].message, "second");
3350 }
3351
3352 #[test]
3353 fn diagnostic_code_as_str_all_codes() {
3354 assert_eq!(DiagnosticCode::E002ProfileWithoutRate.as_str(), "E002");
3356 assert_eq!(DiagnosticCode::E003InfeasibleConstraint.as_str(), "E003");
3357 assert_eq!(DiagnosticCode::W002WideCostRange.as_str(), "W002");
3358 assert_eq!(DiagnosticCode::W003UnknownTrait.as_str(), "W003");
3359 assert_eq!(DiagnosticCode::W004ApproximateLeveling.as_str(), "W004");
3360 assert_eq!(DiagnosticCode::W005ConstraintZeroSlack.as_str(), "W005");
3361 assert_eq!(DiagnosticCode::W006ScheduleVariance.as_str(), "W006");
3362 assert_eq!(DiagnosticCode::W014ContainerDependency.as_str(), "W014");
3363 assert_eq!(DiagnosticCode::H002UnusedProfile.as_str(), "H002");
3364 assert_eq!(DiagnosticCode::H003UnusedTrait.as_str(), "H003");
3365 assert_eq!(DiagnosticCode::H004TaskUnconstrained.as_str(), "H004");
3366 assert_eq!(DiagnosticCode::I002RefinementProgress.as_str(), "I002");
3367 assert_eq!(DiagnosticCode::I003ResourceUtilization.as_str(), "I003");
3368 assert_eq!(DiagnosticCode::I004ProjectStatus.as_str(), "I004");
3369 assert_eq!(DiagnosticCode::I005EarnedValueSummary.as_str(), "I005");
3370 }
3371
3372 #[test]
3373 fn diagnostic_code_default_severity_all() {
3374 assert_eq!(
3376 DiagnosticCode::E003InfeasibleConstraint.default_severity(),
3377 Severity::Error
3378 );
3379 assert_eq!(
3381 DiagnosticCode::E002ProfileWithoutRate.default_severity(),
3382 Severity::Warning
3383 );
3384 assert_eq!(
3385 DiagnosticCode::W004ApproximateLeveling.default_severity(),
3386 Severity::Warning
3387 );
3388 assert_eq!(
3389 DiagnosticCode::W005ConstraintZeroSlack.default_severity(),
3390 Severity::Warning
3391 );
3392 assert_eq!(
3393 DiagnosticCode::W006ScheduleVariance.default_severity(),
3394 Severity::Warning
3395 );
3396 assert_eq!(
3398 DiagnosticCode::H002UnusedProfile.default_severity(),
3399 Severity::Hint
3400 );
3401 assert_eq!(
3402 DiagnosticCode::H003UnusedTrait.default_severity(),
3403 Severity::Hint
3404 );
3405 assert_eq!(
3406 DiagnosticCode::H004TaskUnconstrained.default_severity(),
3407 Severity::Hint
3408 );
3409 assert_eq!(
3411 DiagnosticCode::I002RefinementProgress.default_severity(),
3412 Severity::Info
3413 );
3414 assert_eq!(
3415 DiagnosticCode::I003ResourceUtilization.default_severity(),
3416 Severity::Info
3417 );
3418 assert_eq!(
3419 DiagnosticCode::I004ProjectStatus.default_severity(),
3420 Severity::Info
3421 );
3422 assert_eq!(
3423 DiagnosticCode::I005EarnedValueSummary.default_severity(),
3424 Severity::Info
3425 );
3426 }
3427
3428 #[test]
3429 fn diagnostic_code_ordering_priority_all() {
3430 assert!(DiagnosticCode::E002ProfileWithoutRate.ordering_priority() < 10);
3432 assert!(DiagnosticCode::E003InfeasibleConstraint.ordering_priority() < 10);
3433 assert!(DiagnosticCode::R102InvertedRateRange.ordering_priority() < 10);
3434 assert!(DiagnosticCode::R104UnknownProfile.ordering_priority() < 10);
3435 assert_eq!(DiagnosticCode::W002WideCostRange.ordering_priority(), 10);
3437 assert_eq!(
3438 DiagnosticCode::R012TraitMultiplierStack.ordering_priority(),
3439 11
3440 );
3441 assert_eq!(
3442 DiagnosticCode::W004ApproximateLeveling.ordering_priority(),
3443 12
3444 );
3445 assert_eq!(
3446 DiagnosticCode::W005ConstraintZeroSlack.ordering_priority(),
3447 12
3448 );
3449 assert_eq!(DiagnosticCode::W006ScheduleVariance.ordering_priority(), 13);
3450 assert_eq!(
3451 DiagnosticCode::W007UnresolvedDependency.ordering_priority(),
3452 14
3453 );
3454 assert_eq!(
3455 DiagnosticCode::W014ContainerDependency.ordering_priority(),
3456 15
3457 );
3458 assert_eq!(DiagnosticCode::W003UnknownTrait.ordering_priority(), 21);
3460 assert_eq!(DiagnosticCode::H002UnusedProfile.ordering_priority(), 31);
3462 assert_eq!(DiagnosticCode::H003UnusedTrait.ordering_priority(), 32);
3463 assert_eq!(
3464 DiagnosticCode::H004TaskUnconstrained.ordering_priority(),
3465 33
3466 );
3467 assert_eq!(
3469 DiagnosticCode::I002RefinementProgress.ordering_priority(),
3470 41
3471 );
3472 assert_eq!(
3473 DiagnosticCode::I003ResourceUtilization.ordering_priority(),
3474 42
3475 );
3476 assert_eq!(DiagnosticCode::I004ProjectStatus.ordering_priority(), 43);
3477 assert_eq!(
3478 DiagnosticCode::I005EarnedValueSummary.ordering_priority(),
3479 44
3480 );
3481 }
3482
3483 #[test]
3484 fn diagnostic_code_display() {
3485 assert_eq!(
3487 format!("{}", DiagnosticCode::E001CircularSpecialization),
3488 "E001"
3489 );
3490 assert_eq!(
3491 format!("{}", DiagnosticCode::W014ContainerDependency),
3492 "W014"
3493 );
3494 assert_eq!(format!("{}", DiagnosticCode::H004TaskUnconstrained), "H004");
3495 }
3496
3497 #[test]
3498 fn rate_range_spread_percent_zero_expected() {
3499 use rust_decimal::Decimal;
3500 let range = RateRange::new(Decimal::ZERO, Decimal::ZERO);
3502 assert_eq!(range.spread_percent(), 0.0);
3503 }
3504
3505 #[test]
3506 fn cost_range_spread_percent_zero_expected() {
3507 use rust_decimal::Decimal;
3508 let range = CostRange::new(Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, "USD");
3510 assert_eq!(range.spread_percent(), 0.0);
3511 }
3512
3513 #[test]
3514 fn resource_profile_builder_calendar() {
3515 let profile = ResourceProfile::new("dev").calendar("work_calendar");
3516 assert_eq!(profile.calendar, Some("work_calendar".to_string()));
3517 }
3518
3519 #[test]
3520 fn resource_profile_builder_efficiency() {
3521 let profile = ResourceProfile::new("dev").efficiency(0.8);
3522 assert_eq!(profile.efficiency, Some(0.8));
3523 }
3524
3525 #[test]
3526 fn task_builder_summary() {
3527 let task = Task::new("task1").summary("Short name");
3528 assert_eq!(task.summary, Some("Short name".to_string()));
3529 }
3530
3531 #[test]
3532 fn task_builder_effort() {
3533 let task = Task::new("task1").effort(Duration::days(5));
3534 assert_eq!(task.effort, Some(Duration::days(5)));
3535 }
3536
3537 #[test]
3538 fn task_builder_duration() {
3539 let task = Task::new("task1").duration(Duration::days(3));
3540 assert_eq!(task.duration, Some(Duration::days(3)));
3541 }
3542
3543 #[test]
3544 fn task_builder_depends_on() {
3545 let task = Task::new("task2").depends_on("task1");
3546 assert_eq!(task.depends.len(), 1);
3547 assert_eq!(task.depends[0].predecessor, "task1");
3548 assert_eq!(task.depends[0].dep_type, DependencyType::FinishToStart);
3549 assert_eq!(task.depends[0].lag, None);
3550 }
3551
3552 #[test]
3553 fn task_builder_assign() {
3554 let task = Task::new("task1").assign("alice");
3555 assert_eq!(task.assigned.len(), 1);
3556 assert_eq!(task.assigned[0].resource_id, "alice");
3557 assert_eq!(task.assigned[0].units, 1.0);
3558 }
3559
3560 #[test]
3561 fn task_builder_assign_with_units() {
3562 let task = Task::new("task1").assign_with_units("bob", 0.5);
3563 assert_eq!(task.assigned.len(), 1);
3564 assert_eq!(task.assigned[0].resource_id, "bob");
3565 assert_eq!(task.assigned[0].units, 0.5);
3566 }
3567
3568 #[test]
3569 fn source_span_with_label_and_display() {
3570 let span = SourceSpan::new(10, 5, 8).with_label("highlight");
3571 assert_eq!(span.line, 10);
3572 assert_eq!(span.column, 5);
3573 assert_eq!(span.length, 8);
3574 assert_eq!(span.label, Some("highlight".to_string()));
3575 }
3576
3577 #[test]
3582 fn scheduling_mode_default_is_duration_based() {
3583 assert_eq!(SchedulingMode::default(), SchedulingMode::DurationBased);
3584 }
3585
3586 #[test]
3587 fn scheduling_mode_description() {
3588 assert_eq!(
3589 SchedulingMode::DurationBased.description(),
3590 "duration-based (no effort tracking)"
3591 );
3592 assert_eq!(
3593 SchedulingMode::EffortBased.description(),
3594 "effort-based (no cost tracking)"
3595 );
3596 assert_eq!(
3597 SchedulingMode::ResourceLoaded.description(),
3598 "resource-loaded (full tracking)"
3599 );
3600 }
3601
3602 #[test]
3603 fn scheduling_mode_display() {
3604 assert_eq!(
3605 format!("{}", SchedulingMode::DurationBased),
3606 "duration-based (no effort tracking)"
3607 );
3608 assert_eq!(
3609 format!("{}", SchedulingMode::ResourceLoaded),
3610 "resource-loaded (full tracking)"
3611 );
3612 }
3613
3614 #[test]
3615 fn scheduling_mode_capabilities_duration_based() {
3616 let caps = SchedulingMode::DurationBased.capabilities();
3617 assert!(caps.timeline);
3618 assert!(!caps.utilization);
3619 assert!(!caps.cost_tracking);
3620 }
3621
3622 #[test]
3623 fn scheduling_mode_capabilities_effort_based() {
3624 let caps = SchedulingMode::EffortBased.capabilities();
3625 assert!(caps.timeline);
3626 assert!(caps.utilization);
3627 assert!(!caps.cost_tracking);
3628 }
3629
3630 #[test]
3631 fn scheduling_mode_capabilities_resource_loaded() {
3632 let caps = SchedulingMode::ResourceLoaded.capabilities();
3633 assert!(caps.timeline);
3634 assert!(caps.utilization);
3635 assert!(caps.cost_tracking);
3636 }
3637}
3638
3639pub trait Classifier: Send + Sync {
3658 fn name(&self) -> &'static str;
3660
3661 fn classify(&self, task: &Task, schedule: &Schedule) -> (String, usize);
3667}
3668
3669#[derive(Clone, Copy, Debug, Default)]
3682pub struct StatusClassifier;
3683
3684impl Classifier for StatusClassifier {
3685 fn name(&self) -> &'static str {
3686 "Progress Status"
3687 }
3688
3689 fn classify(&self, task: &Task, _schedule: &Schedule) -> (String, usize) {
3690 let pct = task.complete.unwrap_or(0.0);
3691
3692 match pct {
3693 p if p == 0.0 => ("Backlog".into(), 0),
3694 p if p > 0.0 && p <= 25.0 => ("Ready".into(), 1),
3695 p if p > 25.0 && p <= 75.0 => ("Doing".into(), 2),
3696 p if p > 75.0 && p < 100.0 => ("Review".into(), 3),
3697 p if p == 100.0 => ("Done".into(), 4),
3698 _ => ("Invalid".into(), 5),
3699 }
3700 }
3701}
3702
3703pub fn group_by<'a>(
3725 project: &'a Project,
3726 schedule: &'a Schedule,
3727 classifier: &dyn Classifier,
3728) -> Vec<(String, Vec<&'a Task>)> {
3729 let mut groups: HashMap<String, (usize, Vec<&Task>)> = HashMap::new();
3730
3731 for task in &project.tasks {
3732 let (label, order) = classifier.classify(task, schedule);
3733 groups
3734 .entry(label)
3735 .or_insert_with(|| (order, Vec::new()))
3736 .1
3737 .push(task);
3738 }
3739
3740 let mut result: Vec<_> = groups
3742 .into_iter()
3743 .map(|(label, (order, tasks))| (label, order, tasks))
3744 .collect();
3745
3746 result.sort_by_key(|(_, order, _)| *order);
3747 result
3748 .into_iter()
3749 .map(|(label, _, tasks)| (label, tasks))
3750 .collect()
3751}
3752
3753#[cfg(test)]
3754mod classifier_tests {
3755 use super::*;
3756
3757 fn empty_schedule() -> Schedule {
3759 Schedule {
3760 tasks: HashMap::new(),
3761 critical_path: Vec::new(),
3762 project_duration: Duration::days(0),
3763 project_end: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
3764 total_cost: None,
3765 total_cost_range: None,
3766 project_progress: 0,
3767 project_baseline_finish: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
3768 project_forecast_finish: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
3769 project_variance_days: 0,
3770 planned_value: 0,
3771 earned_value: 0,
3772 spi: 1.0,
3773 }
3774 }
3775
3776 #[test]
3777 fn test_classifier_trait_exists() {
3778 struct DummyClassifier;
3780 impl Classifier for DummyClassifier {
3781 fn name(&self) -> &'static str {
3782 "Dummy"
3783 }
3784 fn classify(&self, _task: &Task, _schedule: &Schedule) -> (String, usize) {
3785 ("category".into(), 0)
3786 }
3787 }
3788
3789 let classifier = DummyClassifier;
3790 assert_eq!(classifier.name(), "Dummy");
3791 }
3792
3793 #[test]
3794 fn test_status_classifier_name() {
3795 let classifier = StatusClassifier;
3796 assert_eq!(classifier.name(), "Progress Status");
3797 }
3798
3799 #[test]
3800 fn test_status_classifier_backlog() {
3801 let classifier = StatusClassifier;
3802 let schedule = empty_schedule();
3803 let task = Task::new("test");
3804 let (label, order) = classifier.classify(&task, &schedule);
3806 assert_eq!(label, "Backlog");
3807 assert_eq!(order, 0);
3808 }
3809
3810 #[test]
3811 fn test_status_classifier_ready() {
3812 let classifier = StatusClassifier;
3813 let schedule = empty_schedule();
3814 let task = Task::new("test").complete(10.0);
3815 let (label, order) = classifier.classify(&task, &schedule);
3816 assert_eq!(label, "Ready");
3817 assert_eq!(order, 1);
3818 }
3819
3820 #[test]
3821 fn test_status_classifier_ready_boundary() {
3822 let classifier = StatusClassifier;
3823 let schedule = empty_schedule();
3824 let task = Task::new("test").complete(25.0);
3825 let (label, order) = classifier.classify(&task, &schedule);
3826 assert_eq!(label, "Ready");
3827 assert_eq!(order, 1);
3828 }
3829
3830 #[test]
3831 fn test_status_classifier_doing() {
3832 let classifier = StatusClassifier;
3833 let schedule = empty_schedule();
3834 let task = Task::new("test").complete(50.0);
3835 let (label, order) = classifier.classify(&task, &schedule);
3836 assert_eq!(label, "Doing");
3837 assert_eq!(order, 2);
3838 }
3839
3840 #[test]
3841 fn test_status_classifier_doing_boundaries() {
3842 let classifier = StatusClassifier;
3843 let schedule = empty_schedule();
3844
3845 let task = Task::new("test").complete(26.0);
3847 let (label, _) = classifier.classify(&task, &schedule);
3848 assert_eq!(label, "Doing");
3849
3850 let task = Task::new("test").complete(75.0);
3852 let (label, _) = classifier.classify(&task, &schedule);
3853 assert_eq!(label, "Doing");
3854 }
3855
3856 #[test]
3857 fn test_status_classifier_review() {
3858 let classifier = StatusClassifier;
3859 let schedule = empty_schedule();
3860 let task = Task::new("test").complete(90.0);
3861 let (label, order) = classifier.classify(&task, &schedule);
3862 assert_eq!(label, "Review");
3863 assert_eq!(order, 3);
3864 }
3865
3866 #[test]
3867 fn test_status_classifier_review_boundary() {
3868 let classifier = StatusClassifier;
3869 let schedule = empty_schedule();
3870
3871 let task = Task::new("test").complete(76.0);
3873 let (label, _) = classifier.classify(&task, &schedule);
3874 assert_eq!(label, "Review");
3875
3876 let task = Task::new("test").complete(99.0);
3878 let (label, _) = classifier.classify(&task, &schedule);
3879 assert_eq!(label, "Review");
3880 }
3881
3882 #[test]
3883 fn test_status_classifier_done() {
3884 let classifier = StatusClassifier;
3885 let schedule = empty_schedule();
3886 let task = Task::new("test").complete(100.0);
3887 let (label, order) = classifier.classify(&task, &schedule);
3888 assert_eq!(label, "Done");
3889 assert_eq!(order, 4);
3890 }
3891
3892 #[test]
3893 fn test_status_classifier_invalid() {
3894 let classifier = StatusClassifier;
3895 let schedule = empty_schedule();
3896 let task = Task::new("test").complete(150.0);
3897 let (label, order) = classifier.classify(&task, &schedule);
3898 assert_eq!(label, "Invalid");
3899 assert_eq!(order, 5);
3900 }
3901
3902 #[test]
3903 fn test_group_by_empty_project() {
3904 let project = Project::new("Empty");
3905 let schedule = empty_schedule();
3906 let classifier = StatusClassifier;
3907
3908 let groups = group_by(&project, &schedule, &classifier);
3909 assert!(groups.is_empty());
3910 }
3911
3912 #[test]
3913 fn test_group_by_single_task() {
3914 let mut project = Project::new("Test");
3915 project.tasks.push(Task::new("task1").complete(50.0));
3916
3917 let schedule = empty_schedule();
3918 let classifier = StatusClassifier;
3919
3920 let groups = group_by(&project, &schedule, &classifier);
3921 assert_eq!(groups.len(), 1);
3922 assert_eq!(groups[0].0, "Doing");
3923 assert_eq!(groups[0].1.len(), 1);
3924 }
3925
3926 #[test]
3927 fn test_group_by_multiple_tasks_same_category() {
3928 let mut project = Project::new("Test");
3929 project.tasks.push(Task::new("task1").complete(30.0));
3930 project.tasks.push(Task::new("task2").complete(50.0));
3931 project.tasks.push(Task::new("task3").complete(70.0));
3932
3933 let schedule = empty_schedule();
3934 let classifier = StatusClassifier;
3935
3936 let groups = group_by(&project, &schedule, &classifier);
3937 assert_eq!(groups.len(), 1);
3938 assert_eq!(groups[0].0, "Doing");
3939 assert_eq!(groups[0].1.len(), 3);
3940 }
3941
3942 #[test]
3943 fn test_group_by_multiple_categories() {
3944 let mut project = Project::new("Test");
3945 project.tasks.push(Task::new("backlog").complete(0.0));
3946 project.tasks.push(Task::new("doing").complete(50.0));
3947 project.tasks.push(Task::new("done").complete(100.0));
3948
3949 let schedule = empty_schedule();
3950 let classifier = StatusClassifier;
3951
3952 let groups = group_by(&project, &schedule, &classifier);
3953 assert_eq!(groups.len(), 3);
3954
3955 assert_eq!(groups[0].0, "Backlog");
3957 assert_eq!(groups[1].0, "Doing");
3958 assert_eq!(groups[2].0, "Done");
3959 }
3960
3961 #[test]
3962 fn test_group_by_ordering_correct() {
3963 let mut project = Project::new("Test");
3964 project.tasks.push(Task::new("done").complete(100.0));
3966 project.tasks.push(Task::new("review").complete(90.0));
3967 project.tasks.push(Task::new("doing").complete(50.0));
3968 project.tasks.push(Task::new("ready").complete(10.0));
3969 project.tasks.push(Task::new("backlog").complete(0.0));
3970
3971 let schedule = empty_schedule();
3972 let classifier = StatusClassifier;
3973
3974 let groups = group_by(&project, &schedule, &classifier);
3975
3976 let labels: Vec<_> = groups.iter().map(|(l, _)| l.as_str()).collect();
3978 assert_eq!(labels, vec!["Backlog", "Ready", "Doing", "Review", "Done"]);
3979 }
3980
3981 #[test]
3982 fn test_group_by_no_mutation() {
3983 let mut project = Project::new("Test");
3984 project.tasks.push(Task::new("task1").complete(50.0));
3985
3986 let schedule = empty_schedule();
3987 let classifier = StatusClassifier;
3988
3989 let task_count_before = project.tasks.len();
3990 let _ = group_by(&project, &schedule, &classifier);
3991 let task_count_after = project.tasks.len();
3992
3993 assert_eq!(task_count_before, task_count_after);
3994 }
3995
3996 #[test]
3997 fn test_custom_classifier_works() {
3998 struct PriorityClassifier;
4000
4001 impl Classifier for PriorityClassifier {
4002 fn name(&self) -> &'static str {
4003 "Priority"
4004 }
4005
4006 fn classify(&self, task: &Task, _schedule: &Schedule) -> (String, usize) {
4007 match task.priority {
4008 p if p >= 800 => ("Critical".into(), 0),
4009 p if p >= 500 => ("Normal".into(), 1),
4010 _ => ("Low".into(), 2),
4011 }
4012 }
4013 }
4014
4015 let mut project = Project::new("Test");
4016 project.tasks.push(Task::new("high").priority(900));
4017 project.tasks.push(Task::new("normal").priority(500));
4018 project.tasks.push(Task::new("low").priority(100));
4019
4020 let schedule = empty_schedule();
4021 let classifier = PriorityClassifier;
4022
4023 let groups = group_by(&project, &schedule, &classifier);
4024 assert_eq!(groups.len(), 3);
4025 assert_eq!(groups[0].0, "Critical");
4026 assert_eq!(groups[1].0, "Normal");
4027 assert_eq!(groups[2].0, "Low");
4028 }
4029}