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!".yellow().bold()
87 );
88 println!("The following local IDs exist in multiple tags:");
89 for (local_id, tags) in &collisions {
90 println!(" {} -> {}", local_id.cyan(), tags.join(", ").dimmed());
91 }
92 println!();
93 println!(
94 "{}",
95 "Tasks will be shown with full namespaced IDs (tag:id) to avoid confusion."
96 .dimmed()
97 );
98 println!();
99 }
100 }
101
102 let waves = compute_waves(&actionable, max_parallel);
104
105 println!(
107 "\n{} {}",
108 "Execution Waves".blue().bold(),
109 format!("(max {} parallel)", max_parallel).dimmed()
110 );
111 println!("{}", "=".repeat(50).blue());
112 println!();
113
114 let mut total_tasks = 0;
115 for wave in &waves {
116 total_tasks += wave.tasks.len();
117
118 let batch_info = if wave.tasks.len() > max_parallel {
119 format!(
120 " (batched into {} rounds)",
121 wave.tasks.len().div_ceil(max_parallel)
122 )
123 } else {
124 String::new()
125 };
126
127 println!(
128 "{} {} task{}{}",
129 format!("Wave {}:", wave.number).yellow().bold(),
130 wave.tasks.len(),
131 if wave.tasks.len() == 1 { "" } else { "s" },
132 batch_info.dimmed()
133 );
134
135 for (round_idx, chunk) in wave.tasks.chunks(max_parallel).enumerate() {
136 if wave.tasks.len() > max_parallel {
137 println!(" {} {}", "Round".dimmed(), round_idx + 1);
138 }
139
140 for task_id in chunk {
141 if let Some(task) = actionable.iter().find(|t| &t.id == task_id) {
143 let status_indicator = match task.status {
144 TaskStatus::Pending => "○".white(),
145 TaskStatus::InProgress => "●".cyan(),
146 TaskStatus::Blocked => "✗".red(),
147 _ => "?".dimmed(),
148 };
149
150 let deps = if task.dependencies.is_empty() {
151 String::new()
152 } else {
153 format!(" <- {}", task.dependencies.join(", "))
154 .dimmed()
155 .to_string()
156 };
157
158 let complexity = if task.complexity > 0 {
159 format!(" [{}]", task.complexity).dimmed().to_string()
160 } else {
161 String::new()
162 };
163
164 println!(
165 " {} {} {}{}{}",
166 status_indicator,
167 task_id.cyan(),
168 task.title,
169 complexity,
170 deps
171 );
172 }
173 }
174 }
175 println!();
176 }
177
178 println!("{}", "Summary".blue().bold());
180 println!("{}", "-".repeat(30).blue());
181
182 let total_waves = waves.len();
183 let total_rounds: usize = waves
184 .iter()
185 .map(|w| w.tasks.len().div_ceil(max_parallel))
186 .sum();
187
188 println!(" Total tasks: {}", total_tasks);
189 println!(" Total waves: {}", total_waves);
190 println!(" Total rounds: {}", total_rounds);
191
192 if total_tasks > 0 && total_rounds > 0 {
193 let speedup = total_tasks as f64 / total_rounds as f64;
194 println!(" Speedup: {}", format!("{:.1}x", speedup).green());
195 println!(
196 " {}",
197 format!(
198 "(from {} sequential to {} parallel rounds)",
199 total_tasks, total_rounds
200 )
201 .dimmed()
202 );
203 }
204
205 let blocked: Vec<_> = actionable
207 .iter()
208 .filter(|t| t.status == TaskStatus::Blocked)
209 .collect();
210 if !blocked.is_empty() {
211 println!();
212 println!("{}", "Blocked Tasks:".red().bold());
213 for task in blocked {
214 println!(" {} {}", task.id.red(), task.title);
215 }
216 }
217
218 println!();
219
220 Ok(())
221}
222
223fn compute_waves(tasks: &[&Task], _max_parallel: usize) -> Vec<Wave> {
226 let task_ids: HashSet<String> = tasks.iter().map(|t| t.id.clone()).collect();
229
230 let mut in_degree: HashMap<String, usize> = HashMap::new();
232 let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
233
234 for task in tasks {
235 in_degree.entry(task.id.clone()).or_insert(0);
236
237 for dep in &task.dependencies {
238 if task_ids.contains(dep) {
240 *in_degree.entry(task.id.clone()).or_insert(0) += 1;
241 dependents
242 .entry(dep.clone())
243 .or_default()
244 .push(task.id.clone());
245 }
246 }
247 }
248
249 let mut waves: Vec<Wave> = Vec::new();
251 let mut remaining = in_degree.clone();
252 let mut wave_number = 1;
253
254 while !remaining.is_empty() {
255 let ready: Vec<String> = remaining
257 .iter()
258 .filter(|(_, °)| deg == 0)
259 .map(|(id, _)| id.clone())
260 .collect();
261
262 if ready.is_empty() {
263 println!("{}", "Warning: Circular dependency detected!".red().bold());
265 println!("The following tasks have unresolved dependencies:");
266 for id in remaining.keys() {
267 if let Some(task) = tasks.iter().find(|t| &t.id == id) {
268 let unmet_deps: Vec<_> = task
269 .dependencies
270 .iter()
271 .filter(|d| remaining.contains_key(*d))
272 .collect();
273 println!(" {} depends on {:?}", id, unmet_deps);
274 }
275 }
276 break;
277 }
278
279 for task_id in &ready {
281 remaining.remove(task_id);
282
283 if let Some(deps) = dependents.get(task_id) {
284 for dep_id in deps {
285 if let Some(deg) = remaining.get_mut(dep_id) {
286 *deg = deg.saturating_sub(1);
287 }
288 }
289 }
290 }
291
292 waves.push(Wave {
293 number: wave_number,
294 tasks: ready,
295 });
296 wave_number += 1;
297 }
298
299 waves
300}
301
302fn detect_id_collisions(tasks: &[&Task]) -> Vec<(String, Vec<String>)> {
305 let mut id_to_tags: HashMap<String, Vec<String>> = HashMap::new();
306
307 for task in tasks {
308 let local_id = task.local_id().to_string();
309 let tag = task.epic_tag().unwrap_or("unknown").to_string();
310
311 id_to_tags.entry(local_id).or_default().push(tag);
312 }
313
314 let mut collisions: Vec<(String, Vec<String>)> = id_to_tags
316 .into_iter()
317 .filter(|(_, tags)| {
318 let mut unique_tags: Vec<_> = tags.iter().cloned().collect();
320 unique_tags.sort();
321 unique_tags.dedup();
322 unique_tags.len() > 1
323 })
324 .map(|(id, mut tags)| {
325 tags.sort();
326 tags.dedup();
327 (id, tags)
328 })
329 .collect();
330
331 collisions.sort_by(|a, b| a.0.cmp(&b.0));
332 collisions
333}