scud/commands/
next.rs

1use anyhow::Result;
2use colored::Colorize;
3use std::path::PathBuf;
4
5use crate::commands::helpers::{flatten_all_tasks, resolve_group_tag};
6use crate::models::task::{Task, TaskStatus};
7use crate::storage::Storage;
8
9/// Result of finding the next task
10pub enum NextTaskResult<'a> {
11    /// Found a task with dependencies met
12    Available(&'a crate::models::task::Task),
13    /// No pending tasks at all
14    NoPendingTasks,
15    /// Pending tasks exist but blocked by dependencies
16    BlockedByDependencies,
17}
18
19/// Find the next available task
20/// all_tasks should contain tasks from all phases for cross-tag dependency resolution
21pub fn find_next_available<'a>(
22    phase: &'a crate::models::phase::Phase,
23    all_tasks: &[&Task],
24) -> NextTaskResult<'a> {
25    let pending_tasks: Vec<_> = phase
26        .tasks
27        .iter()
28        .filter(|t| t.status == TaskStatus::Pending)
29        .collect();
30
31    if pending_tasks.is_empty() {
32        return NextTaskResult::NoPendingTasks;
33    }
34
35    // Find tasks with dependencies met (checking across all phases)
36    let deps_met: Vec<_> = pending_tasks
37        .iter()
38        .filter(|t| t.has_dependencies_met_refs(all_tasks))
39        .collect();
40
41    if deps_met.is_empty() {
42        return NextTaskResult::BlockedByDependencies;
43    }
44
45    NextTaskResult::Available(deps_met[0])
46}
47
48pub fn run(
49    project_root: Option<PathBuf>,
50    tag: Option<&str>,
51    spawn: bool,
52    all_tags: bool,
53) -> Result<()> {
54    let storage = Storage::new(project_root);
55    let tasks = storage.load_tasks()?;
56    let all_tasks_flat = flatten_all_tasks(&tasks);
57
58    if all_tags {
59        // Search across ALL phases for the next available task
60        run_all_tags(&tasks, &all_tasks_flat, spawn)
61    } else {
62        // Standard single-phase behavior
63        let phase_tag = resolve_group_tag(&storage, tag, true)?;
64        let phase = tasks
65            .get(&phase_tag)
66            .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
67
68        run_single_phase(phase, &phase_tag, &all_tasks_flat, spawn)
69    }
70}
71
72fn run_single_phase(
73    phase: &crate::models::phase::Phase,
74    phase_tag: &str,
75    all_tasks_flat: &[&Task],
76    spawn: bool,
77) -> Result<()> {
78    // Handle --spawn mode (machine-readable JSON output)
79    if spawn {
80        match find_next_available(phase, all_tasks_flat) {
81            NextTaskResult::Available(task) => {
82                let output = serde_json::json!({
83                    "task_id": task.id,
84                    "title": task.title,
85                    "tag": phase_tag,
86                    "complexity": task.complexity,
87                });
88                println!("{}", serde_json::to_string(&output)?);
89            }
90            _ => {
91                println!("null");
92            }
93        }
94        return Ok(());
95    }
96
97    match find_next_available(phase, all_tasks_flat) {
98        NextTaskResult::Available(task) => {
99            print_task_details(task);
100            print_standard_instructions(&task.id);
101        }
102        NextTaskResult::NoPendingTasks => {
103            println!("{}", "All tasks completed or in progress!".green().bold());
104            println!("Run: scud list --status in-progress");
105        }
106        NextTaskResult::BlockedByDependencies => {
107            println!(
108                "{}",
109                "No available tasks - all pending tasks blocked by dependencies".yellow()
110            );
111            println!("Run: scud list --status pending");
112            println!("Run: scud doctor  # to diagnose stuck states");
113        }
114    }
115
116    Ok(())
117}
118
119fn run_all_tags(
120    all_phases: &std::collections::HashMap<String, crate::models::phase::Phase>,
121    all_tasks_flat: &[&Task],
122    spawn: bool,
123) -> Result<()> {
124    // Collect pending tasks from ALL phases, filtering for actionable ones
125    let mut pending_tasks: Vec<(&Task, &str)> = Vec::new();
126
127    for (tag, phase) in all_phases {
128        for task in &phase.tasks {
129            // Only include pending, non-expanded tasks
130            if task.status == TaskStatus::Pending {
131                // If it's a subtask, only include if parent is expanded
132                if let Some(ref parent_id) = task.parent_id {
133                    let parent_expanded = phase
134                        .get_task(parent_id)
135                        .map(|p| p.is_expanded())
136                        .unwrap_or(false);
137                    if parent_expanded {
138                        pending_tasks.push((task, tag.as_str()));
139                    }
140                } else if !task.is_expanded() {
141                    // Top-level task that's not expanded
142                    pending_tasks.push((task, tag.as_str()));
143                }
144            }
145        }
146    }
147
148    // Find first task with all dependencies met (including cross-tag and inherited)
149    let available = pending_tasks
150        .iter()
151        .find(|(task, _)| task.has_dependencies_met_refs(all_tasks_flat));
152
153    if spawn {
154        match available {
155            Some((task, tag)) => {
156                let output = serde_json::json!({
157                    "task_id": task.id,
158                    "title": task.title,
159                    "tag": tag,
160                    "complexity": task.complexity,
161                });
162                println!("{}", serde_json::to_string(&output)?);
163            }
164            None => {
165                println!("null");
166            }
167        }
168        return Ok(());
169    }
170
171    match available {
172        Some((task, tag)) => {
173            println!("{} {}", "Phase:".dimmed(), tag.cyan());
174            print_task_details(task);
175            print_standard_instructions(&task.id);
176        }
177        None => {
178            if pending_tasks.is_empty() {
179                println!("{}", "All tasks completed or in progress!".green().bold());
180                println!("Run: scud list --status in-progress");
181            } else {
182                println!(
183                    "{}",
184                    "No available tasks - all pending tasks blocked by dependencies".yellow()
185                );
186                println!(
187                    "Pending tasks exist in {} phase(s), but all are blocked.",
188                    pending_tasks.len()
189                );
190                println!("Run: scud waves --all-tags  # to see dependency graph");
191                println!("Run: scud doctor  # to diagnose stuck states");
192            }
193        }
194    }
195
196    Ok(())
197}
198
199fn print_task_details(task: &crate::models::task::Task) {
200    println!("{}", "Next Available Task:".green().bold());
201    println!();
202    println!("{:<20} {}", "ID:".yellow(), task.id.cyan());
203    println!("{:<20} {}", "Title:".yellow(), task.title.bold());
204    println!("{:<20} {}", "Complexity:".yellow(), task.complexity);
205    println!("{:<20} {:?}", "Priority:".yellow(), task.priority);
206
207    if let Some(ref assigned) = task.assigned_to {
208        println!("{:<20} {}", "Assigned to:".yellow(), assigned.green());
209    }
210
211    println!();
212    println!("{}", "Description:".yellow());
213    println!("{}", task.description);
214
215    if let Some(details) = &task.details {
216        println!();
217        println!("{}", "Technical Details:".yellow());
218        println!("{}", details);
219    }
220
221    if let Some(test_strategy) = &task.test_strategy {
222        println!();
223        println!("{}", "Test Strategy:".yellow());
224        println!("{}", test_strategy);
225    }
226}
227
228fn print_standard_instructions(task_id: &str) {
229    println!();
230    println!("{}", "To start this task:".blue());
231    println!("  scud set-status {} in-progress", task_id);
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::models::phase::Phase;
238    use crate::models::task::{Task, TaskStatus};
239
240    fn create_test_phase() -> Phase {
241        let mut phase = Phase::new("test-phase".to_string());
242
243        let mut task1 = Task::new("1".to_string(), "Task 1".to_string(), "Desc 1".to_string());
244        task1.set_status(TaskStatus::Done);
245
246        let mut task2 = Task::new("2".to_string(), "Task 2".to_string(), "Desc 2".to_string());
247        task2.dependencies = vec!["1".to_string()];
248        // task2 is pending with deps met
249
250        let mut task3 = Task::new("3".to_string(), "Task 3".to_string(), "Desc 3".to_string());
251        task3.dependencies = vec!["2".to_string()];
252        // task3 is pending with deps NOT met
253
254        phase.add_task(task1);
255        phase.add_task(task2);
256        phase.add_task(task3);
257
258        phase
259    }
260
261    /// Helper to get task refs from phase for testing
262    fn get_task_refs(phase: &Phase) -> Vec<&Task> {
263        phase.tasks.iter().collect()
264    }
265
266    #[test]
267    fn test_find_next_available_basic() {
268        let phase = create_test_phase();
269        let all_tasks = get_task_refs(&phase);
270
271        match find_next_available(&phase, &all_tasks) {
272            NextTaskResult::Available(task) => {
273                assert_eq!(task.id, "2");
274            }
275            _ => panic!("Expected Available result"),
276        }
277    }
278
279    #[test]
280    fn test_find_next_no_pending() {
281        let mut phase = Phase::new("test".to_string());
282        let mut task = Task::new("1".to_string(), "Done".to_string(), "Desc".to_string());
283        task.set_status(TaskStatus::Done);
284        phase.add_task(task);
285
286        let all_tasks = get_task_refs(&phase);
287
288        match find_next_available(&phase, &all_tasks) {
289            NextTaskResult::NoPendingTasks => {}
290            _ => panic!("Expected NoPendingTasks result"),
291        }
292    }
293
294    #[test]
295    fn test_find_next_blocked_by_deps() {
296        let mut phase = Phase::new("test".to_string());
297
298        let task1 = Task::new("1".to_string(), "Task 1".to_string(), "Desc".to_string());
299        // task1 is pending
300
301        let mut task2 = Task::new("2".to_string(), "Task 2".to_string(), "Desc".to_string());
302        task2.dependencies = vec!["1".to_string()];
303        // task2 depends on pending task1
304
305        // Add task2 first, task1 second (so task2 is checked first)
306        phase.add_task(task2);
307        phase.add_task(task1);
308
309        let all_tasks = get_task_refs(&phase);
310
311        // task1 should be found since it has no deps
312        match find_next_available(&phase, &all_tasks) {
313            NextTaskResult::Available(task) => {
314                assert_eq!(task.id, "1");
315            }
316            _ => panic!("Expected task 1 to be available"),
317        }
318    }
319
320    #[test]
321    fn test_find_next_all_blocked() {
322        let mut phase = Phase::new("test".to_string());
323
324        let mut task1 = Task::new("1".to_string(), "Task 1".to_string(), "Desc".to_string());
325        task1.dependencies = vec!["nonexistent".to_string()];
326        // task1 depends on non-existent task
327
328        phase.add_task(task1);
329
330        let all_tasks = get_task_refs(&phase);
331
332        match find_next_available(&phase, &all_tasks) {
333            NextTaskResult::BlockedByDependencies => {}
334            _ => panic!("Expected BlockedByDependencies result"),
335        }
336    }
337
338    #[test]
339    fn test_find_next_cross_tag_dependency() {
340        // Create a phase with a task that depends on a task from another "phase"
341        let mut phase = Phase::new("api".to_string());
342        let mut api_task = Task::new(
343            "api:1".to_string(),
344            "API Task".to_string(),
345            "Desc".to_string(),
346        );
347        api_task.dependencies = vec!["auth:1".to_string()]; // Depends on auth phase
348        phase.add_task(api_task);
349
350        // Create "auth" task (simulating another phase)
351        let mut auth_task = Task::new(
352            "auth:1".to_string(),
353            "Auth Task".to_string(),
354            "Desc".to_string(),
355        );
356        auth_task.set_status(TaskStatus::Done);
357
358        // Combine all tasks (simulating flattened all_phases)
359        let all_tasks: Vec<&Task> = vec![&phase.tasks[0], &auth_task];
360
361        // With cross-tag tasks included, dependency should be met
362        match find_next_available(&phase, &all_tasks) {
363            NextTaskResult::Available(task) => {
364                assert_eq!(task.id, "api:1");
365            }
366            _ => panic!("Expected Available result with cross-tag dependency met"),
367        }
368    }
369
370    #[test]
371    fn test_find_next_cross_tag_dependency_not_met() {
372        // Create a phase with a task that depends on a task from another "phase"
373        let mut phase = Phase::new("api".to_string());
374        let mut api_task = Task::new(
375            "api:1".to_string(),
376            "API Task".to_string(),
377            "Desc".to_string(),
378        );
379        api_task.dependencies = vec!["auth:1".to_string()]; // Depends on auth phase
380        phase.add_task(api_task);
381
382        // Create "auth" task (NOT done)
383        let auth_task = Task::new(
384            "auth:1".to_string(),
385            "Auth Task".to_string(),
386            "Desc".to_string(),
387        );
388
389        // Combine all tasks (simulating flattened all_phases)
390        let all_tasks: Vec<&Task> = vec![&phase.tasks[0], &auth_task];
391
392        // With cross-tag dep NOT met, should be blocked
393        match find_next_available(&phase, &all_tasks) {
394            NextTaskResult::BlockedByDependencies => {}
395            _ => panic!("Expected BlockedByDependencies with cross-tag dep not met"),
396        }
397    }
398}