utf8proj_core/
lib.rs

1//! # utf8proj-core
2//!
3//! Core domain model and traits for the utf8proj scheduling engine.
4//!
5//! This crate provides:
6//! - Domain types: `Project`, `Task`, `Resource`, `Calendar`, `Schedule`
7//! - Core traits: `Scheduler`, `WhatIfAnalysis`, `Renderer`
8//! - Error types and result aliases
9//!
10//! ## Example
11//!
12//! ```rust
13//! use utf8proj_core::{Project, Task, Resource, Duration};
14//!
15//! let mut project = Project::new("My Project");
16//! project.tasks.push(
17//!     Task::new("design")
18//!         .effort(Duration::days(5))
19//!         .assign("dev")
20//! );
21//! project.tasks.push(
22//!     Task::new("implement")
23//!         .effort(Duration::days(10))
24//!         .depends_on("design")
25//!         .assign("dev")
26//! );
27//! project.resources.push(Resource::new("dev").capacity(1.0));
28//! ```
29
30use chrono::{Datelike, NaiveDate};
31use rust_decimal::Decimal;
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use thiserror::Error;
35
36// ============================================================================
37// Type Aliases
38// ============================================================================
39
40/// Unique identifier for a task
41pub type TaskId = String;
42
43/// Unique identifier for a resource
44pub type ResourceId = String;
45
46/// Unique identifier for a calendar
47pub type CalendarId = String;
48
49/// Unique identifier for a resource profile
50pub type ProfileId = String;
51
52/// Unique identifier for a trait
53pub type TraitId = String;
54
55/// Duration in working time
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
57pub struct Duration {
58    /// Number of minutes
59    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        } // 8-hour workday
79    }
80
81    pub const fn weeks(w: i64) -> Self {
82        Self {
83            minutes: w * 5 * 8 * 60,
84        } // 5-day workweek
85    }
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/// Monetary amount with currency
115#[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// ============================================================================
131// RFC-0001: Progressive Resource Refinement Types
132// ============================================================================
133
134/// A trait that modifies resource rates (RFC-0001)
135///
136/// Traits are scalar modifiers, not behavioral mixins. They apply
137/// multiplicative rate adjustments (e.g., senior = 1.3x, junior = 0.8x).
138///
139/// # Example
140///
141/// ```rust
142/// use utf8proj_core::Trait;
143///
144/// let senior = Trait::new("senior")
145///     .description("5+ years experience")
146///     .rate_multiplier(1.3);
147///
148/// assert_eq!(senior.rate_multiplier, 1.3);
149/// ```
150#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
151pub struct Trait {
152    /// Unique identifier
153    pub id: TraitId,
154    /// Human-readable name
155    pub name: String,
156    /// Optional description
157    pub description: Option<String>,
158    /// Rate multiplier (1.0 = no change, 1.3 = 30% increase)
159    pub rate_multiplier: f64,
160}
161
162impl Trait {
163    /// Create a new trait with the given ID
164    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    /// Set the trait description
175    pub fn description(mut self, desc: impl Into<String>) -> Self {
176        self.description = Some(desc.into());
177        self
178    }
179
180    /// Set the rate multiplier
181    pub fn rate_multiplier(mut self, multiplier: f64) -> Self {
182        self.rate_multiplier = multiplier;
183        self
184    }
185}
186
187/// Rate range for abstract resource profiles (RFC-0001)
188///
189/// Represents a cost range with min/max bounds and optional currency.
190/// Used during early planning when exact rates are unknown.
191#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
192pub struct RateRange {
193    /// Minimum rate (best case)
194    pub min: Decimal,
195    /// Maximum rate (worst case)
196    pub max: Decimal,
197    /// Currency (defaults to project currency if not set)
198    pub currency: Option<String>,
199}
200
201impl RateRange {
202    /// Create a new rate range
203    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    /// Set the currency
212    pub fn currency(mut self, currency: impl Into<String>) -> Self {
213        self.currency = Some(currency.into());
214        self
215    }
216
217    /// Calculate the expected (midpoint) rate
218    pub fn expected(&self) -> Decimal {
219        (self.min + self.max) / Decimal::from(2)
220    }
221
222    /// Calculate the spread percentage: (max - min) / expected * 100
223    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        // Convert to f64 for percentage calculation
230        use rust_decimal::prelude::ToPrimitive;
231        (spread / expected).to_f64().unwrap_or(0.0) * 100.0
232    }
233
234    /// Check if this is a collapsed range (min == max)
235    pub fn is_collapsed(&self) -> bool {
236        self.min == self.max
237    }
238
239    /// Check if the range is inverted (min > max)
240    pub fn is_inverted(&self) -> bool {
241        self.min > self.max
242    }
243
244    /// Apply a multiplier to the range (for trait composition)
245    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/// Resource rate that can be either fixed or a range (RFC-0001)
257#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
258pub enum ResourceRate {
259    /// Fixed rate (concrete resource)
260    Fixed(Money),
261    /// Rate range (abstract profile)
262    Range(RateRange),
263}
264
265impl ResourceRate {
266    /// Get the expected rate value
267    pub fn expected(&self) -> Decimal {
268        match self {
269            ResourceRate::Fixed(money) => money.amount,
270            ResourceRate::Range(range) => range.expected(),
271        }
272    }
273
274    /// Check if this is a range (abstract)
275    pub fn is_range(&self) -> bool {
276        matches!(self, ResourceRate::Range(_))
277    }
278
279    /// Check if this is a fixed rate (concrete)
280    pub fn is_fixed(&self) -> bool {
281        matches!(self, ResourceRate::Fixed(_))
282    }
283}
284
285/// Abstract resource profile for planning (RFC-0001)
286///
287/// Represents a role or capability, not a specific person.
288/// Used during early estimation when staffing is not finalized.
289///
290/// # Example
291///
292/// ```rust
293/// use utf8proj_core::{ResourceProfile, RateRange};
294/// use rust_decimal::Decimal;
295///
296/// let backend_dev = ResourceProfile::new("backend_dev")
297///     .name("Backend Developer")
298///     .description("Server-side development")
299///     .specializes("developer")
300///     .skill("java")
301///     .skill("sql")
302///     .rate_range(RateRange::new(Decimal::from(550), Decimal::from(800)));
303/// ```
304#[derive(Clone, Debug, Serialize, Deserialize)]
305pub struct ResourceProfile {
306    /// Unique identifier
307    pub id: ProfileId,
308    /// Human-readable name
309    pub name: String,
310    /// Optional description
311    pub description: Option<String>,
312    /// Parent profile (constraint refinement, not OO inheritance)
313    pub specializes: Option<ProfileId>,
314    /// Required skills
315    pub skills: Vec<String>,
316    /// Applied traits (rate modifiers)
317    pub traits: Vec<TraitId>,
318    /// Rate (can be range or fixed)
319    pub rate: Option<ResourceRate>,
320    /// Custom calendar
321    pub calendar: Option<CalendarId>,
322    /// Efficiency factor
323    pub efficiency: Option<f32>,
324}
325
326impl ResourceProfile {
327    /// Create a new resource profile with the given ID
328    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    /// Set the profile name
344    pub fn name(mut self, name: impl Into<String>) -> Self {
345        self.name = name.into();
346        self
347    }
348
349    /// Set the description
350    pub fn description(mut self, desc: impl Into<String>) -> Self {
351        self.description = Some(desc.into());
352        self
353    }
354
355    /// Set the parent profile (specialization)
356    pub fn specializes(mut self, parent: impl Into<String>) -> Self {
357        self.specializes = Some(parent.into());
358        self
359    }
360
361    /// Add a skill
362    pub fn skill(mut self, skill: impl Into<String>) -> Self {
363        self.skills.push(skill.into());
364        self
365    }
366
367    /// Add multiple skills
368    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    /// Add a trait
374    pub fn with_trait(mut self, trait_id: impl Into<String>) -> Self {
375        self.traits.push(trait_id.into());
376        self
377    }
378
379    /// Add multiple traits
380    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    /// Set a rate range
386    pub fn rate_range(mut self, range: RateRange) -> Self {
387        self.rate = Some(ResourceRate::Range(range));
388        self
389    }
390
391    /// Set a fixed rate
392    pub fn rate(mut self, rate: Money) -> Self {
393        self.rate = Some(ResourceRate::Fixed(rate));
394        self
395    }
396
397    /// Set the calendar
398    pub fn calendar(mut self, calendar: impl Into<String>) -> Self {
399        self.calendar = Some(calendar.into());
400        self
401    }
402
403    /// Set the efficiency factor
404    pub fn efficiency(mut self, efficiency: f32) -> Self {
405        self.efficiency = Some(efficiency);
406        self
407    }
408
409    /// Check if this profile is abstract (has no fixed rate or is a range)
410    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/// Cost range for scheduled tasks (RFC-0001)
420///
421/// Represents the computed cost range for a task or project
422/// based on abstract resource assignments.
423#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
424pub struct CostRange {
425    /// Minimum cost (best case)
426    pub min: Decimal,
427    /// Expected cost (based on policy)
428    pub expected: Decimal,
429    /// Maximum cost (worst case)
430    pub max: Decimal,
431    /// Currency
432    pub currency: String,
433}
434
435impl CostRange {
436    /// Create a new cost range
437    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    /// Create a fixed (zero-spread) cost range
447    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    /// Calculate the spread percentage: ±((max - min) / 2 / expected) * 100
457    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    /// Check if this is a fixed cost (zero spread)
467    pub fn is_fixed(&self) -> bool {
468        self.min == self.max
469    }
470
471    /// Add two cost ranges
472    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/// Policy for calculating expected cost from ranges (RFC-0001)
483#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
484pub enum CostPolicy {
485    /// Use midpoint: (min + max) / 2
486    #[default]
487    Midpoint,
488    /// Use minimum (optimistic)
489    Optimistic,
490    /// Use maximum (pessimistic)
491    Pessimistic,
492}
493
494impl CostPolicy {
495    /// Calculate expected value from a range
496    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// ============================================================================
506// Project
507// ============================================================================
508
509/// A complete project definition
510#[derive(Clone, Debug, Serialize, Deserialize)]
511pub struct Project {
512    /// Unique identifier
513    pub id: String,
514    /// Human-readable name
515    pub name: String,
516    /// Project start date
517    pub start: NaiveDate,
518    /// Project end date (optional, can be computed)
519    pub end: Option<NaiveDate>,
520    /// Status date for progress-aware scheduling (RFC-0004)
521    /// When set, remaining work schedules from this date
522    pub status_date: Option<NaiveDate>,
523    /// Default calendar for the project
524    pub calendar: CalendarId,
525    /// Currency for cost calculations
526    pub currency: String,
527    /// All tasks in the project (may be hierarchical)
528    pub tasks: Vec<Task>,
529    /// All resources available to the project
530    pub resources: Vec<Resource>,
531    /// Calendar definitions
532    pub calendars: Vec<Calendar>,
533    /// Scenario definitions (for what-if analysis)
534    pub scenarios: Vec<Scenario>,
535    /// Custom attributes (timezone, etc.)
536    pub attributes: HashMap<String, String>,
537
538    // RFC-0001: Progressive Resource Refinement fields
539    /// Resource profiles (abstract roles/capabilities)
540    pub profiles: Vec<ResourceProfile>,
541    /// Trait definitions (rate modifiers)
542    pub traits: Vec<Trait>,
543    /// Policy for calculating expected cost from ranges
544    pub cost_policy: CostPolicy,
545}
546
547impl Project {
548    /// Create a new project with the given name
549    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    /// Get a task by ID (searches recursively)
570    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    /// Get a resource by ID
586    pub fn get_resource(&self, id: &str) -> Option<&Resource> {
587        self.resources.iter().find(|r| r.id == id)
588    }
589
590    /// Get a resource profile by ID (RFC-0001)
591    pub fn get_profile(&self, id: &str) -> Option<&ResourceProfile> {
592        self.profiles.iter().find(|p| p.id == id)
593    }
594
595    /// Get a trait by ID (RFC-0001)
596    pub fn get_trait(&self, id: &str) -> Option<&Trait> {
597        self.traits.iter().find(|t| t.id == id)
598    }
599
600    /// Get all leaf tasks (tasks without children)
601    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// ============================================================================
618// Task
619// ============================================================================
620
621/// A schedulable unit of work
622#[derive(Clone, Debug, Serialize, Deserialize)]
623pub struct Task {
624    /// Unique identifier
625    pub id: TaskId,
626    /// Human-readable description (from quoted string in DSL)
627    pub name: String,
628    /// Optional short display name (MS Project "Task Name" style)
629    pub summary: Option<String>,
630    /// Work effort required (person-time)
631    pub effort: Option<Duration>,
632    /// Calendar duration (overrides effort-based calculation)
633    pub duration: Option<Duration>,
634    /// Task dependencies
635    pub depends: Vec<Dependency>,
636    /// Resource assignments
637    pub assigned: Vec<ResourceRef>,
638    /// Scheduling priority (higher = scheduled first)
639    pub priority: u32,
640    /// Scheduling constraints
641    pub constraints: Vec<TaskConstraint>,
642    /// Is this a milestone (zero duration)?
643    pub milestone: bool,
644    /// Child tasks (WBS hierarchy)
645    pub children: Vec<Task>,
646    /// Completion percentage (for tracking)
647    pub complete: Option<f32>,
648    /// Actual start date (when work actually began)
649    pub actual_start: Option<NaiveDate>,
650    /// Actual finish date (when work actually completed)
651    pub actual_finish: Option<NaiveDate>,
652    /// Explicit remaining duration (overrides linear calculation from complete%)
653    /// When set, this takes precedence over `duration * (1 - complete/100)`
654    pub explicit_remaining: Option<Duration>,
655    /// Task status for progress tracking
656    pub status: Option<TaskStatus>,
657    /// Custom attributes
658    pub attributes: HashMap<String, String>,
659}
660
661impl Task {
662    /// Create a new task with the given ID
663    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    /// Set the task name
687    pub fn name(mut self, name: impl Into<String>) -> Self {
688        self.name = name.into();
689        self
690    }
691
692    /// Set the task summary (short display name)
693    pub fn summary(mut self, summary: impl Into<String>) -> Self {
694        self.summary = Some(summary.into());
695        self
696    }
697
698    /// Set the effort
699    pub fn effort(mut self, effort: Duration) -> Self {
700        self.effort = Some(effort);
701        self
702    }
703
704    /// Set the duration
705    pub fn duration(mut self, duration: Duration) -> Self {
706        self.duration = Some(duration);
707        self
708    }
709
710    /// Add a dependency (FinishToStart by default)
711    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    /// Add a dependency with full control over type and lag
721    pub fn with_dependency(mut self, dep: Dependency) -> Self {
722        self.depends.push(dep);
723        self
724    }
725
726    /// Assign a resource
727    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    /// Assign a resource with specific allocation units
736    ///
737    /// Units represent allocation percentage: 1.0 = 100%, 0.5 = 50%, etc.
738    /// This affects effort-driven duration calculation:
739    ///   Duration = Effort / Total_Units
740    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    /// Set priority
749    pub fn priority(mut self, priority: u32) -> Self {
750        self.priority = priority;
751        self
752    }
753
754    /// Mark as milestone
755    pub fn milestone(mut self) -> Self {
756        self.milestone = true;
757        self.duration = Some(Duration::zero());
758        self
759    }
760
761    /// Add a child task
762    pub fn child(mut self, child: Task) -> Self {
763        self.children.push(child);
764        self
765    }
766
767    /// Add a temporal constraint
768    pub fn constraint(mut self, constraint: TaskConstraint) -> Self {
769        self.constraints.push(constraint);
770        self
771    }
772
773    /// Check if this is a summary task (has children)
774    pub fn is_summary(&self) -> bool {
775        !self.children.is_empty()
776    }
777
778    // ========================================================================
779    // Progress Tracking Methods
780    // ========================================================================
781
782    /// Calculate remaining duration based on completion percentage.
783    /// Uses linear interpolation: remaining = original × (1 - complete/100)
784    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    /// Get effective completion percentage as u8 (0-100).
792    /// Returns 0 if not set, clamped to 0-100 range.
793    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    /// Derive task status from actual dates and completion.
800    /// Returns explicit status if set, otherwise derives from data.
801    /// For containers, uses effective_progress() to derive status from children.
802    pub fn derived_status(&self) -> TaskStatus {
803        // Use explicit status if set
804        if let Some(ref status) = self.status {
805            return status.clone();
806        }
807
808        // Derive from actual data - use effective_progress for container rollup
809        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    /// Set the completion percentage (builder pattern)
820    pub fn complete(mut self, pct: f32) -> Self {
821        self.complete = Some(pct);
822        self
823    }
824
825    /// Check if this task is a container (has children)
826    pub fn is_container(&self) -> bool {
827        !self.children.is_empty()
828    }
829
830    /// Calculate container progress as weighted average of children by duration.
831    /// Returns None if not a container or if no children have duration.
832    /// Formula: Σ(child.percent_complete × child.duration) / Σ(child.duration)
833    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    /// Helper to recursively calculate weighted progress from all descendants.
848    /// Returns (weighted_sum, total_duration_minutes)
849    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                // Recursively get progress from nested containers
856                let (child_weighted, child_duration) = child.calculate_weighted_progress();
857                total_weighted += child_weighted;
858                total_duration += child_duration;
859            } else {
860                // Leaf task - use its duration and progress
861                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    /// Get the effective progress for this task, considering container rollup.
874    /// For containers: returns derived progress from children (unless manually overridden).
875    /// For leaf tasks: returns the explicit completion percentage.
876    pub fn effective_progress(&self) -> u8 {
877        // If manual override is set, use it
878        if let Some(pct) = self.complete {
879            return pct.clamp(0.0, 100.0) as u8;
880        }
881
882        // For containers, derive from children
883        if let Some(derived) = self.container_progress() {
884            return derived;
885        }
886
887        // Default to 0
888        0
889    }
890
891    /// Check if container progress significantly differs from manual override.
892    /// Returns Some((manual, derived)) if mismatch > threshold, None otherwise.
893    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    /// Set the actual start date (builder pattern)
910    pub fn actual_start(mut self, date: NaiveDate) -> Self {
911        self.actual_start = Some(date);
912        self
913    }
914
915    /// Set the actual finish date (builder pattern)
916    pub fn actual_finish(mut self, date: NaiveDate) -> Self {
917        self.actual_finish = Some(date);
918        self
919    }
920
921    /// Set explicit remaining duration (builder pattern)
922    /// When set, this overrides the linear calculation `duration * (1 - complete/100)`
923    pub fn explicit_remaining(mut self, remaining: Duration) -> Self {
924        self.explicit_remaining = Some(remaining);
925        self
926    }
927
928    /// Set the task status (builder pattern)
929    pub fn with_status(mut self, status: TaskStatus) -> Self {
930        self.status = Some(status);
931        self
932    }
933}
934
935/// Task dependency with type and lag
936#[derive(Clone, Debug, Serialize, Deserialize)]
937pub struct Dependency {
938    /// ID of the predecessor task
939    pub predecessor: TaskId,
940    /// Type of dependency
941    pub dep_type: DependencyType,
942    /// Lag time (positive) or lead time (negative)
943    pub lag: Option<Duration>,
944}
945
946/// Types of task dependencies
947#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
948pub enum DependencyType {
949    /// Finish-to-Start: successor starts after predecessor finishes
950    #[default]
951    FinishToStart,
952    /// Start-to-Start: successor starts when predecessor starts
953    StartToStart,
954    /// Finish-to-Finish: successor finishes when predecessor finishes
955    FinishToFinish,
956    /// Start-to-Finish: successor finishes when predecessor starts
957    StartToFinish,
958}
959
960/// Task status for progress tracking
961#[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/// Scheduling mode classification for capability awareness
986///
987/// This describes *what kind of schedule* a project represents, not whether
988/// it's correct or complete. All modes are valid and appropriate for different
989/// planning contexts.
990#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
991pub enum SchedulingMode {
992    /// Tasks use `duration:` only, no effort or resource assignments
993    /// Suitable for: roadmaps, timelines, regulatory deadlines, migration plans
994    /// Capabilities: timeline ✓, utilization ✗, cost tracking ✗
995    #[default]
996    DurationBased,
997    /// Tasks use `effort:` with resource assignments
998    /// Suitable for: project planning with team workload tracking
999    /// Capabilities: timeline ✓, utilization ✓, cost tracking depends on rates
1000    EffortBased,
1001    /// Tasks use `effort:` with resource assignments AND resources have rates
1002    /// Suitable for: full project management with budget tracking
1003    /// Capabilities: timeline ✓, utilization ✓, cost tracking ✓
1004    ResourceLoaded,
1005}
1006
1007impl SchedulingMode {
1008    /// Human-readable description for diagnostics
1009    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    /// What capabilities are available in this mode
1018    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/// Capabilities available for a given scheduling mode
1046#[derive(Clone, Debug, Default, PartialEq, Eq)]
1047pub struct SchedulingCapabilities {
1048    /// Can calculate task start/end dates
1049    pub timeline: bool,
1050    /// Can track resource utilization
1051    pub utilization: bool,
1052    /// Can track project costs
1053    pub cost_tracking: bool,
1054}
1055
1056/// Reference to a resource with allocation units
1057#[derive(Clone, Debug, Serialize, Deserialize)]
1058pub struct ResourceRef {
1059    /// ID of the resource
1060    pub resource_id: ResourceId,
1061    /// Allocation units (1.0 = 100%)
1062    pub units: f32,
1063}
1064
1065/// Constraint on task scheduling
1066#[derive(Clone, Debug, Serialize, Deserialize)]
1067pub enum TaskConstraint {
1068    /// Task must start on this date
1069    MustStartOn(NaiveDate),
1070    /// Task must finish on this date
1071    MustFinishOn(NaiveDate),
1072    /// Task cannot start before this date
1073    StartNoEarlierThan(NaiveDate),
1074    /// Task must start by this date
1075    StartNoLaterThan(NaiveDate),
1076    /// Task cannot finish before this date
1077    FinishNoEarlierThan(NaiveDate),
1078    /// Task must finish by this date
1079    FinishNoLaterThan(NaiveDate),
1080}
1081
1082// ============================================================================
1083// Resource
1084// ============================================================================
1085
1086/// A person or equipment that can be assigned to tasks
1087#[derive(Clone, Debug, Serialize, Deserialize)]
1088pub struct Resource {
1089    /// Unique identifier
1090    pub id: ResourceId,
1091    /// Human-readable name
1092    pub name: String,
1093    /// Cost rate (per time unit)
1094    pub rate: Option<Money>,
1095    /// Capacity (1.0 = full time, 0.5 = half time)
1096    pub capacity: f32,
1097    /// Custom calendar (overrides project default)
1098    pub calendar: Option<CalendarId>,
1099    /// Efficiency factor (default 1.0)
1100    pub efficiency: f32,
1101    /// Custom attributes
1102    pub attributes: HashMap<String, String>,
1103
1104    // RFC-0001: Progressive Resource Refinement fields
1105    /// Profile that this resource specializes (constraint refinement)
1106    pub specializes: Option<ProfileId>,
1107    /// Availability (0.0-1.0, multiplied with calendar hours)
1108    /// Separate from capacity for progressive refinement semantics
1109    pub availability: Option<f32>,
1110}
1111
1112impl Resource {
1113    /// Create a new resource with the given ID
1114    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    /// Set the resource name
1130    pub fn name(mut self, name: impl Into<String>) -> Self {
1131        self.name = name.into();
1132        self
1133    }
1134
1135    /// Set the capacity
1136    pub fn capacity(mut self, capacity: f32) -> Self {
1137        self.capacity = capacity;
1138        self
1139    }
1140
1141    /// Set the cost rate
1142    pub fn rate(mut self, rate: Money) -> Self {
1143        self.rate = Some(rate);
1144        self
1145    }
1146
1147    /// Set the efficiency factor
1148    pub fn efficiency(mut self, efficiency: f32) -> Self {
1149        self.efficiency = efficiency;
1150        self
1151    }
1152
1153    /// Set the profile this resource specializes (RFC-0001)
1154    pub fn specializes(mut self, profile: impl Into<String>) -> Self {
1155        self.specializes = Some(profile.into());
1156        self
1157    }
1158
1159    /// Set the availability (RFC-0001)
1160    ///
1161    /// Availability is multiplied with calendar hours to get effective capacity.
1162    /// Example: 0.8 availability × 8h/day calendar = 6.4 effective hours/day
1163    pub fn availability(mut self, availability: f32) -> Self {
1164        self.availability = Some(availability);
1165        self
1166    }
1167
1168    /// Get effective availability (defaults to 1.0 if not set)
1169    pub fn effective_availability(&self) -> f32 {
1170        self.availability.unwrap_or(1.0)
1171    }
1172
1173    /// Check if this resource specializes a profile
1174    pub fn is_specialized(&self) -> bool {
1175        self.specializes.is_some()
1176    }
1177}
1178
1179// ============================================================================
1180// Calendar
1181// ============================================================================
1182
1183/// Working time definitions
1184#[derive(Clone, Debug, Serialize, Deserialize)]
1185pub struct Calendar {
1186    /// Unique identifier
1187    pub id: CalendarId,
1188    /// Human-readable name
1189    pub name: String,
1190    /// Working hours per day
1191    pub working_hours: Vec<TimeRange>,
1192    /// Working days (0 = Sunday, 6 = Saturday)
1193    pub working_days: Vec<u8>,
1194    /// Holiday dates
1195    pub holidays: Vec<Holiday>,
1196    /// Exceptions (override working hours for specific dates)
1197    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], // Mon-Fri
1216            holidays: Vec::new(),
1217            exceptions: Vec::new(),
1218        }
1219    }
1220}
1221
1222impl Calendar {
1223    /// Calculate working hours per day
1224    pub fn hours_per_day(&self) -> f64 {
1225        self.working_hours.iter().map(|r| r.duration_hours()).sum()
1226    }
1227
1228    /// Check if a date is a working day
1229    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/// Time range within a day (in minutes from midnight)
1242#[derive(Clone, Debug, Serialize, Deserialize)]
1243pub struct TimeRange {
1244    pub start: u16, // Minutes from midnight
1245    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/// Holiday definition
1255#[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/// Calendar exception (override for specific dates)
1269#[derive(Clone, Debug, Serialize, Deserialize)]
1270pub struct CalendarException {
1271    pub date: NaiveDate,
1272    pub working_hours: Option<Vec<TimeRange>>, // None = non-working
1273}
1274
1275// ============================================================================
1276// Scenario
1277// ============================================================================
1278
1279/// Alternative scenario for what-if analysis
1280#[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/// Override for a scenario
1289#[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// ============================================================================
1306// Schedule (Result)
1307// ============================================================================
1308
1309/// The result of scheduling a project
1310#[derive(Clone, Debug, Serialize, Deserialize)]
1311pub struct Schedule {
1312    /// Scheduled tasks indexed by ID
1313    pub tasks: HashMap<TaskId, ScheduledTask>,
1314    /// Tasks on the critical path
1315    pub critical_path: Vec<TaskId>,
1316    /// Total project duration
1317    pub project_duration: Duration,
1318    /// Project end date
1319    pub project_end: NaiveDate,
1320    /// Total project cost (concrete resources only)
1321    pub total_cost: Option<Money>,
1322    /// RFC-0001: Total cost range (includes abstract profile assignments)
1323    pub total_cost_range: Option<CostRange>,
1324
1325    // Project Status Fields (I004)
1326    /// Overall project progress (0-100), weighted by task duration
1327    pub project_progress: u8,
1328    /// Project baseline finish date (max of all baseline_finish)
1329    pub project_baseline_finish: NaiveDate,
1330    /// Project forecast finish date (max of all forecast_finish)
1331    pub project_forecast_finish: NaiveDate,
1332    /// Project-level variance in calendar days (forecast - baseline)
1333    pub project_variance_days: i64,
1334
1335    // Earned Value Fields (I005)
1336    /// Planned Value at status date (0-100), weighted % of baseline work due
1337    pub planned_value: u8,
1338    /// Earned Value (same as project_progress, 0-100)
1339    pub earned_value: u8,
1340    /// Schedule Performance Index (EV / PV), capped at 2.0
1341    pub spi: f64,
1342}
1343
1344/// A task with computed schedule information
1345#[derive(Clone, Debug, Serialize, Deserialize)]
1346pub struct ScheduledTask {
1347    /// Task ID
1348    pub task_id: TaskId,
1349    /// Scheduled start date
1350    pub start: NaiveDate,
1351    /// Scheduled finish date
1352    pub finish: NaiveDate,
1353    /// Actual duration
1354    pub duration: Duration,
1355    /// Resource assignments with time periods
1356    pub assignments: Vec<Assignment>,
1357    /// Slack/float time
1358    pub slack: Duration,
1359    /// Is this task on the critical path?
1360    pub is_critical: bool,
1361    /// Early start date
1362    pub early_start: NaiveDate,
1363    /// Early finish date
1364    pub early_finish: NaiveDate,
1365    /// Late start date
1366    pub late_start: NaiveDate,
1367    /// Late finish date
1368    pub late_finish: NaiveDate,
1369
1370    // ========================================================================
1371    // Progress Tracking Fields
1372    // ========================================================================
1373    /// Forecast start (actual_start if available, otherwise planned start)
1374    pub forecast_start: NaiveDate,
1375    /// Forecast finish date (calculated based on progress)
1376    pub forecast_finish: NaiveDate,
1377    /// Remaining duration based on progress
1378    pub remaining_duration: Duration,
1379    /// Completion percentage (0-100)
1380    pub percent_complete: u8,
1381    /// Current task status
1382    pub status: TaskStatus,
1383
1384    // ========================================================================
1385    // Variance Fields (Baseline vs Forecast)
1386    // ========================================================================
1387    /// Baseline start (planned start ignoring progress)
1388    pub baseline_start: NaiveDate,
1389    /// Baseline finish (planned finish ignoring progress)
1390    pub baseline_finish: NaiveDate,
1391    /// Start variance in days (forecast_start - baseline_start, positive = late)
1392    pub start_variance_days: i64,
1393    /// Finish variance in days (forecast_finish - baseline_finish, positive = late)
1394    pub finish_variance_days: i64,
1395
1396    // ========================================================================
1397    // RFC-0001: Cost Range Fields
1398    // ========================================================================
1399    /// Task cost range (aggregated from all assignments)
1400    pub cost_range: Option<CostRange>,
1401    /// Whether this task has any abstract (profile) assignments
1402    pub has_abstract_assignments: bool,
1403}
1404
1405impl ScheduledTask {
1406    /// Create a test ScheduledTask with default progress tracking fields.
1407    /// Useful for unit tests that don't need progress data.
1408    #[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/// Resource assignment for a specific period
1446#[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    /// RFC-0001: Cost range for abstract profile assignments
1454    pub cost_range: Option<CostRange>,
1455    /// RFC-0001: Whether this is an abstract (profile) assignment
1456    pub is_abstract: bool,
1457    /// Explicit effort in person-days for this assignment.
1458    /// When set, this overrides the calculated effort (duration × units).
1459    /// Used when task has explicit `effort:` attribute different from duration.
1460    pub effort_days: Option<f64>,
1461}
1462
1463// ============================================================================
1464// Traits
1465// ============================================================================
1466
1467/// Core scheduling abstraction
1468pub trait Scheduler: Send + Sync {
1469    /// Compute a schedule for the given project
1470    fn schedule(&self, project: &Project) -> Result<Schedule, ScheduleError>;
1471
1472    /// Check if a schedule is feasible without computing it
1473    fn is_feasible(&self, project: &Project) -> FeasibilityResult;
1474
1475    /// Explain why a particular scheduling decision was made
1476    fn explain(&self, project: &Project, task: &TaskId) -> Explanation;
1477}
1478
1479/// What-if analysis capabilities (typically BDD-powered)
1480pub trait WhatIfAnalysis {
1481    /// Analyze impact of a constraint change
1482    fn what_if(&self, project: &Project, change: &Constraint) -> WhatIfReport;
1483
1484    /// Count valid schedules under current constraints
1485    fn count_solutions(&self, project: &Project) -> num_bigint::BigUint;
1486
1487    /// Find all critical constraints
1488    fn critical_constraints(&self, project: &Project) -> Vec<Constraint>;
1489}
1490
1491/// Output rendering
1492pub trait Renderer {
1493    type Output;
1494
1495    /// Render a schedule to the output format
1496    fn render(&self, project: &Project, schedule: &Schedule) -> Result<Self::Output, RenderError>;
1497}
1498
1499// ============================================================================
1500// Result Types
1501// ============================================================================
1502
1503/// Result of feasibility check
1504#[derive(Clone, Debug)]
1505pub struct FeasibilityResult {
1506    pub feasible: bool,
1507    pub conflicts: Vec<Conflict>,
1508    pub suggestions: Vec<Suggestion>,
1509}
1510
1511/// Scheduling conflict
1512#[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/// Suggestion for resolving issues
1529#[derive(Clone, Debug)]
1530pub struct Suggestion {
1531    pub description: String,
1532    pub impact: String,
1533}
1534
1535/// Type of effect a constraint has on scheduling
1536#[derive(Clone, Debug, PartialEq)]
1537pub enum ConstraintEffectType {
1538    /// Constraint pushed ES/EF forward (floor constraint active)
1539    PushedStart,
1540    /// Constraint capped LS/LF (ceiling constraint active)
1541    CappedLate,
1542    /// Constraint pinned task to specific date (MustStartOn/MustFinishOn)
1543    Pinned,
1544    /// Constraint was redundant (dependencies already more restrictive)
1545    Redundant,
1546}
1547
1548/// Effect of a temporal constraint on task scheduling
1549#[derive(Clone, Debug)]
1550pub struct ConstraintEffect {
1551    /// The constraint that was applied
1552    pub constraint: TaskConstraint,
1553    /// What effect the constraint had
1554    pub effect: ConstraintEffectType,
1555    /// Human-readable description of the effect
1556    pub description: String,
1557}
1558
1559/// Impact of calendar on task scheduling
1560#[derive(Clone, Debug, Default)]
1561pub struct CalendarImpact {
1562    /// Calendar ID used for this task
1563    pub calendar_id: CalendarId,
1564    /// Number of non-working days in task period
1565    pub non_working_days: u32,
1566    /// Number of weekend days in task period
1567    pub weekend_days: u32,
1568    /// Number of holidays in task period
1569    pub holiday_days: u32,
1570    /// Total calendar delay (days added due to non-working time)
1571    pub total_delay_days: i64,
1572    /// Human-readable impact description
1573    pub description: String,
1574}
1575
1576/// Explanation of a scheduling decision
1577#[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    /// Detailed effects of temporal constraints (Phase 4)
1584    pub constraint_effects: Vec<ConstraintEffect>,
1585    /// Calendar impact on task duration
1586    pub calendar_impact: Option<CalendarImpact>,
1587    /// Diagnostics relevant to this task's scheduling
1588    pub related_diagnostics: Vec<DiagnosticCode>,
1589}
1590
1591/// Constraint for what-if analysis
1592#[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/// Result of what-if analysis
1612#[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// ============================================================================
1623// Errors
1624// ============================================================================
1625
1626/// Scheduling error
1627#[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/// Rendering error
1652#[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// ============================================================================
1665// Diagnostics
1666// ============================================================================
1667
1668/// Diagnostic severity level
1669///
1670/// Severity determines how the diagnostic is treated by the CLI:
1671/// - Error: Always fatal, blocks completion
1672/// - Warning: Likely problem, becomes error in --strict mode
1673/// - Hint: Suggestion, becomes warning in --strict mode
1674/// - Info: Informational, unchanged in --strict mode
1675#[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    /// Returns the string prefix used in diagnostic output (e.g., "error", "warning")
1685    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/// Diagnostic code identifying the specific diagnostic type
1702///
1703/// Codes are stable identifiers used for:
1704/// - Machine-readable output (JSON)
1705/// - Documentation references
1706/// - Suppression/filtering
1707///
1708/// Naming convention: {Severity prefix}{Number}{Description}
1709#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1710pub enum DiagnosticCode {
1711    // Errors (E) - Cannot proceed
1712    /// Circular specialization in profile inheritance chain
1713    E001CircularSpecialization,
1714    /// Profile has no rate and is used in cost-bearing assignments
1715    E002ProfileWithoutRate,
1716    /// Task constraint cannot be satisfied (ES > LS)
1717    E003InfeasibleConstraint,
1718    /// Rate range is inverted (min > max) - RFC-0004 R102
1719    R102InvertedRateRange,
1720    /// Unknown profile referenced in specialization - RFC-0004 R104
1721    R104UnknownProfile,
1722
1723    // Calendar Errors (C001-C009)
1724    /// Calendar has no working hours defined
1725    C001ZeroWorkingHours,
1726    /// Calendar has no working days defined
1727    C002NoWorkingDays,
1728
1729    // Warnings (W) - Likely problem
1730    /// Task assigned to abstract profile instead of concrete resource
1731    W001AbstractAssignment,
1732    /// Task cost range spread exceeds threshold
1733    W002WideCostRange,
1734    /// Profile references undefined trait
1735    W003UnknownTrait,
1736    /// Trait multiplier stack exceeds 2.0 - RFC-0004 R012
1737    R012TraitMultiplierStack,
1738    /// Resource leveling could not fully resolve all conflicts
1739    W004ApproximateLeveling,
1740    /// Task constraint reduces slack to zero (now on critical path)
1741    W005ConstraintZeroSlack,
1742    /// Task is slipping beyond threshold (forecast > baseline)
1743    W006ScheduleVariance,
1744    /// Dependency references a task that does not exist
1745    W007UnresolvedDependency,
1746    /// Container has dependencies but child task has none (MS Project compatibility)
1747    W014ContainerDependency,
1748
1749    // Calendar Warnings (C010-C019)
1750    /// Task scheduled on non-working day
1751    C010NonWorkingDay,
1752    /// Task and assigned resource use different calendars
1753    C011CalendarMismatch,
1754
1755    // Hints (H) - Suggestions
1756    /// Task has both concrete and abstract assignments
1757    H001MixedAbstraction,
1758    /// Profile is defined but never assigned
1759    H002UnusedProfile,
1760    /// Trait is defined but never referenced
1761    H003UnusedTrait,
1762    /// Task has no predecessors or date constraints (dangling/orphan task)
1763    H004TaskUnconstrained,
1764
1765    // Calendar Hints (C020-C029)
1766    /// Calendar has low availability (< 40% working days)
1767    C020LowAvailability,
1768    /// Calendar is missing common holiday
1769    C021MissingCommonHoliday,
1770    /// Calendar has suspicious working hours (e.g., 24/7)
1771    C022SuspiciousHours,
1772    /// Holiday falls on non-working day (redundant)
1773    C023RedundantHoliday,
1774
1775    // Info (I) - Informational
1776    /// Project scheduling summary
1777    I001ProjectCostSummary,
1778    /// Refinement progress status
1779    I002RefinementProgress,
1780    /// Resource utilization summary
1781    I003ResourceUtilization,
1782    /// Project status summary (overall progress and variance)
1783    I004ProjectStatus,
1784    /// Earned value summary (EV, PV, SPI)
1785    I005EarnedValueSummary,
1786
1787    // Leveling (L) - Resource leveling diagnostics
1788    /// Resource overallocation resolved by delaying task
1789    L001OverallocationResolved,
1790    /// Unresolvable resource conflict (demand > capacity after leveling)
1791    L002UnresolvableConflict,
1792    /// Project duration increased due to leveling
1793    L003DurationIncreased,
1794    /// Milestone delayed due to leveling
1795    L004MilestoneDelayed,
1796
1797    // Progress (P) - Progress tracking diagnostics
1798    /// Explicit remaining conflicts with linear derivation from complete%
1799    P005RemainingCompleteConflict,
1800    /// Container's explicit progress conflicts with weighted children average
1801    P006ContainerProgressMismatch,
1802}
1803
1804impl DiagnosticCode {
1805    /// Returns the short code string (e.g., "E001", "W002")
1806    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    /// Returns the default severity for this diagnostic code
1849    pub fn default_severity(&self) -> Severity {
1850        match self {
1851            DiagnosticCode::E001CircularSpecialization => Severity::Error,
1852            DiagnosticCode::E002ProfileWithoutRate => Severity::Warning, // Error in strict mode
1853            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            // Leveling diagnostics (L001-L004)
1883            DiagnosticCode::L001OverallocationResolved => Severity::Hint,
1884            DiagnosticCode::L002UnresolvableConflict => Severity::Warning,
1885            DiagnosticCode::L003DurationIncreased => Severity::Hint,
1886            DiagnosticCode::L004MilestoneDelayed => Severity::Warning,
1887            // Progress diagnostics (P005-P006)
1888            DiagnosticCode::P005RemainingCompleteConflict => Severity::Warning,
1889            DiagnosticCode::P006ContainerProgressMismatch => Severity::Warning,
1890        }
1891    }
1892
1893    /// Returns the diagnostic ordering priority (lower = emitted first)
1894    ///
1895    /// Ordering: Errors → Cost warnings → Assignment warnings → Hints → Info
1896    pub fn ordering_priority(&self) -> u8 {
1897        match self {
1898            // Structural errors first
1899            DiagnosticCode::E001CircularSpecialization => 0,
1900            DiagnosticCode::E002ProfileWithoutRate => 1,
1901            DiagnosticCode::E003InfeasibleConstraint => 2,
1902            DiagnosticCode::R102InvertedRateRange => 3,
1903            DiagnosticCode::R104UnknownProfile => 4,
1904            // Calendar errors
1905            DiagnosticCode::C001ZeroWorkingHours => 5,
1906            DiagnosticCode::C002NoWorkingDays => 6,
1907            // Cost-related warnings
1908            DiagnosticCode::W002WideCostRange => 10,
1909            DiagnosticCode::R012TraitMultiplierStack => 11,
1910            DiagnosticCode::W004ApproximateLeveling => 12,
1911            // Constraint warnings
1912            DiagnosticCode::W005ConstraintZeroSlack => 12,
1913            // Schedule variance warnings
1914            DiagnosticCode::W006ScheduleVariance => 13,
1915            // Dependency warnings
1916            DiagnosticCode::W007UnresolvedDependency => 14,
1917            // MS Project compatibility warnings
1918            DiagnosticCode::W014ContainerDependency => 15,
1919            // Calendar warnings
1920            DiagnosticCode::C010NonWorkingDay => 15,
1921            DiagnosticCode::C011CalendarMismatch => 16,
1922            // Assignment-related warnings
1923            DiagnosticCode::W001AbstractAssignment => 20,
1924            DiagnosticCode::W003UnknownTrait => 21,
1925            // Hints
1926            DiagnosticCode::H001MixedAbstraction => 30,
1927            DiagnosticCode::H002UnusedProfile => 31,
1928            DiagnosticCode::H003UnusedTrait => 32,
1929            DiagnosticCode::H004TaskUnconstrained => 33,
1930            // Calendar hints
1931            DiagnosticCode::C020LowAvailability => 34,
1932            DiagnosticCode::C021MissingCommonHoliday => 35,
1933            DiagnosticCode::C022SuspiciousHours => 36,
1934            DiagnosticCode::C023RedundantHoliday => 37,
1935            // Info last
1936            DiagnosticCode::I001ProjectCostSummary => 40,
1937            DiagnosticCode::I002RefinementProgress => 41,
1938            DiagnosticCode::I003ResourceUtilization => 42,
1939            DiagnosticCode::I004ProjectStatus => 43,
1940            DiagnosticCode::I005EarnedValueSummary => 44,
1941            // Leveling diagnostics (after info, grouped together)
1942            DiagnosticCode::L001OverallocationResolved => 50,
1943            DiagnosticCode::L002UnresolvableConflict => 51,
1944            DiagnosticCode::L003DurationIncreased => 52,
1945            DiagnosticCode::L004MilestoneDelayed => 53,
1946            // Progress diagnostics (grouped with schedule variance)
1947            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/// Source location span for diagnostic highlighting
1960#[derive(Debug, Clone, PartialEq, Eq)]
1961pub struct SourceSpan {
1962    /// Line number (1-based)
1963    pub line: usize,
1964    /// Column number (1-based)
1965    pub column: usize,
1966    /// Length of the span in characters
1967    pub length: usize,
1968    /// Optional label for this span
1969    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/// A diagnostic message emitted during analysis or scheduling
1989///
1990/// Diagnostics are structured data representing errors, warnings, hints,
1991/// or informational messages. The message text is fully rendered at
1992/// emission time according to the specification in DIAGNOSTICS.md.
1993///
1994/// # Example
1995///
1996/// ```
1997/// use utf8proj_core::{Diagnostic, DiagnosticCode, Severity};
1998///
1999/// let diagnostic = Diagnostic::new(
2000///     DiagnosticCode::W001AbstractAssignment,
2001///     "task 'api_dev' is assigned to abstract profile 'developer'"
2002/// );
2003///
2004/// assert_eq!(diagnostic.severity, Severity::Warning);
2005/// assert_eq!(diagnostic.code.as_str(), "W001");
2006/// ```
2007#[derive(Debug, Clone)]
2008pub struct Diagnostic {
2009    /// The diagnostic code
2010    pub code: DiagnosticCode,
2011    /// Severity level (derived from code by default)
2012    pub severity: Severity,
2013    /// The primary message (fully rendered)
2014    pub message: String,
2015    /// Source file path (if applicable)
2016    pub file: Option<std::path::PathBuf>,
2017    /// Primary source span (if applicable)
2018    pub span: Option<SourceSpan>,
2019    /// Additional spans for related locations
2020    pub secondary_spans: Vec<SourceSpan>,
2021    /// Additional notes (displayed after the main message)
2022    pub notes: Vec<String>,
2023    /// Hints for fixing the issue
2024    pub hints: Vec<String>,
2025}
2026
2027impl Diagnostic {
2028    /// Create a new diagnostic with the given code and message
2029    ///
2030    /// Severity is derived from the diagnostic code's default.
2031    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    /// Create an error diagnostic
2045    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    /// Create a warning diagnostic
2059    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    /// Set the source file
2073    pub fn with_file(mut self, file: impl Into<std::path::PathBuf>) -> Self {
2074        self.file = Some(file.into());
2075        self
2076    }
2077
2078    /// Set the primary source span
2079    pub fn with_span(mut self, span: SourceSpan) -> Self {
2080        self.span = Some(span);
2081        self
2082    }
2083
2084    /// Add a secondary span
2085    pub fn with_secondary_span(mut self, span: SourceSpan) -> Self {
2086        self.secondary_spans.push(span);
2087        self
2088    }
2089
2090    /// Add a note
2091    pub fn with_note(mut self, note: impl Into<String>) -> Self {
2092        self.notes.push(note.into());
2093        self
2094    }
2095
2096    /// Add a hint
2097    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
2098        self.hints.push(hint.into());
2099        self
2100    }
2101
2102    /// Returns true if this is an error-level diagnostic
2103    pub fn is_error(&self) -> bool {
2104        self.severity == Severity::Error
2105    }
2106
2107    /// Returns true if this is a warning-level diagnostic
2108    pub fn is_warning(&self) -> bool {
2109        self.severity == Severity::Warning
2110    }
2111}
2112
2113/// Trait for receiving diagnostic messages
2114///
2115/// Implementations handle formatting and output of diagnostics.
2116/// The solver and analyzer emit diagnostics through this trait,
2117/// allowing different backends (CLI, LSP, tests) to handle them.
2118///
2119/// # Example
2120///
2121/// ```
2122/// use utf8proj_core::{Diagnostic, DiagnosticEmitter};
2123///
2124/// struct CollectingEmitter {
2125///     diagnostics: Vec<Diagnostic>,
2126/// }
2127///
2128/// impl DiagnosticEmitter for CollectingEmitter {
2129///     fn emit(&mut self, diagnostic: Diagnostic) {
2130///         self.diagnostics.push(diagnostic);
2131///     }
2132/// }
2133/// ```
2134pub trait DiagnosticEmitter {
2135    /// Emit a diagnostic
2136    fn emit(&mut self, diagnostic: Diagnostic);
2137}
2138
2139/// A simple diagnostic emitter that collects diagnostics into a Vec
2140///
2141/// Useful for testing and batch processing.
2142#[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    /// Returns true if any errors were emitted
2153    pub fn has_errors(&self) -> bool {
2154        self.diagnostics.iter().any(|d| d.is_error())
2155    }
2156
2157    /// Returns the count of errors
2158    pub fn error_count(&self) -> usize {
2159        self.diagnostics.iter().filter(|d| d.is_error()).count()
2160    }
2161
2162    /// Returns the count of warnings
2163    pub fn warning_count(&self) -> usize {
2164        self.diagnostics.iter().filter(|d| d.is_warning()).count()
2165    }
2166
2167    /// Returns diagnostics sorted by ordering priority, then by source location
2168    pub fn sorted(&self) -> Vec<&Diagnostic> {
2169        let mut sorted: Vec<_> = self.diagnostics.iter().collect();
2170        sorted.sort_by(|a, b| {
2171            // First by ordering priority
2172            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            // Then by file
2177            let file_cmp = a.file.cmp(&b.file);
2178            if file_cmp != std::cmp::Ordering::Equal {
2179                return file_cmp;
2180            }
2181            // Then by line
2182            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            // Then by column
2189            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// ============================================================================
2204// Tests
2205// ============================================================================
2206
2207#[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        // Monday
2241        let monday = NaiveDate::from_ymd_opt(2025, 2, 3).unwrap();
2242        assert!(cal.is_working_day(monday));
2243
2244        // Saturday
2245        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        // Test minutes constructor
2284        let d_min = Duration::minutes(120);
2285        assert_eq!(d_min.minutes, 120);
2286        assert_eq!(d_min.as_hours(), 2.0);
2287
2288        // Test hours constructor
2289        let d_hours = Duration::hours(3);
2290        assert_eq!(d_hours.minutes, 180);
2291        assert_eq!(d_hours.as_hours(), 3.0);
2292
2293        // Test weeks constructor (5 days * 8 hours)
2294        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        // Find top-level task
2330        let standalone = project.get_task("standalone");
2331        assert!(standalone.is_some());
2332        assert_eq!(standalone.unwrap().name, "Standalone");
2333
2334        // Find nested task (depth 1)
2335        let child1 = project.get_task("child1");
2336        assert!(child1.is_some());
2337        assert_eq!(child1.unwrap().name, "Child 1");
2338
2339        // Find deeply nested task (depth 2)
2340        let grandchild = project.get_task("grandchild");
2341        assert!(grandchild.is_some());
2342        assert_eq!(grandchild.unwrap().name, "Grandchild");
2343
2344        // Non-existent task
2345        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); // Default assignment
2405        assert_eq!(task.assigned[1].units, 0.5); // Partial assignment
2406        assert_eq!(task.assigned[2].units, 0.25); // Quarter assignment
2407    }
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        // Default: 9:00-12:00 (3h) + 13:00-17:00 (4h) = 7 hours
2420        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, // 9:00 AM
2427            end: 17 * 60,  // 5:00 PM
2428        };
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        // Before holiday
2447        assert!(!holiday.contains(NaiveDate::from_ymd_opt(2025, 12, 23).unwrap()));
2448
2449        // First day of holiday
2450        assert!(holiday.contains(NaiveDate::from_ymd_opt(2025, 12, 24).unwrap()));
2451
2452        // Middle of holiday
2453        assert!(holiday.contains(NaiveDate::from_ymd_opt(2025, 12, 25).unwrap()));
2454
2455        // Last day of holiday
2456        assert!(holiday.contains(NaiveDate::from_ymd_opt(2025, 12, 26).unwrap()));
2457
2458        // After holiday
2459        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    // ========================================================================
2516    // Progress Tracking Tests
2517    // ========================================================================
2518
2519    #[test]
2520    fn remaining_duration_linear_interpolation() {
2521        // 10-day task at 60% complete → 4 days remaining
2522        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        // Clamp above 100
2565        let task = Task::new("task").complete(150.0);
2566        assert_eq!(task.effective_percent_complete(), 100);
2567
2568        // Clamp below 0
2569        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        // Even with 100% complete, explicit status takes precedence
2606        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    // ========================================================================
2641    // Container Progress Tests
2642    // ========================================================================
2643
2644    #[test]
2645    fn container_progress_weighted_average() {
2646        // Container with 3 children of different durations and progress
2647        // Backend: 20d @ 60%, Frontend: 15d @ 30%, Testing: 10d @ 0%
2648        // Expected: (20*60 + 15*30 + 10*0) / (20+15+10) = 1650/45 = 36.67 ≈ 37%
2649        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        // Nested structure:
2694        // project
2695        // ├── phase1 (container)
2696        // │   ├── task_a: 10d @ 100%
2697        // │   └── task_b: 10d @ 50%
2698        // └── phase2 (container)
2699        //     └── task_c: 20d @ 25%
2700        //
2701        // Phase1: (10*100 + 10*50) / 20 = 75%
2702        // Total: (10*100 + 10*50 + 20*25) / 40 = 2000/40 = 50%
2703        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        // Check nested container progress
2726        let phase1 = &project.children[0];
2727        assert_eq!(phase1.container_progress(), Some(75));
2728
2729        // Check top-level container progress (flattens all leaves)
2730        assert_eq!(project.container_progress(), Some(50));
2731    }
2732
2733    #[test]
2734    fn container_progress_effective_with_override() {
2735        // Container with explicit progress set (manual override)
2736        let container = Task::new("dev")
2737            .complete(80.0) // Manual override
2738            .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        // Derived would be 50%, but manual override is 80%
2742        assert_eq!(container.container_progress(), Some(50));
2743        assert_eq!(container.effective_progress(), 80); // Uses override
2744    }
2745
2746    #[test]
2747    fn container_progress_mismatch_detection() {
2748        let container = Task::new("dev")
2749            .complete(80.0) // Claims 80%
2750            .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        // Derived is 30%, claimed is 80% - 50% mismatch
2754        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        // No mismatch if threshold is high
2761        assert!(container.progress_mismatch(60).is_none());
2762    }
2763
2764    #[test]
2765    fn container_progress_uses_effort_fallback() {
2766        // When duration not set, should use effort
2767        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        // Container with children that have no duration/effort returns None
2777        let container = Task::new("dev")
2778            .child(Task::new("a").complete(50.0)) // No duration
2779            .child(Task::new("b").complete(100.0)); // No duration
2780
2781        assert_eq!(container.container_progress(), None);
2782    }
2783
2784    #[test]
2785    fn effective_progress_container_no_override() {
2786        // Container without manual override uses derived progress
2787        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        // No complete() set on container, so it derives from children
2792        assert_eq!(container.effective_progress(), 50);
2793    }
2794
2795    #[test]
2796    fn effective_progress_leaf_no_complete() {
2797        // Leaf task with no complete set returns 0
2798        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        // progress_mismatch on a leaf task returns None
2805        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        // Jan 1 is a Wednesday (working day) but is a holiday
2843        let jan1 = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
2844        assert!(!cal.is_working_day(jan1));
2845
2846        // Jan 2 is Thursday, should be working
2847        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    // ========================================================================
2874    // RFC-0001: Progressive Resource Refinement Tests
2875    // ========================================================================
2876
2877    #[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        // Range 500-700, expected 600, spread = 200, spread% = (200/600)*100 = 33.33%
2906        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        // Profile with no rate is abstract
2999        let no_rate = ResourceProfile::new("dev");
3000        assert!(no_rate.is_abstract());
3001
3002        // Profile with range is abstract
3003        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        // Profile with fixed rate is concrete
3008        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        // Cost range $40,000 - $60,000, expected $50,000
3035        // Half spread = $10,000, spread% = (10000/50000)*100 = 20%
3036        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        // New fields should be initialized
3163        assert!(project.profiles.is_empty());
3164        assert!(project.traits.is_empty());
3165        assert_eq!(project.cost_policy, CostPolicy::Midpoint);
3166    }
3167
3168    // ========================================================================
3169    // Diagnostic Tests
3170    // ========================================================================
3171
3172    #[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        // Errors come before warnings
3217        assert!(
3218            DiagnosticCode::E001CircularSpecialization.ordering_priority()
3219                < DiagnosticCode::W001AbstractAssignment.ordering_priority()
3220        );
3221        // Warnings come before hints
3222        assert!(
3223            DiagnosticCode::W001AbstractAssignment.ordering_priority()
3224                < DiagnosticCode::H001MixedAbstraction.ordering_priority()
3225        );
3226        // Hints come before info
3227        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        // Emit in wrong order
3304        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        // Should be: Error, Warning, Hint, Info
3324        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        // Same code, different locations
3335        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        // Test all diagnostic codes have correct string representation
3355        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        // Errors
3375        assert_eq!(
3376            DiagnosticCode::E003InfeasibleConstraint.default_severity(),
3377            Severity::Error
3378        );
3379        // Warnings (W002 onwards - E002 is warning by default, error in strict)
3380        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        // Hints
3397        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        // Info
3410        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        // Errors have lowest priority (emitted first)
3431        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        // Cost warnings
3436        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        // Assignment warnings
3459        assert_eq!(DiagnosticCode::W003UnknownTrait.ordering_priority(), 21);
3460        // Hints
3461        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        // Info (highest priority = emitted last)
3468        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        // Test Display trait implementation
3486        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        // When min == max == 0, spread should be 0% (not NaN or error)
3501        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        // When expected is zero, spread should be 0% (not NaN or error)
3509        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    // =========================================================================
3578    // Scheduling Mode Tests
3579    // =========================================================================
3580
3581    #[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
3639// ============================================================================
3640// Classifier Module (RFC-0011)
3641// ============================================================================
3642
3643/// Classifiers discretize continuous task attributes into categorical labels.
3644///
3645/// This abstraction enables multiple view patterns (Kanban, risk matrix, budget
3646/// tracking) through a single interface. Classifiers are read-only - they observe
3647/// state but never mutate it.
3648///
3649/// # Example
3650///
3651/// ```rust
3652/// use utf8proj_core::{Classifier, StatusClassifier, Task, Schedule};
3653///
3654/// let classifier = StatusClassifier;
3655/// assert_eq!(classifier.name(), "Progress Status");
3656/// ```
3657pub trait Classifier: Send + Sync {
3658    /// Human-readable name for this classification scheme
3659    fn name(&self) -> &'static str;
3660
3661    /// Classify a task into a category with ordering.
3662    ///
3663    /// Returns `(label, order)` where:
3664    /// - `label`: The category name (e.g., "Doing", "Critical")
3665    /// - `order`: Sort position (lower = earlier in output)
3666    fn classify(&self, task: &Task, schedule: &Schedule) -> (String, usize);
3667}
3668
3669/// Classifies tasks by progress percentage into workflow stages.
3670///
3671/// # Categories
3672///
3673/// | Range | Label | Order |
3674/// |-------|-------|-------|
3675/// | 0% | Backlog | 0 |
3676/// | 1-25% | Ready | 1 |
3677/// | 26-75% | Doing | 2 |
3678/// | 76-99% | Review | 3 |
3679/// | 100% | Done | 4 |
3680/// | >100% | Invalid | 5 |
3681#[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
3703/// Groups tasks by classifier categories.
3704///
3705/// Returns a vector of `(category_label, tasks)` tuples, sorted by the
3706/// classifier's natural order. Empty categories are not included.
3707///
3708/// # Arguments
3709///
3710/// * `project` - The project containing tasks to classify
3711/// * `schedule` - The computed schedule (may be used by some classifiers)
3712/// * `classifier` - The classification scheme to apply
3713///
3714/// # Example
3715///
3716/// ```rust,ignore
3717/// use utf8proj_core::{group_by, StatusClassifier};
3718///
3719/// let groups = group_by(&project, &schedule, &StatusClassifier);
3720/// for (category, tasks) in groups {
3721///     println!("{}: {} tasks", category, tasks.len());
3722/// }
3723/// ```
3724pub 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    // Convert to vec and sort by classifier's natural order
3741    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    /// Create an empty schedule for testing
3758    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        // Verify the trait compiles and can be implemented
3779        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        // Default task has no complete % set, should be 0%
3805        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        // 26% should be Doing
3846        let task = Task::new("test").complete(26.0);
3847        let (label, _) = classifier.classify(&task, &schedule);
3848        assert_eq!(label, "Doing");
3849
3850        // 75% should be Doing
3851        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        // 76% should be Review
3872        let task = Task::new("test").complete(76.0);
3873        let (label, _) = classifier.classify(&task, &schedule);
3874        assert_eq!(label, "Review");
3875
3876        // 99% should be Review
3877        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        // Verify ordering: Backlog (0) < Doing (2) < Done (4)
3956        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        // Add tasks in reverse order to ensure sorting works
3965        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        // Verify correct order regardless of input order
3977        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        // Test that users can define their own classifiers
3999        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}