Skip to main content

scud_task_core/models/
phase.rs

1use super::task::{Task, TaskStatus};
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                TaskStatus::Pending => pending += 1,
95                TaskStatus::InProgress => in_progress += 1,
96                TaskStatus::Done => done += 1,
97                TaskStatus::Blocked => blocked += 1,
98                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 == 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 == TaskStatus::Pending && task.has_dependencies_met(&self.tasks)
154        })
155    }
156
157    /// Find next task with cross-tag dependency support.
158    /// Pass flattened tasks from all phases for proper dependency resolution.
159    pub fn find_next_task_cross_tag<'a>(&'a self, all_tasks: &[&Task]) -> Option<&'a Task> {
160        self.tasks.iter().find(|task| {
161            task.status == TaskStatus::Pending && task.has_dependencies_met_refs(all_tasks)
162        })
163    }
164
165    pub fn get_tasks_needing_expansion(&self) -> Vec<&Task> {
166        self.tasks.iter().filter(|t| t.needs_expansion()).collect()
167    }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct PhaseStats {
172    pub total: usize,
173    pub pending: usize,
174    pub in_progress: usize,
175    pub done: usize,
176    pub blocked: usize,
177    pub expanded: usize,
178    pub total_complexity: u32,
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::models::task::{Task, TaskStatus};
185
186    #[test]
187    fn test_phase_creation() {
188        let phase = Phase::new("phase-1-auth".to_string());
189
190        assert_eq!(phase.name, "phase-1-auth");
191        assert!(phase.tasks.is_empty());
192    }
193
194    #[test]
195    fn test_add_task() {
196        let mut phase = Phase::new("phase-1".to_string());
197        let task = Task::new(
198            "TASK-1".to_string(),
199            "Test Task".to_string(),
200            "Description".to_string(),
201        );
202
203        phase.add_task(task.clone());
204
205        assert_eq!(phase.tasks.len(), 1);
206        assert_eq!(phase.tasks[0].id, "TASK-1");
207    }
208
209    #[test]
210    fn test_get_task() {
211        let mut phase = Phase::new("phase-1".to_string());
212        let task = Task::new(
213            "TASK-1".to_string(),
214            "Test Task".to_string(),
215            "Description".to_string(),
216        );
217        phase.add_task(task);
218
219        let retrieved = phase.get_task("TASK-1");
220        assert!(retrieved.is_some());
221        assert_eq!(retrieved.unwrap().id, "TASK-1");
222
223        let missing = phase.get_task("TASK-99");
224        assert!(missing.is_none());
225    }
226
227    #[test]
228    fn test_get_task_mut() {
229        let mut phase = Phase::new("phase-1".to_string());
230        let task = Task::new(
231            "TASK-1".to_string(),
232            "Test Task".to_string(),
233            "Description".to_string(),
234        );
235        phase.add_task(task);
236
237        {
238            let task_mut = phase.get_task_mut("TASK-1").unwrap();
239            task_mut.set_status(TaskStatus::InProgress);
240        }
241
242        assert_eq!(
243            phase.get_task("TASK-1").unwrap().status,
244            TaskStatus::InProgress
245        );
246    }
247
248    #[test]
249    fn test_remove_task() {
250        let mut phase = Phase::new("phase-1".to_string());
251        let task1 = Task::new(
252            "TASK-1".to_string(),
253            "Task 1".to_string(),
254            "Desc".to_string(),
255        );
256        let task2 = Task::new(
257            "TASK-2".to_string(),
258            "Task 2".to_string(),
259            "Desc".to_string(),
260        );
261        phase.add_task(task1);
262        phase.add_task(task2);
263
264        let removed = phase.remove_task("TASK-1");
265        assert!(removed.is_some());
266        assert_eq!(removed.unwrap().id, "TASK-1");
267        assert_eq!(phase.tasks.len(), 1);
268        assert_eq!(phase.tasks[0].id, "TASK-2");
269
270        let missing = phase.remove_task("TASK-99");
271        assert!(missing.is_none());
272    }
273
274    #[test]
275    fn test_get_stats_empty_phase() {
276        let phase = Phase::new("phase-1".to_string());
277        let stats = phase.get_stats();
278
279        assert_eq!(stats.total, 0);
280        assert_eq!(stats.pending, 0);
281        assert_eq!(stats.in_progress, 0);
282        assert_eq!(stats.done, 0);
283        assert_eq!(stats.blocked, 0);
284        assert_eq!(stats.total_complexity, 0);
285    }
286
287    #[test]
288    fn test_get_stats_with_tasks() {
289        let mut phase = Phase::new("phase-1".to_string());
290
291        let mut task1 = Task::new(
292            "TASK-1".to_string(),
293            "Task 1".to_string(),
294            "Desc".to_string(),
295        );
296        task1.complexity = 3;
297        task1.set_status(TaskStatus::Done);
298
299        let mut task2 = Task::new(
300            "TASK-2".to_string(),
301            "Task 2".to_string(),
302            "Desc".to_string(),
303        );
304        task2.complexity = 5;
305        task2.set_status(TaskStatus::InProgress);
306
307        let mut task3 = Task::new(
308            "TASK-3".to_string(),
309            "Task 3".to_string(),
310            "Desc".to_string(),
311        );
312        task3.complexity = 8;
313        // Pending by default
314
315        let mut task4 = Task::new(
316            "TASK-4".to_string(),
317            "Task 4".to_string(),
318            "Desc".to_string(),
319        );
320        task4.complexity = 2;
321        task4.set_status(TaskStatus::Blocked);
322
323        phase.add_task(task1);
324        phase.add_task(task2);
325        phase.add_task(task3);
326        phase.add_task(task4);
327
328        let stats = phase.get_stats();
329
330        assert_eq!(stats.total, 4);
331        assert_eq!(stats.pending, 1);
332        assert_eq!(stats.in_progress, 1);
333        assert_eq!(stats.done, 1);
334        assert_eq!(stats.blocked, 1);
335        assert_eq!(stats.total_complexity, 18); // 3 + 5 + 8 + 2
336    }
337
338    #[test]
339    fn test_find_next_task_no_dependencies() {
340        let mut phase = Phase::new("phase-1".to_string());
341
342        let mut task1 = Task::new(
343            "TASK-1".to_string(),
344            "Task 1".to_string(),
345            "Desc".to_string(),
346        );
347        task1.set_status(TaskStatus::Done);
348
349        let task2 = Task::new(
350            "TASK-2".to_string(),
351            "Task 2".to_string(),
352            "Desc".to_string(),
353        );
354        // Pending, no dependencies
355
356        phase.add_task(task1);
357        phase.add_task(task2);
358
359        let next = phase.find_next_task();
360        assert!(next.is_some());
361        assert_eq!(next.unwrap().id, "TASK-2"); // First pending task
362    }
363
364    #[test]
365    fn test_find_next_task_with_dependencies() {
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 task2 = Task::new(
376            "TASK-2".to_string(),
377            "Task 2".to_string(),
378            "Desc".to_string(),
379        );
380        // Pending, no dependencies
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, but depends on TASK-2 which is not done
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-2"); // TASK-3 blocked by dependencies
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_phase_serialization() {
426        let mut phase = Phase::new("phase-1".to_string());
427        let task = Task::new(
428            "TASK-1".to_string(),
429            "Test Task".to_string(),
430            "Description".to_string(),
431        );
432        phase.add_task(task);
433
434        let json = serde_json::to_string(&phase).unwrap();
435        let deserialized: Phase = serde_json::from_str(&json).unwrap();
436
437        assert_eq!(phase.name, deserialized.name);
438        assert_eq!(phase.tasks.len(), deserialized.tasks.len());
439        assert_eq!(phase.tasks[0].id, deserialized.tasks[0].id);
440    }
441
442    #[test]
443    fn test_id_format_parse() {
444        assert_eq!(IdFormat::parse("sequential"), IdFormat::Sequential);
445        assert_eq!(IdFormat::parse("uuid"), IdFormat::Uuid);
446        assert_eq!(IdFormat::parse("UUID"), IdFormat::Uuid);
447        assert_eq!(IdFormat::parse("unknown"), IdFormat::Sequential); // Default
448    }
449
450    #[test]
451    fn test_id_format_as_str() {
452        assert_eq!(IdFormat::Sequential.as_str(), "sequential");
453        assert_eq!(IdFormat::Uuid.as_str(), "uuid");
454    }
455}