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 let agent = if let Some(ref agent_type) = task.agent_type {
167 format!(" @{}", agent_type).dimmed().to_string()
168 } else {
169 String::new()
170 };
171
172 println!(
173 " {} {} {}{}{}{}",
174 status_indicator,
175 task_id.cyan(),
176 task.title,
177 complexity,
178 agent,
179 deps
180 );
181 }
182 }
183 }
184 println!();
185 }
186
187 println!("{}", "Summary".blue().bold());
189 println!("{}", "-".repeat(30).blue());
190
191 let total_waves = waves.len();
192 let total_rounds: usize = waves
193 .iter()
194 .map(|w| w.tasks.len().div_ceil(max_parallel))
195 .sum();
196
197 println!(" Total tasks: {}", total_tasks);
198 println!(" Total waves: {}", total_waves);
199 println!(" Total rounds: {}", total_rounds);
200
201 if total_tasks > 0 && total_rounds > 0 {
202 let speedup = total_tasks as f64 / total_rounds as f64;
203 println!(" Speedup: {}", format!("{:.1}x", speedup).green());
204 println!(
205 " {}",
206 format!(
207 "(from {} sequential to {} parallel rounds)",
208 total_tasks, total_rounds
209 )
210 .dimmed()
211 );
212 }
213
214 let blocked: Vec<_> = actionable
216 .iter()
217 .filter(|t| t.status == TaskStatus::Blocked)
218 .collect();
219 if !blocked.is_empty() {
220 println!();
221 println!("{}", "Blocked Tasks:".red().bold());
222 for task in blocked {
223 println!(" {} {}", task.id.red(), task.title);
224 }
225 }
226
227 println!();
228
229 Ok(())
230}
231
232fn compute_waves(tasks: &[&Task], _max_parallel: usize) -> Vec<Wave> {
235 let task_ids: HashSet<String> = tasks.iter().map(|t| t.id.clone()).collect();
238
239 let mut in_degree: HashMap<String, usize> = HashMap::new();
241 let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
242
243 for task in tasks {
244 in_degree.entry(task.id.clone()).or_insert(0);
245
246 for dep in &task.dependencies {
247 if task_ids.contains(dep) {
249 *in_degree.entry(task.id.clone()).or_insert(0) += 1;
250 dependents
251 .entry(dep.clone())
252 .or_default()
253 .push(task.id.clone());
254 }
255 }
256 }
257
258 let mut waves: Vec<Wave> = Vec::new();
260 let mut remaining = in_degree.clone();
261 let mut wave_number = 1;
262
263 while !remaining.is_empty() {
264 let ready: Vec<String> = remaining
266 .iter()
267 .filter(|(_, °)| deg == 0)
268 .map(|(id, _)| id.clone())
269 .collect();
270
271 if ready.is_empty() {
272 println!("{}", "Warning: Circular dependency detected!".red().bold());
274 println!("The following tasks have unresolved dependencies:");
275 for id in remaining.keys() {
276 if let Some(task) = tasks.iter().find(|t| &t.id == id) {
277 let unmet_deps: Vec<_> = task
278 .dependencies
279 .iter()
280 .filter(|d| remaining.contains_key(*d))
281 .collect();
282 println!(" {} depends on {:?}", id, unmet_deps);
283 }
284 }
285 break;
286 }
287
288 for task_id in &ready {
290 remaining.remove(task_id);
291
292 if let Some(deps) = dependents.get(task_id) {
293 for dep_id in deps {
294 if let Some(deg) = remaining.get_mut(dep_id) {
295 *deg = deg.saturating_sub(1);
296 }
297 }
298 }
299 }
300
301 waves.push(Wave {
302 number: wave_number,
303 tasks: ready,
304 });
305 wave_number += 1;
306 }
307
308 waves
309}
310
311fn detect_id_collisions(tasks: &[&Task]) -> Vec<(String, Vec<String>)> {
314 let mut id_to_tags: HashMap<String, Vec<String>> = HashMap::new();
315
316 for task in tasks {
317 let local_id = task.local_id().to_string();
318 let tag = task.epic_tag().unwrap_or("unknown").to_string();
319
320 id_to_tags.entry(local_id).or_default().push(tag);
321 }
322
323 let mut collisions: Vec<(String, Vec<String>)> = id_to_tags
325 .into_iter()
326 .filter(|(_, tags)| {
327 let mut unique_tags: Vec<_> = tags.to_vec();
329 unique_tags.sort();
330 unique_tags.dedup();
331 unique_tags.len() > 1
332 })
333 .map(|(id, mut tags)| {
334 tags.sort();
335 tags.dedup();
336 (id, tags)
337 })
338 .collect();
339
340 collisions.sort_by(|a, b| a.0.cmp(&b.0));
341 collisions
342}