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                    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    // Summary
188    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    // Show blocked tasks if any
215    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
232/// Compute execution waves using Kahn's algorithm (topological sort with level assignment)
233/// When processing tasks from multiple phases, we namespace task IDs to avoid collisions
234fn compute_waves(tasks: &[&Task], _max_parallel: usize) -> Vec<Wave> {
235    // Build a map from task pointer to its namespaced ID
236    // This handles the case where multiple phases have tasks with the same local ID
237    let task_ids: HashSet<String> = tasks.iter().map(|t| t.id.clone()).collect();
238
239    // Build in-degree map (how many dependencies does each task have within our set?)
240    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            // Only count dependencies that are in our actionable task set
248            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    // Kahn's algorithm with wave tracking
259    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        // Find all tasks with no remaining dependencies (in-degree = 0)
265        let ready: Vec<String> = remaining
266            .iter()
267            .filter(|(_, &deg)| deg == 0)
268            .map(|(id, _)| id.clone())
269            .collect();
270
271        if ready.is_empty() {
272            // Circular dependency detected
273            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        // Remove ready tasks from remaining and update dependents
289        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
311/// Detect ID collisions when merging tasks from multiple phases
312/// Returns a list of (local_id, Vec<tag>) for IDs that appear in multiple tags
313fn 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    // Filter to only those with collisions (same local ID in multiple tags)
324    let mut collisions: Vec<(String, Vec<String>)> = id_to_tags
325        .into_iter()
326        .filter(|(_, tags)| {
327            // Dedupe tags and check if more than one unique tag
328            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}