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    // Build dependency graph and compute waves
81    let waves = compute_waves(&actionable, max_parallel);
82
83    // Display waves
84    println!(
85        "\n{} {}",
86        "Execution Waves".blue().bold(),
87        format!("(max {} parallel)", max_parallel).dimmed()
88    );
89    println!("{}", "=".repeat(50).blue());
90    println!();
91
92    let mut total_tasks = 0;
93    for wave in &waves {
94        total_tasks += wave.tasks.len();
95
96        let batch_info = if wave.tasks.len() > max_parallel {
97            format!(
98                " (batched into {} rounds)",
99                wave.tasks.len().div_ceil(max_parallel)
100            )
101        } else {
102            String::new()
103        };
104
105        println!(
106            "{} {} task{}{}",
107            format!("Wave {}:", wave.number).yellow().bold(),
108            wave.tasks.len(),
109            if wave.tasks.len() == 1 { "" } else { "s" },
110            batch_info.dimmed()
111        );
112
113        for (round_idx, chunk) in wave.tasks.chunks(max_parallel).enumerate() {
114            if wave.tasks.len() > max_parallel {
115                println!("  {} {}", "Round".dimmed(), round_idx + 1);
116            }
117
118            for task_id in chunk {
119                // Find task details
120                if let Some(task) = actionable.iter().find(|t| &t.id == task_id) {
121                    let status_indicator = match task.status {
122                        TaskStatus::Pending => "○".white(),
123                        TaskStatus::InProgress => "●".cyan(),
124                        TaskStatus::Blocked => "✗".red(),
125                        _ => "?".dimmed(),
126                    };
127
128                    let deps = if task.dependencies.is_empty() {
129                        String::new()
130                    } else {
131                        format!(" <- {}", task.dependencies.join(", "))
132                            .dimmed()
133                            .to_string()
134                    };
135
136                    let complexity = if task.complexity > 0 {
137                        format!(" [{}]", task.complexity).dimmed().to_string()
138                    } else {
139                        String::new()
140                    };
141
142                    println!(
143                        "    {} {} {}{}{}",
144                        status_indicator,
145                        task_id.cyan(),
146                        task.title,
147                        complexity,
148                        deps
149                    );
150                }
151            }
152        }
153        println!();
154    }
155
156    // Summary
157    println!("{}", "Summary".blue().bold());
158    println!("{}", "-".repeat(30).blue());
159
160    let total_waves = waves.len();
161    let total_rounds: usize = waves
162        .iter()
163        .map(|w| w.tasks.len().div_ceil(max_parallel))
164        .sum();
165
166    println!("  Total tasks:   {}", total_tasks);
167    println!("  Total waves:   {}", total_waves);
168    println!("  Total rounds:  {}", total_rounds);
169
170    if total_tasks > 0 && total_rounds > 0 {
171        let speedup = total_tasks as f64 / total_rounds as f64;
172        println!("  Speedup:       {}", format!("{:.1}x", speedup).green());
173        println!(
174            "  {}",
175            format!(
176                "(from {} sequential to {} parallel rounds)",
177                total_tasks, total_rounds
178            )
179            .dimmed()
180        );
181    }
182
183    // Show blocked tasks if any
184    let blocked: Vec<_> = actionable
185        .iter()
186        .filter(|t| t.status == TaskStatus::Blocked)
187        .collect();
188    if !blocked.is_empty() {
189        println!();
190        println!("{}", "Blocked Tasks:".red().bold());
191        for task in blocked {
192            println!("  {} {}", task.id.red(), task.title);
193        }
194    }
195
196    println!();
197
198    Ok(())
199}
200
201/// Compute execution waves using Kahn's algorithm (topological sort with level assignment)
202/// When processing tasks from multiple phases, we namespace task IDs to avoid collisions
203fn compute_waves(tasks: &[&Task], _max_parallel: usize) -> Vec<Wave> {
204    // Build a map from task pointer to its namespaced ID
205    // This handles the case where multiple phases have tasks with the same local ID
206    let task_ids: HashSet<String> = tasks.iter().map(|t| t.id.clone()).collect();
207
208    // Build in-degree map (how many dependencies does each task have within our set?)
209    let mut in_degree: HashMap<String, usize> = HashMap::new();
210    let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
211
212    for task in tasks {
213        in_degree.entry(task.id.clone()).or_insert(0);
214
215        for dep in &task.dependencies {
216            // Only count dependencies that are in our actionable task set
217            if task_ids.contains(dep) {
218                *in_degree.entry(task.id.clone()).or_insert(0) += 1;
219                dependents
220                    .entry(dep.clone())
221                    .or_default()
222                    .push(task.id.clone());
223            }
224        }
225    }
226
227    // Kahn's algorithm with wave tracking
228    let mut waves: Vec<Wave> = Vec::new();
229    let mut remaining = in_degree.clone();
230    let mut wave_number = 1;
231
232    while !remaining.is_empty() {
233        // Find all tasks with no remaining dependencies (in-degree = 0)
234        let ready: Vec<String> = remaining
235            .iter()
236            .filter(|(_, &deg)| deg == 0)
237            .map(|(id, _)| id.clone())
238            .collect();
239
240        if ready.is_empty() {
241            // Circular dependency detected
242            println!("{}", "Warning: Circular dependency detected!".red().bold());
243            println!("The following tasks have unresolved dependencies:");
244            for id in remaining.keys() {
245                if let Some(task) = tasks.iter().find(|t| &t.id == id) {
246                    let unmet_deps: Vec<_> = task
247                        .dependencies
248                        .iter()
249                        .filter(|d| remaining.contains_key(*d))
250                        .collect();
251                    println!("  {} depends on {:?}", id, unmet_deps);
252                }
253            }
254            break;
255        }
256
257        // Remove ready tasks from remaining and update dependents
258        for task_id in &ready {
259            remaining.remove(task_id);
260
261            if let Some(deps) = dependents.get(task_id) {
262                for dep_id in deps {
263                    if let Some(deg) = remaining.get_mut(dep_id) {
264                        *deg = deg.saturating_sub(1);
265                    }
266                }
267            }
268        }
269
270        waves.push(Wave {
271            number: wave_number,
272            tasks: ready,
273        });
274        wave_number += 1;
275    }
276
277    waves
278}