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!(
174                "{} {}",
175                "Phase:".dimmed(),
176                tag.cyan()
177            );
178            print_task_details(task);
179            print_standard_instructions(&task.id);
180        }
181        None => {
182            if pending_tasks.is_empty() {
183                println!("{}", "All tasks completed or in progress!".green().bold());
184                println!("Run: scud list --status in-progress");
185            } else {
186                println!(
187                    "{}",
188                    "No available tasks - all pending tasks blocked by dependencies".yellow()
189                );
190                println!("Pending tasks exist in {} phase(s), but all are blocked.", pending_tasks.len());
191                println!("Run: scud waves --all-tags  # to see dependency graph");
192                println!("Run: scud doctor  # to diagnose stuck states");
193            }
194        }
195    }
196
197    Ok(())
198}
199
200fn print_task_details(task: &crate::models::task::Task) {
201    println!("{}", "Next Available Task:".green().bold());
202    println!();
203    println!("{:<20} {}", "ID:".yellow(), task.id.cyan());
204    println!("{:<20} {}", "Title:".yellow(), task.title.bold());
205    println!("{:<20} {}", "Complexity:".yellow(), task.complexity);
206    println!("{:<20} {:?}", "Priority:".yellow(), task.priority);
207
208    if let Some(ref assigned) = task.assigned_to {
209        println!("{:<20} {}", "Assigned to:".yellow(), assigned.green());
210    }
211
212    println!();
213    println!("{}", "Description:".yellow());
214    println!("{}", task.description);
215
216    if let Some(details) = &task.details {
217        println!();
218        println!("{}", "Technical Details:".yellow());
219        println!("{}", details);
220    }
221
222    if let Some(test_strategy) = &task.test_strategy {
223        println!();
224        println!("{}", "Test Strategy:".yellow());
225        println!("{}", test_strategy);
226    }
227}
228
229fn print_standard_instructions(task_id: &str) {
230    println!();
231    println!("{}", "To start this task:".blue());
232    println!("  scud set-status {} in-progress", task_id);
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::models::phase::Phase;
239    use crate::models::task::{Task, TaskStatus};
240
241    fn create_test_phase() -> Phase {
242        let mut phase = Phase::new("test-phase".to_string());
243
244        let mut task1 = Task::new("1".to_string(), "Task 1".to_string(), "Desc 1".to_string());
245        task1.set_status(TaskStatus::Done);
246
247        let mut task2 = Task::new("2".to_string(), "Task 2".to_string(), "Desc 2".to_string());
248        task2.dependencies = vec!["1".to_string()];
249        // task2 is pending with deps met
250
251        let mut task3 = Task::new("3".to_string(), "Task 3".to_string(), "Desc 3".to_string());
252        task3.dependencies = vec!["2".to_string()];
253        // task3 is pending with deps NOT met
254
255        phase.add_task(task1);
256        phase.add_task(task2);
257        phase.add_task(task3);
258
259        phase
260    }
261
262    /// Helper to get task refs from phase for testing
263    fn get_task_refs(phase: &Phase) -> Vec<&Task> {
264        phase.tasks.iter().collect()
265    }
266
267    #[test]
268    fn test_find_next_available_basic() {
269        let phase = create_test_phase();
270        let all_tasks = get_task_refs(&phase);
271
272        match find_next_available(&phase, &all_tasks) {
273            NextTaskResult::Available(task) => {
274                assert_eq!(task.id, "2");
275            }
276            _ => panic!("Expected Available result"),
277        }
278    }
279
280    #[test]
281    fn test_find_next_no_pending() {
282        let mut phase = Phase::new("test".to_string());
283        let mut task = Task::new("1".to_string(), "Done".to_string(), "Desc".to_string());
284        task.set_status(TaskStatus::Done);
285        phase.add_task(task);
286
287        let all_tasks = get_task_refs(&phase);
288
289        match find_next_available(&phase, &all_tasks) {
290            NextTaskResult::NoPendingTasks => {}
291            _ => panic!("Expected NoPendingTasks result"),
292        }
293    }
294
295    #[test]
296    fn test_find_next_blocked_by_deps() {
297        let mut phase = Phase::new("test".to_string());
298
299        let task1 = Task::new("1".to_string(), "Task 1".to_string(), "Desc".to_string());
300        // task1 is pending
301
302        let mut task2 = Task::new("2".to_string(), "Task 2".to_string(), "Desc".to_string());
303        task2.dependencies = vec!["1".to_string()];
304        // task2 depends on pending task1
305
306        // Add task2 first, task1 second (so task2 is checked first)
307        phase.add_task(task2);
308        phase.add_task(task1);
309
310        let all_tasks = get_task_refs(&phase);
311
312        // task1 should be found since it has no deps
313        match find_next_available(&phase, &all_tasks) {
314            NextTaskResult::Available(task) => {
315                assert_eq!(task.id, "1");
316            }
317            _ => panic!("Expected task 1 to be available"),
318        }
319    }
320
321    #[test]
322    fn test_find_next_all_blocked() {
323        let mut phase = Phase::new("test".to_string());
324
325        let mut task1 = Task::new("1".to_string(), "Task 1".to_string(), "Desc".to_string());
326        task1.dependencies = vec!["nonexistent".to_string()];
327        // task1 depends on non-existent task
328
329        phase.add_task(task1);
330
331        let all_tasks = get_task_refs(&phase);
332
333        match find_next_available(&phase, &all_tasks) {
334            NextTaskResult::BlockedByDependencies => {}
335            _ => panic!("Expected BlockedByDependencies result"),
336        }
337    }
338
339    #[test]
340    fn test_find_next_cross_tag_dependency() {
341        // Create a phase with a task that depends on a task from another "phase"
342        let mut phase = Phase::new("api".to_string());
343        let mut api_task = Task::new(
344            "api:1".to_string(),
345            "API Task".to_string(),
346            "Desc".to_string(),
347        );
348        api_task.dependencies = vec!["auth:1".to_string()]; // Depends on auth phase
349        phase.add_task(api_task);
350
351        // Create "auth" task (simulating another phase)
352        let mut auth_task = Task::new(
353            "auth:1".to_string(),
354            "Auth Task".to_string(),
355            "Desc".to_string(),
356        );
357        auth_task.set_status(TaskStatus::Done);
358
359        // Combine all tasks (simulating flattened all_phases)
360        let all_tasks: Vec<&Task> = vec![&phase.tasks[0], &auth_task];
361
362        // With cross-tag tasks included, dependency should be met
363        match find_next_available(&phase, &all_tasks) {
364            NextTaskResult::Available(task) => {
365                assert_eq!(task.id, "api:1");
366            }
367            _ => panic!("Expected Available result with cross-tag dependency met"),
368        }
369    }
370
371    #[test]
372    fn test_find_next_cross_tag_dependency_not_met() {
373        // Create a phase with a task that depends on a task from another "phase"
374        let mut phase = Phase::new("api".to_string());
375        let mut api_task = Task::new(
376            "api:1".to_string(),
377            "API Task".to_string(),
378            "Desc".to_string(),
379        );
380        api_task.dependencies = vec!["auth:1".to_string()]; // Depends on auth phase
381        phase.add_task(api_task);
382
383        // Create "auth" task (NOT done)
384        let auth_task = Task::new(
385            "auth:1".to_string(),
386            "Auth Task".to_string(),
387            "Desc".to_string(),
388        );
389
390        // Combine all tasks (simulating flattened all_phases)
391        let all_tasks: Vec<&Task> = vec![&phase.tasks[0], &auth_task];
392
393        // With cross-tag dep NOT met, should be blocked
394        match find_next_available(&phase, &all_tasks) {
395            NextTaskResult::BlockedByDependencies => {}
396            _ => panic!("Expected BlockedByDependencies with cross-tag dep not met"),
397        }
398    }
399}