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 => {
66                    // Check if all subtasks are done - if so, count as done
67                    let all_subtasks_done = task.subtasks.iter().all(|subtask_id| {
68                        self.get_task(subtask_id)
69                            .map(|st| st.status == super::task::TaskStatus::Done)
70                            .unwrap_or(false)
71                    });
72                    if all_subtasks_done && !task.subtasks.is_empty() {
73                        done += 1;
74                    } else {
75                        expanded += 1;
76                    }
77                }
78                _ => {}
79            }
80        }
81
82        PhaseStats {
83            total,
84            pending,
85            in_progress,
86            done,
87            blocked,
88            expanded,
89            total_complexity,
90        }
91    }
92
93    /// Get actionable tasks (not expanded, not subtasks of incomplete parents)
94    pub fn get_actionable_tasks(&self) -> Vec<&Task> {
95        self.tasks
96            .iter()
97            .filter(|t| {
98                // Exclude expanded parents (work on subtasks instead)
99                if t.is_expanded() {
100                    return false;
101                }
102                // Include subtasks only if they're actionable
103                if let Some(ref parent_id) = t.parent_id {
104                    // Parent must be expanded
105                    self.get_task(parent_id)
106                        .map(|p| p.is_expanded())
107                        .unwrap_or(false)
108                } else {
109                    // Top-level task
110                    true
111                }
112            })
113            .collect()
114    }
115
116    /// Find next task using only local phase tasks for dependency checking.
117    /// For cross-tag dependency support, use find_next_task_cross_tag instead.
118    pub fn find_next_task(&self) -> Option<&Task> {
119        self.tasks.iter().find(|task| {
120            task.status == super::task::TaskStatus::Pending
121                && task.has_dependencies_met(&self.tasks)
122        })
123    }
124
125    /// Find next task with cross-tag dependency support.
126    /// Pass flattened tasks from all phases for proper dependency resolution.
127    pub fn find_next_task_cross_tag<'a>(&'a self, all_tasks: &[&Task]) -> Option<&'a Task> {
128        self.tasks.iter().find(|task| {
129            task.status == super::task::TaskStatus::Pending
130                && task.has_dependencies_met_refs(all_tasks)
131        })
132    }
133
134    pub fn get_tasks_needing_expansion(&self) -> Vec<&Task> {
135        self.tasks.iter().filter(|t| t.needs_expansion()).collect()
136    }
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct PhaseStats {
141    pub total: usize,
142    pub pending: usize,
143    pub in_progress: usize,
144    pub done: usize,
145    pub blocked: usize,
146    pub expanded: usize,
147    pub total_complexity: u32,
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::models::task::{Task, TaskStatus};
154
155    #[test]
156    fn test_phase_creation() {
157        let phase = Phase::new("phase-1-auth".to_string());
158
159        assert_eq!(phase.name, "phase-1-auth");
160        assert!(phase.tasks.is_empty());
161    }
162
163    #[test]
164    fn test_add_task() {
165        let mut phase = Phase::new("phase-1".to_string());
166        let task = Task::new(
167            "TASK-1".to_string(),
168            "Test Task".to_string(),
169            "Description".to_string(),
170        );
171
172        phase.add_task(task.clone());
173
174        assert_eq!(phase.tasks.len(), 1);
175        assert_eq!(phase.tasks[0].id, "TASK-1");
176    }
177
178    #[test]
179    fn test_get_task() {
180        let mut phase = Phase::new("phase-1".to_string());
181        let task = Task::new(
182            "TASK-1".to_string(),
183            "Test Task".to_string(),
184            "Description".to_string(),
185        );
186        phase.add_task(task);
187
188        let retrieved = phase.get_task("TASK-1");
189        assert!(retrieved.is_some());
190        assert_eq!(retrieved.unwrap().id, "TASK-1");
191
192        let missing = phase.get_task("TASK-99");
193        assert!(missing.is_none());
194    }
195
196    #[test]
197    fn test_get_task_mut() {
198        let mut phase = Phase::new("phase-1".to_string());
199        let task = Task::new(
200            "TASK-1".to_string(),
201            "Test Task".to_string(),
202            "Description".to_string(),
203        );
204        phase.add_task(task);
205
206        {
207            let task_mut = phase.get_task_mut("TASK-1").unwrap();
208            task_mut.set_status(TaskStatus::InProgress);
209        }
210
211        assert_eq!(
212            phase.get_task("TASK-1").unwrap().status,
213            TaskStatus::InProgress
214        );
215    }
216
217    #[test]
218    fn test_remove_task() {
219        let mut phase = Phase::new("phase-1".to_string());
220        let task1 = Task::new(
221            "TASK-1".to_string(),
222            "Task 1".to_string(),
223            "Desc".to_string(),
224        );
225        let task2 = Task::new(
226            "TASK-2".to_string(),
227            "Task 2".to_string(),
228            "Desc".to_string(),
229        );
230        phase.add_task(task1);
231        phase.add_task(task2);
232
233        let removed = phase.remove_task("TASK-1");
234        assert!(removed.is_some());
235        assert_eq!(removed.unwrap().id, "TASK-1");
236        assert_eq!(phase.tasks.len(), 1);
237        assert_eq!(phase.tasks[0].id, "TASK-2");
238
239        let missing = phase.remove_task("TASK-99");
240        assert!(missing.is_none());
241    }
242
243    #[test]
244    fn test_get_stats_empty_phase() {
245        let phase = Phase::new("phase-1".to_string());
246        let stats = phase.get_stats();
247
248        assert_eq!(stats.total, 0);
249        assert_eq!(stats.pending, 0);
250        assert_eq!(stats.in_progress, 0);
251        assert_eq!(stats.done, 0);
252        assert_eq!(stats.blocked, 0);
253        assert_eq!(stats.total_complexity, 0);
254    }
255
256    #[test]
257    fn test_get_stats_with_tasks() {
258        let mut phase = Phase::new("phase-1".to_string());
259
260        let mut task1 = Task::new(
261            "TASK-1".to_string(),
262            "Task 1".to_string(),
263            "Desc".to_string(),
264        );
265        task1.complexity = 3;
266        task1.set_status(TaskStatus::Done);
267
268        let mut task2 = Task::new(
269            "TASK-2".to_string(),
270            "Task 2".to_string(),
271            "Desc".to_string(),
272        );
273        task2.complexity = 5;
274        task2.set_status(TaskStatus::InProgress);
275
276        let mut task3 = Task::new(
277            "TASK-3".to_string(),
278            "Task 3".to_string(),
279            "Desc".to_string(),
280        );
281        task3.complexity = 8;
282        // Pending by default
283
284        let mut task4 = Task::new(
285            "TASK-4".to_string(),
286            "Task 4".to_string(),
287            "Desc".to_string(),
288        );
289        task4.complexity = 2;
290        task4.set_status(TaskStatus::Blocked);
291
292        phase.add_task(task1);
293        phase.add_task(task2);
294        phase.add_task(task3);
295        phase.add_task(task4);
296
297        let stats = phase.get_stats();
298
299        assert_eq!(stats.total, 4);
300        assert_eq!(stats.pending, 1);
301        assert_eq!(stats.in_progress, 1);
302        assert_eq!(stats.done, 1);
303        assert_eq!(stats.blocked, 1);
304        assert_eq!(stats.total_complexity, 18); // 3 + 5 + 8 + 2
305    }
306
307    #[test]
308    fn test_find_next_task_no_dependencies() {
309        let mut phase = Phase::new("phase-1".to_string());
310
311        let mut task1 = Task::new(
312            "TASK-1".to_string(),
313            "Task 1".to_string(),
314            "Desc".to_string(),
315        );
316        task1.set_status(TaskStatus::Done);
317
318        let task2 = Task::new(
319            "TASK-2".to_string(),
320            "Task 2".to_string(),
321            "Desc".to_string(),
322        );
323        // Pending, no dependencies
324
325        let task3 = Task::new(
326            "TASK-3".to_string(),
327            "Task 3".to_string(),
328            "Desc".to_string(),
329        );
330        // Pending, no dependencies
331
332        phase.add_task(task1);
333        phase.add_task(task2);
334        phase.add_task(task3);
335
336        let next = phase.find_next_task();
337        assert!(next.is_some());
338        assert_eq!(next.unwrap().id, "TASK-2"); // First pending task
339    }
340
341    #[test]
342    fn test_find_next_task_with_dependencies() {
343        let mut phase = Phase::new("phase-1".to_string());
344
345        let mut task1 = Task::new(
346            "TASK-1".to_string(),
347            "Task 1".to_string(),
348            "Desc".to_string(),
349        );
350        task1.set_status(TaskStatus::Done);
351
352        let task2 = Task::new(
353            "TASK-2".to_string(),
354            "Task 2".to_string(),
355            "Desc".to_string(),
356        );
357        // Pending, no dependencies
358
359        let mut task3 = Task::new(
360            "TASK-3".to_string(),
361            "Task 3".to_string(),
362            "Desc".to_string(),
363        );
364        task3.dependencies = vec!["TASK-1".to_string(), "TASK-2".to_string()];
365        // Pending, but depends on TASK-2 which is not done
366
367        phase.add_task(task1);
368        phase.add_task(task2);
369        phase.add_task(task3);
370
371        let next = phase.find_next_task();
372        assert!(next.is_some());
373        assert_eq!(next.unwrap().id, "TASK-2"); // TASK-3 blocked by dependencies
374    }
375
376    #[test]
377    fn test_find_next_task_dependencies_met() {
378        let mut phase = Phase::new("phase-1".to_string());
379
380        let mut task1 = Task::new(
381            "TASK-1".to_string(),
382            "Task 1".to_string(),
383            "Desc".to_string(),
384        );
385        task1.set_status(TaskStatus::Done);
386
387        let mut task2 = Task::new(
388            "TASK-2".to_string(),
389            "Task 2".to_string(),
390            "Desc".to_string(),
391        );
392        task2.set_status(TaskStatus::Done);
393
394        let mut task3 = Task::new(
395            "TASK-3".to_string(),
396            "Task 3".to_string(),
397            "Desc".to_string(),
398        );
399        task3.dependencies = vec!["TASK-1".to_string(), "TASK-2".to_string()];
400        // Pending, dependencies met
401
402        phase.add_task(task1);
403        phase.add_task(task2);
404        phase.add_task(task3);
405
406        let next = phase.find_next_task();
407        assert!(next.is_some());
408        assert_eq!(next.unwrap().id, "TASK-3"); // Dependencies met
409    }
410
411    #[test]
412    fn test_find_next_task_none_available() {
413        let mut phase = Phase::new("phase-1".to_string());
414
415        let mut task1 = Task::new(
416            "TASK-1".to_string(),
417            "Task 1".to_string(),
418            "Desc".to_string(),
419        );
420        task1.set_status(TaskStatus::Done);
421
422        let mut task2 = Task::new(
423            "TASK-2".to_string(),
424            "Task 2".to_string(),
425            "Desc".to_string(),
426        );
427        task2.set_status(TaskStatus::InProgress);
428
429        phase.add_task(task1);
430        phase.add_task(task2);
431
432        let next = phase.find_next_task();
433        assert!(next.is_none()); // No pending tasks
434    }
435
436    #[test]
437    fn test_get_tasks_needing_expansion() {
438        let mut phase = Phase::new("phase-1".to_string());
439
440        let mut task1 = Task::new(
441            "TASK-1".to_string(),
442            "Small Task".to_string(),
443            "Desc".to_string(),
444        );
445        task1.complexity = 5;
446
447        let mut task2 = Task::new(
448            "TASK-2".to_string(),
449            "Medium Task".to_string(),
450            "Desc".to_string(),
451        );
452        task2.complexity = 13;
453
454        let mut task3 = Task::new(
455            "TASK-3".to_string(),
456            "Large Task".to_string(),
457            "Desc".to_string(),
458        );
459        task3.complexity = 21;
460
461        let mut task4 = Task::new(
462            "TASK-4".to_string(),
463            "Huge Task".to_string(),
464            "Desc".to_string(),
465        );
466        task4.complexity = 34;
467
468        phase.add_task(task1);
469        phase.add_task(task2);
470        phase.add_task(task3);
471        phase.add_task(task4);
472
473        let needing_expansion = phase.get_tasks_needing_expansion();
474
475        assert_eq!(needing_expansion.len(), 4); // All tasks with complexity >= 3
476        assert!(needing_expansion.iter().any(|t| t.id == "TASK-1"));
477        assert!(needing_expansion.iter().any(|t| t.id == "TASK-2"));
478        assert!(needing_expansion.iter().any(|t| t.id == "TASK-3"));
479        assert!(needing_expansion.iter().any(|t| t.id == "TASK-4"));
480    }
481
482    #[test]
483    fn test_phase_serialization() {
484        let mut phase = Phase::new("phase-1".to_string());
485        let task = Task::new(
486            "TASK-1".to_string(),
487            "Test Task".to_string(),
488            "Description".to_string(),
489        );
490        phase.add_task(task);
491
492        let json = serde_json::to_string(&phase).unwrap();
493        let deserialized: Phase = serde_json::from_str(&json).unwrap();
494
495        assert_eq!(phase.name, deserialized.name);
496        assert_eq!(phase.tasks.len(), deserialized.tasks.len());
497        assert_eq!(phase.tasks[0].id, deserialized.tasks[0].id);
498    }
499
500    #[test]
501    fn test_get_stats_expanded_with_all_subtasks_done() {
502        let mut phase = Phase::new("phase-1".to_string());
503
504        // Create an expanded parent task
505        let mut parent = Task::new(
506            "TASK-1".to_string(),
507            "Parent Task".to_string(),
508            "Desc".to_string(),
509        );
510        parent.complexity = 8;
511        parent.set_status(TaskStatus::Expanded);
512        parent.subtasks = vec!["TASK-1.1".to_string(), "TASK-1.2".to_string()];
513
514        // Create subtasks - all done
515        let mut subtask1 = Task::new(
516            "TASK-1.1".to_string(),
517            "Subtask 1".to_string(),
518            "Desc".to_string(),
519        );
520        subtask1.parent_id = Some("TASK-1".to_string());
521        subtask1.set_status(TaskStatus::Done);
522
523        let mut subtask2 = Task::new(
524            "TASK-1.2".to_string(),
525            "Subtask 2".to_string(),
526            "Desc".to_string(),
527        );
528        subtask2.parent_id = Some("TASK-1".to_string());
529        subtask2.set_status(TaskStatus::Done);
530
531        // Add a regular done task
532        let mut task2 = Task::new(
533            "TASK-2".to_string(),
534            "Regular Task".to_string(),
535            "Desc".to_string(),
536        );
537        task2.complexity = 3;
538        task2.set_status(TaskStatus::Done);
539
540        phase.add_task(parent);
541        phase.add_task(subtask1);
542        phase.add_task(subtask2);
543        phase.add_task(task2);
544
545        let stats = phase.get_stats();
546
547        // Should count 2 total (parent + regular task, not subtasks)
548        assert_eq!(stats.total, 2);
549        // Both should be done (parent has all subtasks done, regular is done)
550        assert_eq!(stats.done, 2);
551        // Expanded should be 0 since all subtasks are done
552        assert_eq!(stats.expanded, 0);
553    }
554
555    #[test]
556    fn test_get_stats_expanded_with_incomplete_subtasks() {
557        let mut phase = Phase::new("phase-1".to_string());
558
559        // Create an expanded parent task
560        let mut parent = Task::new(
561            "TASK-1".to_string(),
562            "Parent Task".to_string(),
563            "Desc".to_string(),
564        );
565        parent.complexity = 8;
566        parent.set_status(TaskStatus::Expanded);
567        parent.subtasks = vec!["TASK-1.1".to_string(), "TASK-1.2".to_string()];
568
569        // Create subtasks - one done, one pending
570        let mut subtask1 = Task::new(
571            "TASK-1.1".to_string(),
572            "Subtask 1".to_string(),
573            "Desc".to_string(),
574        );
575        subtask1.parent_id = Some("TASK-1".to_string());
576        subtask1.set_status(TaskStatus::Done);
577
578        let mut subtask2 = Task::new(
579            "TASK-1.2".to_string(),
580            "Subtask 2".to_string(),
581            "Desc".to_string(),
582        );
583        subtask2.parent_id = Some("TASK-1".to_string());
584        // Pending by default
585
586        phase.add_task(parent);
587        phase.add_task(subtask1);
588        phase.add_task(subtask2);
589
590        let stats = phase.get_stats();
591
592        // Should count 1 total (just parent, not subtasks)
593        assert_eq!(stats.total, 1);
594        // Parent should still be expanded (not all subtasks done)
595        assert_eq!(stats.expanded, 1);
596        assert_eq!(stats.done, 0);
597    }
598}