utf8proj_solver/
lib.rs

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