scud/commands/
waves.rs

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    // Validate max_parallel to prevent divide-by-zero panics
22    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    // Determine which phase(s) to plan
30    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        // Use active phase
39        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    // Collect actionable tasks from specified phase(s)
47    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                // Only include actionable tasks (not done, not expanded parents, not cancelled)
52                if task.status != TaskStatus::Done
53                    && task.status != TaskStatus::Expanded
54                    && task.status != TaskStatus::Cancelled
55                {
56                    // If it's a subtask, only include if parent is expanded
57                    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                        // Top-level task that's not expanded
67                        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    // Check for ID collisions when using --all-tags
81    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    // Build dependency graph and compute waves
105    let waves = compute_waves(&actionable, max_parallel);
106
107    // Display waves
108    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                // Find task details
144                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    // Summary
181    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    // Show blocked tasks if any
208    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
225/// Compute execution waves using Kahn's algorithm (topological sort with level assignment)
226/// When processing tasks from multiple phases, we namespace task IDs to avoid collisions
227fn compute_waves(tasks: &[&Task], _max_parallel: usize) -> Vec<Wave> {
228    // Build a map from task pointer to its namespaced ID
229    // This handles the case where multiple phases have tasks with the same local ID
230    let task_ids: HashSet<String> = tasks.iter().map(|t| t.id.clone()).collect();
231
232    // Build in-degree map (how many dependencies does each task have within our set?)
233    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            // Only count dependencies that are in our actionable task set
241            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    // Kahn's algorithm with wave tracking
252    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        // Find all tasks with no remaining dependencies (in-degree = 0)
258        let ready: Vec<String> = remaining
259            .iter()
260            .filter(|(_, &deg)| deg == 0)
261            .map(|(id, _)| id.clone())
262            .collect();
263
264        if ready.is_empty() {
265            // Circular dependency detected
266            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        // Remove ready tasks from remaining and update dependents
282        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
304/// Detect ID collisions when merging tasks from multiple phases
305/// Returns a list of (local_id, Vec<tag>) for IDs that appear in multiple tags
306fn 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    // Filter to only those with collisions (same local ID in multiple tags)
317    let mut collisions: Vec<(String, Vec<String>)> = id_to_tags
318        .into_iter()
319        .filter(|(_, tags)| {
320            // Dedupe tags and check if more than one unique tag
321            let mut unique_tags: Vec<_> = tags.iter().cloned().collect();
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}