1use anyhow::Result;
2use colored::Colorize;
3use std::collections::{HashMap, HashSet};
4use std::path::PathBuf;
5
6use crate::models::task::{Task, TaskStatus};
7use crate::storage::Storage;
8
9#[derive(Debug)]
10pub struct Wave {
11 pub number: usize,
12 pub tasks: Vec<String>,
13}
14
15pub fn run(
16 project_root: Option<PathBuf>,
17 tag: Option<&str>,
18 max_parallel: usize,
19 all_tags: bool,
20) -> Result<()> {
21 if max_parallel == 0 {
23 anyhow::bail!("--max-parallel must be at least 1");
24 }
25
26 let storage = Storage::new(project_root);
27 let all_tasks = storage.load_tasks()?;
28
29 let phase_tags: Vec<String> = if all_tags {
31 all_tasks.keys().cloned().collect()
32 } else if let Some(t) = tag {
33 if !all_tasks.contains_key(t) {
34 anyhow::bail!("Phase '{}' not found. Run: scud tags", t);
35 }
36 vec![t.to_string()]
37 } else {
38 let active = storage.get_active_group()?;
40 match active {
41 Some(t) => vec![t],
42 None => anyhow::bail!("No active task group. Use --tag <phase-tag> or run: scud tags"),
43 }
44 };
45
46 let mut actionable: Vec<&Task> = Vec::new();
48 for tag in &phase_tags {
49 if let Some(phase) = all_tasks.get(tag) {
50 for task in &phase.tasks {
51 if task.status != TaskStatus::Done
53 && task.status != TaskStatus::Expanded
54 && task.status != TaskStatus::Cancelled
55 {
56 if let Some(ref parent_id) = task.parent_id {
58 let parent_expanded = phase
59 .get_task(parent_id)
60 .map(|p| p.is_expanded())
61 .unwrap_or(false);
62 if parent_expanded {
63 actionable.push(task);
64 }
65 } else {
66 actionable.push(task);
68 }
69 }
70 }
71 }
72 }
73
74 if actionable.is_empty() {
75 println!("{}", "No actionable tasks found.".yellow());
76 println!("All tasks may be completed, expanded, or cancelled.");
77 return Ok(());
78 }
79
80 if all_tags {
82 let collisions = detect_id_collisions(&actionable);
83 if !collisions.is_empty() {
84 println!(
85 "{}",
86 "Warning: ID collisions detected across tags!"
87 .yellow()
88 .bold()
89 );
90 println!("The following local IDs exist in multiple tags:");
91 for (local_id, tags) in &collisions {
92 println!(" {} -> {}", local_id.cyan(), tags.join(", ").dimmed());
93 }
94 println!();
95 println!(
96 "{}",
97 "Tasks will be shown with full namespaced IDs (tag:id) to avoid confusion."
98 .dimmed()
99 );
100 println!();
101 }
102 }
103
104 let waves = compute_waves(&actionable, max_parallel);
106
107 println!(
109 "\n{} {}",
110 "Execution Waves".blue().bold(),
111 format!("(max {} parallel)", max_parallel).dimmed()
112 );
113 println!("{}", "=".repeat(50).blue());
114 println!();
115
116 let mut total_tasks = 0;
117 for wave in &waves {
118 total_tasks += wave.tasks.len();
119
120 let batch_info = if wave.tasks.len() > max_parallel {
121 format!(
122 " (batched into {} rounds)",
123 wave.tasks.len().div_ceil(max_parallel)
124 )
125 } else {
126 String::new()
127 };
128
129 println!(
130 "{} {} task{}{}",
131 format!("Wave {}:", wave.number).yellow().bold(),
132 wave.tasks.len(),
133 if wave.tasks.len() == 1 { "" } else { "s" },
134 batch_info.dimmed()
135 );
136
137 for (round_idx, chunk) in wave.tasks.chunks(max_parallel).enumerate() {
138 if wave.tasks.len() > max_parallel {
139 println!(" {} {}", "Round".dimmed(), round_idx + 1);
140 }
141
142 for task_id in chunk {
143 if let Some(task) = actionable.iter().find(|t| &t.id == task_id) {
145 let status_indicator = match task.status {
146 TaskStatus::Pending => "○".white(),
147 TaskStatus::InProgress => "●".cyan(),
148 TaskStatus::Blocked => "✗".red(),
149 _ => "?".dimmed(),
150 };
151
152 let deps = if task.dependencies.is_empty() {
153 String::new()
154 } else {
155 format!(" <- {}", task.dependencies.join(", "))
156 .dimmed()
157 .to_string()
158 };
159
160 let complexity = if task.complexity > 0 {
161 format!(" [{}]", task.complexity).dimmed().to_string()
162 } else {
163 String::new()
164 };
165
166 println!(
167 " {} {} {}{}{}",
168 status_indicator,
169 task_id.cyan(),
170 task.title,
171 complexity,
172 deps
173 );
174 }
175 }
176 }
177 println!();
178 }
179
180 println!("{}", "Summary".blue().bold());
182 println!("{}", "-".repeat(30).blue());
183
184 let total_waves = waves.len();
185 let total_rounds: usize = waves
186 .iter()
187 .map(|w| w.tasks.len().div_ceil(max_parallel))
188 .sum();
189
190 println!(" Total tasks: {}", total_tasks);
191 println!(" Total waves: {}", total_waves);
192 println!(" Total rounds: {}", total_rounds);
193
194 if total_tasks > 0 && total_rounds > 0 {
195 let speedup = total_tasks as f64 / total_rounds as f64;
196 println!(" Speedup: {}", format!("{:.1}x", speedup).green());
197 println!(
198 " {}",
199 format!(
200 "(from {} sequential to {} parallel rounds)",
201 total_tasks, total_rounds
202 )
203 .dimmed()
204 );
205 }
206
207 let blocked: Vec<_> = actionable
209 .iter()
210 .filter(|t| t.status == TaskStatus::Blocked)
211 .collect();
212 if !blocked.is_empty() {
213 println!();
214 println!("{}", "Blocked Tasks:".red().bold());
215 for task in blocked {
216 println!(" {} {}", task.id.red(), task.title);
217 }
218 }
219
220 println!();
221
222 Ok(())
223}
224
225fn compute_waves(tasks: &[&Task], _max_parallel: usize) -> Vec<Wave> {
228 let task_ids: HashSet<String> = tasks.iter().map(|t| t.id.clone()).collect();
231
232 let mut in_degree: HashMap<String, usize> = HashMap::new();
234 let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
235
236 for task in tasks {
237 in_degree.entry(task.id.clone()).or_insert(0);
238
239 for dep in &task.dependencies {
240 if task_ids.contains(dep) {
242 *in_degree.entry(task.id.clone()).or_insert(0) += 1;
243 dependents
244 .entry(dep.clone())
245 .or_default()
246 .push(task.id.clone());
247 }
248 }
249 }
250
251 let mut waves: Vec<Wave> = Vec::new();
253 let mut remaining = in_degree.clone();
254 let mut wave_number = 1;
255
256 while !remaining.is_empty() {
257 let ready: Vec<String> = remaining
259 .iter()
260 .filter(|(_, °)| deg == 0)
261 .map(|(id, _)| id.clone())
262 .collect();
263
264 if ready.is_empty() {
265 println!("{}", "Warning: Circular dependency detected!".red().bold());
267 println!("The following tasks have unresolved dependencies:");
268 for id in remaining.keys() {
269 if let Some(task) = tasks.iter().find(|t| &t.id == id) {
270 let unmet_deps: Vec<_> = task
271 .dependencies
272 .iter()
273 .filter(|d| remaining.contains_key(*d))
274 .collect();
275 println!(" {} depends on {:?}", id, unmet_deps);
276 }
277 }
278 break;
279 }
280
281 for task_id in &ready {
283 remaining.remove(task_id);
284
285 if let Some(deps) = dependents.get(task_id) {
286 for dep_id in deps {
287 if let Some(deg) = remaining.get_mut(dep_id) {
288 *deg = deg.saturating_sub(1);
289 }
290 }
291 }
292 }
293
294 waves.push(Wave {
295 number: wave_number,
296 tasks: ready,
297 });
298 wave_number += 1;
299 }
300
301 waves
302}
303
304fn detect_id_collisions(tasks: &[&Task]) -> Vec<(String, Vec<String>)> {
307 let mut id_to_tags: HashMap<String, Vec<String>> = HashMap::new();
308
309 for task in tasks {
310 let local_id = task.local_id().to_string();
311 let tag = task.epic_tag().unwrap_or("unknown").to_string();
312
313 id_to_tags.entry(local_id).or_default().push(tag);
314 }
315
316 let mut collisions: Vec<(String, Vec<String>)> = id_to_tags
318 .into_iter()
319 .filter(|(_, tags)| {
320 let mut unique_tags: Vec<_> = tags.to_vec();
322 unique_tags.sort();
323 unique_tags.dedup();
324 unique_tags.len() > 1
325 })
326 .map(|(id, mut tags)| {
327 tags.sort();
328 tags.dedup();
329 (id, tags)
330 })
331 .collect();
332
333 collisions.sort_by(|a, b| a.0.cmp(&b.0));
334 collisions
335}