Skip to main content

cpm_planner/
task.rs

1//! CPM Task data structures
2//!
3//! Defines the core types for Critical Path Management:
4//! - [`Task`]: A unit of work to be scheduled
5//! - [`TaskKind`]: Domain-neutral classification of work
6//! - [`TaskBatch`]: A group of tasks that can run in parallel
7//! - [`Bottleneck`]: A task that blocks significant downstream work
8//! - [`CriticalPathResult`]: The full CPM analysis output
9//!
10//! These types are the *algorithm's internal* data model. The wire model
11//! that crosses the [`crate::ports::Planner`] boundary lives in
12//! `crate::plan`. PA3 will provide the bridge.
13
14use serde::{Deserialize, Serialize};
15
16/// Kind of task that can be planned.
17///
18/// Variants are deliberately domain-neutral. Specialised variants for any
19/// given problem domain belong outside this crate.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "type", rename_all = "snake_case")]
22pub enum TaskKind {
23    /// Break a circular dependency cycle in the task graph.
24    ///
25    /// This is a generic graph operation, not coupled to any particular
26    /// problem domain.
27    BreakCycle {
28        /// Module identifiers (or generally, node ids) forming the cycle.
29        cycle: Vec<String>,
30    },
31    /// Implement a spec requirement.
32    ImplementSpec {
33        /// Opaque identifier of the specification to implement.
34        spec_id: String,
35    },
36    /// Custom user-defined task.
37    Custom {
38        /// Free-form description of the work.
39        description: String,
40    },
41}
42
43impl std::fmt::Display for TaskKind {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::BreakCycle { .. } => write!(f, "CYCLE"),
47            Self::ImplementSpec { spec_id, .. } => write!(f, "SPEC-{spec_id}"),
48            Self::Custom { .. } => write!(f, "CUSTOM"),
49        }
50    }
51}
52
53/// Status of a task in the execution plan.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum TaskStatus {
57    /// Not started, dependencies may not be met.
58    #[default]
59    Pending,
60    /// Dependencies met, can start.
61    Ready,
62    /// Currently being worked on.
63    InProgress,
64    /// Successfully completed.
65    Completed,
66    /// Has unmet dependencies.
67    Blocked,
68}
69
70impl std::fmt::Display for TaskStatus {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::Pending => write!(f, "pending"),
74            Self::Ready => write!(f, "ready"),
75            Self::InProgress => write!(f, "in_progress"),
76            Self::Completed => write!(f, "completed"),
77            Self::Blocked => write!(f, "blocked"),
78        }
79    }
80}
81
82/// A single task in the CPM plan.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct Task {
85    /// Unique identifier.
86    pub id: String,
87    /// Human-readable name.
88    pub name: String,
89    /// Type of task.
90    pub kind: TaskKind,
91    /// Estimated effort in hours.
92    pub effort_hours: f32,
93    /// Task IDs this depends on (must complete before this can start).
94    pub dependencies: Vec<String>,
95    /// Current status.
96    pub status: TaskStatus,
97    /// Earliest start time (calculated by forward pass).
98    pub earliest_start: f32,
99    /// Earliest finish time (ES + effort).
100    pub earliest_finish: f32,
101    /// Latest start time (calculated by backward pass).
102    pub latest_start: f32,
103    /// Latest finish time.
104    pub latest_finish: f32,
105    /// Float/slack time (LS - ES).
106    pub float: f32,
107    /// Is this task on the critical path?
108    pub is_critical: bool,
109    /// Files affected by this task (for context building, domain-neutral).
110    pub affected_files: Vec<String>,
111}
112
113impl Default for Task {
114    fn default() -> Self {
115        Self {
116            id: String::new(),
117            name: String::new(),
118            kind: TaskKind::Custom {
119                description: String::new(),
120            },
121            effort_hours: 0.0,
122            dependencies: Vec::new(),
123            status: TaskStatus::Pending,
124            earliest_start: 0.0,
125            earliest_finish: 0.0,
126            latest_start: 0.0,
127            latest_finish: 0.0,
128            float: 0.0,
129            is_critical: false,
130            affected_files: Vec::new(),
131        }
132    }
133}
134
135impl Task {
136    /// Create a new task with the given ID, name, kind, and effort.
137    pub fn new(
138        id: impl Into<String>,
139        name: impl Into<String>,
140        kind: TaskKind,
141        effort_hours: f32,
142    ) -> Self {
143        Self {
144            id: id.into(),
145            name: name.into(),
146            kind,
147            effort_hours,
148            ..Default::default()
149        }
150    }
151
152    /// Add a dependency to this task.
153    #[must_use]
154    pub fn depends_on(mut self, task_id: impl Into<String>) -> Self {
155        self.dependencies.push(task_id.into());
156        self
157    }
158
159    /// Add an affected file.
160    #[must_use]
161    pub fn affects_file(mut self, file: impl Into<String>) -> Self {
162        self.affected_files.push(file.into());
163        self
164    }
165}
166
167/// A batch of tasks that can run in parallel.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct TaskBatch {
170    /// Batch identifier (e.g., "Batch-1").
171    pub id: String,
172    /// Tasks in this batch (all have same ES).
173    pub tasks: Vec<String>,
174    /// Total effort if done sequentially.
175    pub total_effort_hours: f32,
176    /// Actual duration (max of task efforts in batch).
177    pub duration_hours: f32,
178    /// Earliest start time for this batch.
179    pub start_time: f32,
180}
181
182impl Default for TaskBatch {
183    fn default() -> Self {
184        Self {
185            id: String::new(),
186            tasks: Vec::new(),
187            total_effort_hours: 0.0,
188            duration_hours: 0.0,
189            start_time: 0.0,
190        }
191    }
192}
193
194/// A bottleneck task that blocks significant downstream work.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct Bottleneck {
197    /// The bottleneck task ID.
198    pub task_id: String,
199    /// Task name for display.
200    pub task_name: String,
201    /// Number of tasks blocked by this one (directly or transitively).
202    pub blocks_count: usize,
203    /// Total hours of work blocked.
204    pub blocked_hours: f32,
205    /// ROI: `blocked_hours / task_effort` (higher = higher priority).
206    pub roi: f32,
207    /// Effort to complete this task.
208    pub effort_hours: f32,
209}
210
211/// Critical Path Analysis Results.
212#[derive(Debug, Clone, Default, Serialize, Deserialize)]
213pub struct CriticalPathResult {
214    /// Total number of tasks in the plan.
215    pub total_tasks: usize,
216    /// Tasks on the critical path (ordered by execution sequence).
217    pub critical_path: Vec<String>,
218    /// Duration of critical path in hours.
219    pub critical_path_duration: f32,
220    /// Total duration if done sequentially.
221    pub total_duration_sequential: f32,
222    /// Optimal duration with parallelization.
223    pub optimal_duration_parallel: f32,
224    /// Speedup factor (sequential / parallel).
225    pub speedup_factor: f32,
226    /// Batches for parallel execution.
227    pub parallelizable_batches: Vec<TaskBatch>,
228    /// Bottleneck tasks (sorted by ROI descending).
229    pub bottlenecks: Vec<Bottleneck>,
230    /// All tasks with calculated times.
231    pub tasks: Vec<Task>,
232    /// Task ids the forward pass could not schedule. Non-empty iff the
233    /// dependency graph is cyclic or otherwise unschedulable; in that case
234    /// every other field is confidently-wrong and must not be trusted.
235    /// Callers that do not pre-validate (e.g. direct
236    /// [`CpmAlgorithm::calculate`][crate::algorithm::CpmAlgorithm::calculate]
237    /// users) MUST check this is empty before using the result.
238    pub unscheduled: Vec<String>,
239}
240
241impl CriticalPathResult {
242    /// Get a task by ID.
243    #[must_use]
244    pub fn get_task(&self, id: &str) -> Option<&Task> {
245        self.tasks.iter().find(|t| t.id == id)
246    }
247}
248
249#[cfg(test)]
250#[allow(clippy::float_cmp)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_task_creation() {
256        let task = Task::new(
257            "SPEC-001",
258            "Implement login flow",
259            TaskKind::ImplementSpec {
260                spec_id: "auth.login".to_string(),
261            },
262            2.0,
263        )
264        .depends_on("SPEC-000")
265        .affects_file("src/auth/login.rs")
266        .affects_file("src/auth/mod.rs");
267
268        assert_eq!(task.id, "SPEC-001");
269        assert_eq!(task.effort_hours, 2.0);
270        assert_eq!(task.dependencies, vec!["SPEC-000"]);
271        assert_eq!(
272            task.affected_files,
273            vec!["src/auth/login.rs", "src/auth/mod.rs"]
274        );
275    }
276
277    #[test]
278    fn test_task_kind_display() {
279        assert_eq!(
280            TaskKind::ImplementSpec {
281                spec_id: "auth.login".to_string()
282            }
283            .to_string(),
284            "SPEC-auth.login"
285        );
286
287        assert_eq!(
288            TaskKind::BreakCycle {
289                cycle: vec!["a".to_string(), "b".to_string()]
290            }
291            .to_string(),
292            "CYCLE"
293        );
294
295        assert_eq!(
296            TaskKind::Custom {
297                description: "anything".to_string()
298            }
299            .to_string(),
300            "CUSTOM"
301        );
302    }
303
304    #[test]
305    fn test_get_task() {
306        let result = CriticalPathResult {
307            total_tasks: 2,
308            tasks: vec![
309                Task {
310                    id: "T1".to_string(),
311                    effort_hours: 5.0,
312                    ..Default::default()
313                },
314                Task {
315                    id: "T2".to_string(),
316                    effort_hours: 3.0,
317                    ..Default::default()
318                },
319            ],
320            ..Default::default()
321        };
322
323        assert_eq!(result.get_task("T2").expect("T2 present").effort_hours, 3.0);
324        assert!(result.get_task("missing").is_none());
325    }
326}