scud/models/
phase.rs

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