ito_domain/tasks/
compute.rs

1//! Task scheduling helpers.
2//!
3//! This module contains the logic for determining which tasks are actionable.
4//! It handles:
5//! - **Wave Gating**: Tasks in Wave N are blocked until all tasks in Wave N-1 (or explicit dependencies) are complete.
6//! - **Explicit Dependencies**: Tasks can depend on specific other tasks by ID.
7//! - **Back-compat**: Legacy behavior for files without explicit wave definitions.
8
9use std::collections::BTreeMap;
10
11use super::{TaskItem, TaskStatus, TasksFormat, TasksParseResult};
12
13fn parse_numeric_task_id(id: &str) -> Option<(u32, u32)> {
14    let (wave, task) = id.split_once('.')?;
15    let wave = wave.parse::<u32>().ok()?;
16    let task = task.parse::<u32>().ok()?;
17    Some((wave, task))
18}
19
20fn compare_task_ids(a: &str, b: &str) -> std::cmp::Ordering {
21    match (parse_numeric_task_id(a), parse_numeric_task_id(b)) {
22        (Some(aa), Some(bb)) => aa.cmp(&bb).then(a.cmp(b)),
23        (Some(_), None) => std::cmp::Ordering::Less,
24        (None, Some(_)) => std::cmp::Ordering::Greater,
25        (None, None) => a.cmp(b),
26    }
27}
28
29/// Compute ready tasks and blocked tasks (with reasons) from a parsed tasks file.
30pub fn compute_ready_and_blocked(
31    parsed: &TasksParseResult,
32) -> (Vec<TaskItem>, Vec<(TaskItem, Vec<String>)>) {
33    let tasks = &parsed.tasks;
34
35    if parsed.format == TasksFormat::Checkbox {
36        let has_in_progress = tasks.iter().any(|t| t.status == TaskStatus::InProgress);
37        if has_in_progress {
38            return (Vec::new(), Vec::new());
39        }
40        let mut ready: Vec<TaskItem> = tasks
41            .iter()
42            .filter(|t| t.status == TaskStatus::Pending)
43            .cloned()
44            .collect();
45        ready.sort_by(|a, b| a.header_line_index.cmp(&b.header_line_index));
46        return (ready, Vec::new());
47    }
48
49    let mut by_id: std::collections::BTreeMap<&str, &TaskItem> = std::collections::BTreeMap::new();
50    for t in tasks {
51        by_id.insert(t.id.as_str(), t);
52    }
53
54    let mut wave_complete: BTreeMap<u32, bool> = BTreeMap::new();
55    for w in &parsed.waves {
56        let done = tasks
57            .iter()
58            .filter(|t| t.wave == Some(w.wave))
59            .all(|t| t.status.is_done());
60        wave_complete.insert(w.wave, done);
61    }
62
63    let mut wave_unlocked: BTreeMap<u32, bool> = BTreeMap::new();
64    for w in &parsed.waves {
65        let unlocked = w
66            .depends_on
67            .iter()
68            .all(|dep| wave_complete.get(dep).copied().unwrap_or(false));
69        wave_unlocked.insert(w.wave, unlocked);
70    }
71
72    // Back-compat gating when no WaveInfo entries exist.
73    let mut first_incomplete_wave: Option<u32> = None;
74    if parsed.waves.is_empty() {
75        let mut waves: Vec<u32> = tasks.iter().filter_map(|t| t.wave).collect();
76        waves.sort();
77        waves.dedup();
78        for w in waves {
79            let all_done = tasks
80                .iter()
81                .filter(|t| t.wave == Some(w))
82                .all(|t| t.status.is_done());
83            if !all_done {
84                first_incomplete_wave = Some(w);
85                break;
86            }
87        }
88    }
89
90    let all_waves_complete = if parsed.waves.is_empty() {
91        first_incomplete_wave.is_none()
92    } else {
93        wave_complete.values().all(|v| *v)
94    };
95
96    let mut ready: Vec<TaskItem> = Vec::new();
97    let mut blocked: Vec<(TaskItem, Vec<String>)> = Vec::new();
98
99    for t in tasks {
100        if t.status != TaskStatus::Pending {
101            continue;
102        }
103        let mut blockers: Vec<String> = Vec::new();
104
105        if parsed.waves.is_empty() {
106            if let Some(first) = first_incomplete_wave {
107                let is_later_wave = t.wave.is_some_and(|w| w > first);
108                let is_checkpoint_like = t.wave.is_none();
109                if is_later_wave || is_checkpoint_like {
110                    blockers.push(format!("Blocked until Wave {first} is complete"));
111                }
112            }
113        } else {
114            match t.wave {
115                Some(w) => {
116                    if !wave_unlocked.get(&w).copied().unwrap_or(true) {
117                        if let Some(wave) = parsed.waves.iter().find(|wi| wi.wave == w) {
118                            for dep in &wave.depends_on {
119                                if !wave_complete.get(dep).copied().unwrap_or(false) {
120                                    blockers.push(format!("Blocked by Wave {dep}"));
121                                }
122                            }
123                        } else {
124                            blockers.push(format!("Blocked: Wave {w} is locked"));
125                        }
126                    }
127                }
128                None => {
129                    if !all_waves_complete {
130                        blockers.push("Blocked until all waves are complete".to_string());
131                    }
132                }
133            }
134        }
135
136        for dep in &t.dependencies {
137            if dep.is_empty() || dep == "Checkpoint" {
138                continue;
139            }
140            let Some(dep_task) = by_id.get(dep.as_str()).copied() else {
141                blockers.push(format!("Missing dependency: {dep}"));
142                continue;
143            };
144            if t.wave != dep_task.wave {
145                blockers.push(format!("Cross-wave dependency: {dep}"));
146            }
147            if dep_task.status != TaskStatus::Complete {
148                blockers.push(format!("Dependency not complete: {dep}"));
149            }
150        }
151
152        if blockers.is_empty() {
153            ready.push(t.clone());
154        } else {
155            blocked.push((t.clone(), blockers));
156        }
157    }
158
159    ready.sort_by(|a, b| compare_task_ids(&a.id, &b.id));
160    blocked.sort_by(|(a, _), (b, _)| compare_task_ids(&a.id, &b.id));
161
162    (ready, blocked)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::tasks::{ProgressInfo, TaskKind, WaveInfo};
169
170    fn progress_zero() -> ProgressInfo {
171        ProgressInfo {
172            total: 0,
173            complete: 0,
174            shelved: 0,
175            in_progress: 0,
176            pending: 0,
177            remaining: 0,
178        }
179    }
180
181    fn task(
182        id: &str,
183        wave: Option<u32>,
184        status: TaskStatus,
185        deps: &[&str],
186        header_line_index: usize,
187    ) -> TaskItem {
188        TaskItem {
189            id: id.to_string(),
190            name: id.to_string(),
191            wave,
192            status,
193            updated_at: None,
194            dependencies: deps.iter().map(|s| (*s).to_string()).collect(),
195            files: Vec::new(),
196            action: String::new(),
197            verify: None,
198            done_when: None,
199            kind: TaskKind::Normal,
200            header_line_index,
201        }
202    }
203
204    #[test]
205    fn checkbox_mode_returns_pending_sorted_and_no_blocked() {
206        let parsed = TasksParseResult {
207            format: TasksFormat::Checkbox,
208            tasks: vec![
209                task("2", None, TaskStatus::Pending, &[], 2),
210                task("1", None, TaskStatus::Complete, &[], 1),
211                task("3", None, TaskStatus::Pending, &[], 0),
212            ],
213            waves: Vec::new(),
214            diagnostics: Vec::new(),
215            progress: progress_zero(),
216        };
217
218        let (ready, blocked) = compute_ready_and_blocked(&parsed);
219        assert!(blocked.is_empty());
220        assert_eq!(ready.len(), 2);
221        assert_eq!(ready[0].id, "3");
222        assert_eq!(ready[1].id, "2");
223    }
224
225    #[test]
226    fn enhanced_backcompat_blocks_later_waves_and_checkpoints_until_first_incomplete_wave_done() {
227        let parsed = TasksParseResult {
228            format: TasksFormat::Enhanced,
229            tasks: vec![
230                task("1.1", Some(1), TaskStatus::Pending, &[], 0),
231                task("1.2", Some(1), TaskStatus::Complete, &[], 1),
232                task("2.1", Some(2), TaskStatus::Pending, &[], 2),
233                task("checkpoint", None, TaskStatus::Pending, &[], 3),
234            ],
235            waves: Vec::new(),
236            diagnostics: Vec::new(),
237            progress: progress_zero(),
238        };
239
240        let (ready, blocked) = compute_ready_and_blocked(&parsed);
241        assert_eq!(ready.len(), 1);
242        assert_eq!(ready[0].id, "1.1");
243
244        let mut blocked_ids: Vec<&str> = blocked.iter().map(|(t, _)| t.id.as_str()).collect();
245        blocked_ids.sort();
246        assert_eq!(blocked_ids, vec!["2.1", "checkpoint"]);
247
248        let reasons_for_2_1 = blocked
249            .iter()
250            .find(|(t, _)| t.id == "2.1")
251            .unwrap()
252            .1
253            .join("\n");
254        assert!(reasons_for_2_1.contains("Blocked until Wave 1 is complete"));
255    }
256
257    #[test]
258    fn enhanced_wave_dependency_blocks_by_wave_and_unblocks_when_complete() {
259        let parsed = TasksParseResult {
260            format: TasksFormat::Enhanced,
261            tasks: vec![
262                task("1.1", Some(1), TaskStatus::Complete, &[], 0),
263                task("2.1", Some(2), TaskStatus::Pending, &[], 1),
264            ],
265            waves: vec![
266                WaveInfo {
267                    wave: 1,
268                    depends_on: Vec::new(),
269                    header_line_index: 0,
270                    depends_on_line_index: None,
271                },
272                WaveInfo {
273                    wave: 2,
274                    depends_on: vec![1],
275                    header_line_index: 0,
276                    depends_on_line_index: None,
277                },
278            ],
279            diagnostics: Vec::new(),
280            progress: progress_zero(),
281        };
282
283        let (ready, blocked) = compute_ready_and_blocked(&parsed);
284        assert!(blocked.is_empty());
285        assert_eq!(ready.len(), 1);
286        assert_eq!(ready[0].id, "2.1");
287
288        let mut parsed = parsed;
289        parsed.tasks[0].status = TaskStatus::Pending;
290        let (ready, blocked) = compute_ready_and_blocked(&parsed);
291        assert_eq!(ready.len(), 1);
292        assert_eq!(ready[0].id, "1.1");
293        assert_eq!(blocked.len(), 1);
294        assert_eq!(blocked[0].0.id, "2.1");
295        assert!(blocked[0].1.iter().any(|m| m.contains("Blocked by Wave 1")));
296    }
297
298    #[test]
299    fn enhanced_task_dependencies_produce_missing_crosswave_and_not_complete_blockers() {
300        let parsed = TasksParseResult {
301            format: TasksFormat::Enhanced,
302            tasks: vec![
303                task("1.1", Some(1), TaskStatus::Complete, &[], 0),
304                task("1.2", Some(1), TaskStatus::Pending, &["missing"], 1),
305                task("2.1", Some(2), TaskStatus::Pending, &["1.1"], 2),
306                task("2.2", Some(2), TaskStatus::Pending, &["2.1"], 3),
307            ],
308            waves: vec![
309                WaveInfo {
310                    wave: 1,
311                    depends_on: Vec::new(),
312                    header_line_index: 0,
313                    depends_on_line_index: None,
314                },
315                WaveInfo {
316                    wave: 2,
317                    depends_on: Vec::new(),
318                    header_line_index: 0,
319                    depends_on_line_index: None,
320                },
321            ],
322            diagnostics: Vec::new(),
323            progress: progress_zero(),
324        };
325
326        let (ready, blocked) = compute_ready_and_blocked(&parsed);
327        assert!(ready.is_empty());
328
329        let b_1_2 = blocked.iter().find(|(t, _)| t.id == "1.2").unwrap();
330        assert!(
331            b_1_2
332                .1
333                .iter()
334                .any(|m| m.contains("Missing dependency: missing"))
335        );
336
337        let b_2_1 = blocked.iter().find(|(t, _)| t.id == "2.1").unwrap();
338        assert!(
339            b_2_1
340                .1
341                .iter()
342                .any(|m| m.contains("Cross-wave dependency: 1.1"))
343        );
344
345        let b_2_2 = blocked.iter().find(|(t, _)| t.id == "2.2").unwrap();
346        assert!(
347            b_2_2
348                .1
349                .iter()
350                .any(|m| m.contains("Dependency not complete: 2.1"))
351        );
352    }
353
354    #[test]
355    fn enhanced_ready_and_blocked_lists_are_sorted_by_task_id() {
356        let parsed = TasksParseResult {
357            format: TasksFormat::Enhanced,
358            tasks: vec![
359                task("1.2", Some(1), TaskStatus::Pending, &[], 80),
360                task("1.1", Some(1), TaskStatus::Pending, &["missing"], 120),
361                task("1.3", Some(1), TaskStatus::Pending, &[], 40),
362            ],
363            waves: vec![WaveInfo {
364                wave: 1,
365                depends_on: Vec::new(),
366                header_line_index: 200,
367                depends_on_line_index: None,
368            }],
369            diagnostics: Vec::new(),
370            progress: progress_zero(),
371        };
372
373        let (ready, blocked) = compute_ready_and_blocked(&parsed);
374        assert_eq!(
375            ready.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(),
376            vec!["1.2", "1.3"]
377        );
378        assert_eq!(
379            blocked
380                .iter()
381                .map(|(t, _)| t.id.as_str())
382                .collect::<Vec<_>>(),
383            vec!["1.1"]
384        );
385    }
386}