1use 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
29pub 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 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}