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!".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    // Build dependency graph and compute waves
103    let waves = compute_waves(&actionable, max_parallel);
104
105    // Display waves
106    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                // Find task details
142                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    // Summary
179    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    // Show blocked tasks if any
206    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
223/// Compute execution waves using Kahn's algorithm (topological sort with level assignment)
224/// When processing tasks from multiple phases, we namespace task IDs to avoid collisions
225fn compute_waves(tasks: &[&Task], _max_parallel: usize) -> Vec<Wave> {
226    // Build a map from task pointer to its namespaced ID
227    // This handles the case where multiple phases have tasks with the same local ID
228    let task_ids: HashSet<String> = tasks.iter().map(|t| t.id.clone()).collect();
229
230    // Build in-degree map (how many dependencies does each task have within our set?)
231    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            // Only count dependencies that are in our actionable task set
239            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    // Kahn's algorithm with wave tracking
250    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        // Find all tasks with no remaining dependencies (in-degree = 0)
256        let ready: Vec<String> = remaining
257            .iter()
258            .filter(|(_, &deg)| deg == 0)
259            .map(|(id, _)| id.clone())
260            .collect();
261
262        if ready.is_empty() {
263            // Circular dependency detected
264            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        // Remove ready tasks from remaining and update dependents
280        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
302/// Detect ID collisions when merging tasks from multiple phases
303/// Returns a list of (local_id, Vec<tag>) for IDs that appear in multiple tags
304fn 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    // Filter to only those with collisions (same local ID in multiple tags)
315    let mut collisions: Vec<(String, Vec<String>)> = id_to_tags
316        .into_iter()
317        .filter(|(_, tags)| {
318            // Dedupe tags and check if more than one unique tag
319            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}