scud/models/
phase.rs

1use super::task::Task;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct Phase {
6    pub name: String,
7    pub tasks: Vec<Task>,
8}
9
10impl Phase {
11    pub fn new(name: String) -> Self {
12        Phase {
13            name,
14            tasks: Vec::new(),
15        }
16    }
17
18    pub fn add_task(&mut self, task: Task) {
19        self.tasks.push(task);
20    }
21
22    pub fn get_task(&self, task_id: &str) -> Option<&Task> {
23        self.tasks.iter().find(|t| t.id == task_id)
24    }
25
26    pub fn get_task_mut(&mut self, task_id: &str) -> Option<&mut Task> {
27        self.tasks.iter_mut().find(|t| t.id == task_id)
28    }
29
30    pub fn remove_task(&mut self, task_id: &str) -> Option<Task> {
31        self.tasks
32            .iter()
33            .position(|t| t.id == task_id)
34            .map(|idx| self.tasks.remove(idx))
35    }
36
37    pub fn get_stats(&self) -> PhaseStats {
38        let mut total = 0;
39        let mut pending = 0;
40        let mut in_progress = 0;
41        let mut done = 0;
42        let mut blocked = 0;
43        let mut expanded = 0;
44        let mut total_complexity = 0;
45
46        for task in &self.tasks {
47            // Don't count subtasks in total (they're part of parent)
48            if task.is_subtask() {
49                continue;
50            }
51
52            total += 1;
53
54            // Only count complexity for non-expanded tasks
55            // (expanded tasks have their work represented by subtasks)
56            if !task.is_expanded() {
57                total_complexity += task.complexity;
58            }
59
60            match task.status {
61                super::task::TaskStatus::Pending => pending += 1,
62                super::task::TaskStatus::InProgress => in_progress += 1,
63                super::task::TaskStatus::Done => done += 1,
64                super::task::TaskStatus::Blocked => blocked += 1,
65                super::task::TaskStatus::Expanded => expanded += 1,
66                _ => {}
67            }
68        }
69
70        PhaseStats {
71            total,
72            pending,
73            in_progress,
74            done,
75            blocked,
76            expanded,
77            total_complexity,
78        }
79    }
80
81    /// Get actionable tasks (not expanded, not subtasks of incomplete parents)
82    pub fn get_actionable_tasks(&self) -> Vec<&Task> {
83        self.tasks
84            .iter()
85            .filter(|t| {
86                // Exclude expanded parents (work on subtasks instead)
87                if t.is_expanded() {
88                    return false;
89                }
90                // Include subtasks only if they're actionable
91                if let Some(ref parent_id) = t.parent_id {
92                    // Parent must be expanded
93                    self.get_task(parent_id)
94                        .map(|p| p.is_expanded())
95                        .unwrap_or(false)
96                } else {
97                    // Top-level task
98                    true
99                }
100            })
101            .collect()
102    }
103
104    /// Find next task using only local phase tasks for dependency checking.
105    /// For cross-tag dependency support, use find_next_task_cross_tag instead.
106    pub fn find_next_task(&self) -> Option<&Task> {
107        self.tasks.iter().find(|task| {
108            task.status == super::task::TaskStatus::Pending
109                && task.has_dependencies_met(&self.tasks)
110        })
111    }
112
113    /// Find next task with cross-tag dependency support.
114    /// Pass flattened tasks from all phases for proper dependency resolution.
115    pub fn find_next_task_cross_tag<'a>(&'a self, all_tasks: &[&Task]) -> Option<&'a Task> {
116        self.tasks.iter().find(|task| {
117            task.status == super::task::TaskStatus::Pending
118                && task.has_dependencies_met_refs(all_tasks)
119        })
120    }
121
122    pub fn get_tasks_needing_expansion(&self) -> Vec<&Task> {
123        self.tasks.iter().filter(|t| t.needs_expansion()).collect()
124    }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct PhaseStats {
129    pub total: usize,
130    pub pending: usize,
131    pub in_progress: usize,
132    pub done: usize,
133    pub blocked: usize,
134    pub expanded: usize,
135    pub total_complexity: u32,
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::models::task::{Task, TaskStatus};
142
143    #[test]
144    fn test_phase_creation() {
145        let phase = Phase::new("phase-1-auth".to_string());
146
147        assert_eq!(phase.name, "phase-1-auth");
148        assert!(phase.tasks.is_empty());
149    }
150
151    #[test]
152    fn test_add_task() {
153        let mut phase = Phase::new("phase-1".to_string());
154        let task = Task::new(
155            "TASK-1".to_string(),
156            "Test Task".to_string(),
157            "Description".to_string(),
158        );
159
160        phase.add_task(task.clone());
161
162        assert_eq!(phase.tasks.len(), 1);
163        assert_eq!(phase.tasks[0].id, "TASK-1");
164    }
165
166    #[test]
167    fn test_get_task() {
168        let mut phase = Phase::new("phase-1".to_string());
169        let task = Task::new(
170            "TASK-1".to_string(),
171            "Test Task".to_string(),
172            "Description".to_string(),
173        );
174        phase.add_task(task);
175
176        let retrieved = phase.get_task("TASK-1");
177        assert!(retrieved.is_some());
178        assert_eq!(retrieved.unwrap().id, "TASK-1");
179
180        let missing = phase.get_task("TASK-99");
181        assert!(missing.is_none());
182    }
183
184    #[test]
185    fn test_get_task_mut() {
186        let mut phase = Phase::new("phase-1".to_string());
187        let task = Task::new(
188            "TASK-1".to_string(),
189            "Test Task".to_string(),
190            "Description".to_string(),
191        );
192        phase.add_task(task);
193
194        {
195            let task_mut = phase.get_task_mut("TASK-1").unwrap();
196            task_mut.set_status(TaskStatus::InProgress);
197        }
198
199        assert_eq!(
200            phase.get_task("TASK-1").unwrap().status,
201            TaskStatus::InProgress
202        );
203    }
204
205    #[test]
206    fn test_remove_task() {
207        let mut phase = Phase::new("phase-1".to_string());
208        let task1 = Task::new(
209            "TASK-1".to_string(),
210            "Task 1".to_string(),
211            "Desc".to_string(),
212        );
213        let task2 = Task::new(
214            "TASK-2".to_string(),
215            "Task 2".to_string(),
216            "Desc".to_string(),
217        );
218        phase.add_task(task1);
219        phase.add_task(task2);
220
221        let removed = phase.remove_task("TASK-1");
222        assert!(removed.is_some());
223        assert_eq!(removed.unwrap().id, "TASK-1");
224        assert_eq!(phase.tasks.len(), 1);
225        assert_eq!(phase.tasks[0].id, "TASK-2");
226
227        let missing = phase.remove_task("TASK-99");
228        assert!(missing.is_none());
229    }
230
231    #[test]
232    fn test_get_stats_empty_phase() {
233        let phase = Phase::new("phase-1".to_string());
234        let stats = phase.get_stats();
235
236        assert_eq!(stats.total, 0);
237        assert_eq!(stats.pending, 0);
238        assert_eq!(stats.in_progress, 0);
239        assert_eq!(stats.done, 0);
240        assert_eq!(stats.blocked, 0);
241        assert_eq!(stats.total_complexity, 0);
242    }
243
244    #[test]
245    fn test_get_stats_with_tasks() {
246        let mut phase = Phase::new("phase-1".to_string());
247
248        let mut task1 = Task::new(
249            "TASK-1".to_string(),
250            "Task 1".to_string(),
251            "Desc".to_string(),
252        );
253        task1.complexity = 3;
254        task1.set_status(TaskStatus::Done);
255
256        let mut task2 = Task::new(
257            "TASK-2".to_string(),
258            "Task 2".to_string(),
259            "Desc".to_string(),
260        );
261        task2.complexity = 5;
262        task2.set_status(TaskStatus::InProgress);
263
264        let mut task3 = Task::new(
265            "TASK-3".to_string(),
266            "Task 3".to_string(),
267            "Desc".to_string(),
268        );
269        task3.complexity = 8;
270        // Pending by default
271
272        let mut task4 = Task::new(
273            "TASK-4".to_string(),
274            "Task 4".to_string(),
275            "Desc".to_string(),
276        );
277        task4.complexity = 2;
278        task4.set_status(TaskStatus::Blocked);
279
280        phase.add_task(task1);
281        phase.add_task(task2);
282        phase.add_task(task3);
283        phase.add_task(task4);
284
285        let stats = phase.get_stats();
286
287        assert_eq!(stats.total, 4);
288        assert_eq!(stats.pending, 1);
289        assert_eq!(stats.in_progress, 1);
290        assert_eq!(stats.done, 1);
291        assert_eq!(stats.blocked, 1);
292        assert_eq!(stats.total_complexity, 18); // 3 + 5 + 8 + 2
293    }
294
295    #[test]
296    fn test_find_next_task_no_dependencies() {
297        let mut phase = Phase::new("phase-1".to_string());
298
299        let mut task1 = Task::new(
300            "TASK-1".to_string(),
301            "Task 1".to_string(),
302            "Desc".to_string(),
303        );
304        task1.set_status(TaskStatus::Done);
305
306        let task2 = Task::new(
307            "TASK-2".to_string(),
308            "Task 2".to_string(),
309            "Desc".to_string(),
310        );
311        // Pending, no dependencies
312
313        let task3 = Task::new(
314            "TASK-3".to_string(),
315            "Task 3".to_string(),
316            "Desc".to_string(),
317        );
318        // Pending, no dependencies
319
320        phase.add_task(task1);
321        phase.add_task(task2);
322        phase.add_task(task3);
323
324        let next = phase.find_next_task();
325        assert!(next.is_some());
326        assert_eq!(next.unwrap().id, "TASK-2"); // First pending task
327    }
328
329    #[test]
330    fn test_find_next_task_with_dependencies() {
331        let mut phase = Phase::new("phase-1".to_string());
332
333        let mut task1 = Task::new(
334            "TASK-1".to_string(),
335            "Task 1".to_string(),
336            "Desc".to_string(),
337        );
338        task1.set_status(TaskStatus::Done);
339
340        let task2 = Task::new(
341            "TASK-2".to_string(),
342            "Task 2".to_string(),
343            "Desc".to_string(),
344        );
345        // Pending, no dependencies
346
347        let mut task3 = Task::new(
348            "TASK-3".to_string(),
349            "Task 3".to_string(),
350            "Desc".to_string(),
351        );
352        task3.dependencies = vec!["TASK-1".to_string(), "TASK-2".to_string()];
353        // Pending, but depends on TASK-2 which is not done
354
355        phase.add_task(task1);
356        phase.add_task(task2);
357        phase.add_task(task3);
358
359        let next = phase.find_next_task();
360        assert!(next.is_some());
361        assert_eq!(next.unwrap().id, "TASK-2"); // TASK-3 blocked by dependencies
362    }
363
364    #[test]
365    fn test_find_next_task_dependencies_met() {
366        let mut phase = Phase::new("phase-1".to_string());
367
368        let mut task1 = Task::new(
369            "TASK-1".to_string(),
370            "Task 1".to_string(),
371            "Desc".to_string(),
372        );
373        task1.set_status(TaskStatus::Done);
374
375        let mut task2 = Task::new(
376            "TASK-2".to_string(),
377            "Task 2".to_string(),
378            "Desc".to_string(),
379        );
380        task2.set_status(TaskStatus::Done);
381
382        let mut task3 = Task::new(
383            "TASK-3".to_string(),
384            "Task 3".to_string(),
385            "Desc".to_string(),
386        );
387        task3.dependencies = vec!["TASK-1".to_string(), "TASK-2".to_string()];
388        // Pending, dependencies met
389
390        phase.add_task(task1);
391        phase.add_task(task2);
392        phase.add_task(task3);
393
394        let next = phase.find_next_task();
395        assert!(next.is_some());
396        assert_eq!(next.unwrap().id, "TASK-3"); // Dependencies met
397    }
398
399    #[test]
400    fn test_find_next_task_none_available() {
401        let mut phase = Phase::new("phase-1".to_string());
402
403        let mut task1 = Task::new(
404            "TASK-1".to_string(),
405            "Task 1".to_string(),
406            "Desc".to_string(),
407        );
408        task1.set_status(TaskStatus::Done);
409
410        let mut task2 = Task::new(
411            "TASK-2".to_string(),
412            "Task 2".to_string(),
413            "Desc".to_string(),
414        );
415        task2.set_status(TaskStatus::InProgress);
416
417        phase.add_task(task1);
418        phase.add_task(task2);
419
420        let next = phase.find_next_task();
421        assert!(next.is_none()); // No pending tasks
422    }
423
424    #[test]
425    fn test_get_tasks_needing_expansion() {
426        let mut phase = Phase::new("phase-1".to_string());
427
428        let mut task1 = Task::new(
429            "TASK-1".to_string(),
430            "Small Task".to_string(),
431            "Desc".to_string(),
432        );
433        task1.complexity = 5;
434
435        let mut task2 = Task::new(
436            "TASK-2".to_string(),
437            "Medium Task".to_string(),
438            "Desc".to_string(),
439        );
440        task2.complexity = 13;
441
442        let mut task3 = Task::new(
443            "TASK-3".to_string(),
444            "Large Task".to_string(),
445            "Desc".to_string(),
446        );
447        task3.complexity = 21;
448
449        let mut task4 = Task::new(
450            "TASK-4".to_string(),
451            "Huge Task".to_string(),
452            "Desc".to_string(),
453        );
454        task4.complexity = 34;
455
456        phase.add_task(task1);
457        phase.add_task(task2);
458        phase.add_task(task3);
459        phase.add_task(task4);
460
461        let needing_expansion = phase.get_tasks_needing_expansion();
462
463        assert_eq!(needing_expansion.len(), 4); // All tasks with complexity >= 3
464        assert!(needing_expansion.iter().any(|t| t.id == "TASK-1"));
465        assert!(needing_expansion.iter().any(|t| t.id == "TASK-2"));
466        assert!(needing_expansion.iter().any(|t| t.id == "TASK-3"));
467        assert!(needing_expansion.iter().any(|t| t.id == "TASK-4"));
468    }
469
470    #[test]
471    fn test_phase_serialization() {
472        let mut phase = Phase::new("phase-1".to_string());
473        let task = Task::new(
474            "TASK-1".to_string(),
475            "Test Task".to_string(),
476            "Description".to_string(),
477        );
478        phase.add_task(task);
479
480        let json = serde_json::to_string(&phase).unwrap();
481        let deserialized: Phase = serde_json::from_str(&json).unwrap();
482
483        assert_eq!(phase.name, deserialized.name);
484        assert_eq!(phase.tasks.len(), deserialized.tasks.len());
485        assert_eq!(phase.tasks[0].id, deserialized.tasks[0].id);
486    }
487}