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(project_root: Option<PathBuf>, tag: Option<&str>, spawn: bool) -> Result<()> {
49    let storage = Storage::new(project_root);
50    let phase_tag = resolve_group_tag(&storage, tag, true)?;
51
52    // Standard next task behavior (read-only)
53    let tasks = storage.load_tasks()?;
54    let all_tasks_flat = flatten_all_tasks(&tasks);
55    let phase = tasks
56        .get(&phase_tag)
57        .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
58
59    // Handle --spawn mode (machine-readable JSON output)
60    if spawn {
61        match find_next_available(phase, &all_tasks_flat) {
62            NextTaskResult::Available(task) => {
63                let output = serde_json::json!({
64                    "task_id": task.id,
65                    "title": task.title,
66                    "tag": phase_tag,
67                    "complexity": task.complexity,
68                });
69                println!("{}", serde_json::to_string(&output)?);
70            }
71            _ => {
72                println!("null");
73            }
74        }
75        return Ok(());
76    }
77
78    match find_next_available(phase, &all_tasks_flat) {
79        NextTaskResult::Available(task) => {
80            print_task_details(task);
81            print_standard_instructions(&task.id);
82        }
83        NextTaskResult::NoPendingTasks => {
84            println!("{}", "All tasks completed or in progress!".green().bold());
85            println!("Run: scud list --status in-progress");
86        }
87        NextTaskResult::BlockedByDependencies => {
88            println!(
89                "{}",
90                "No available tasks - all pending tasks blocked by dependencies".yellow()
91            );
92            println!("Run: scud list --status pending");
93            println!("Run: scud doctor  # to diagnose stuck states");
94        }
95    }
96
97    Ok(())
98}
99
100fn print_task_details(task: &crate::models::task::Task) {
101    println!("{}", "Next Available Task:".green().bold());
102    println!();
103    println!("{:<20} {}", "ID:".yellow(), task.id.cyan());
104    println!("{:<20} {}", "Title:".yellow(), task.title.bold());
105    println!("{:<20} {}", "Complexity:".yellow(), task.complexity);
106    println!("{:<20} {:?}", "Priority:".yellow(), task.priority);
107
108    if let Some(ref assigned) = task.assigned_to {
109        println!("{:<20} {}", "Assigned to:".yellow(), assigned.green());
110    }
111
112    println!();
113    println!("{}", "Description:".yellow());
114    println!("{}", task.description);
115
116    if let Some(details) = &task.details {
117        println!();
118        println!("{}", "Technical Details:".yellow());
119        println!("{}", details);
120    }
121
122    if let Some(test_strategy) = &task.test_strategy {
123        println!();
124        println!("{}", "Test Strategy:".yellow());
125        println!("{}", test_strategy);
126    }
127}
128
129fn print_standard_instructions(task_id: &str) {
130    println!();
131    println!("{}", "To start this task:".blue());
132    println!("  scud set-status {} in-progress", task_id);
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::models::phase::Phase;
139    use crate::models::task::{Task, TaskStatus};
140
141    fn create_test_phase() -> Phase {
142        let mut phase = Phase::new("test-phase".to_string());
143
144        let mut task1 = Task::new("1".to_string(), "Task 1".to_string(), "Desc 1".to_string());
145        task1.set_status(TaskStatus::Done);
146
147        let mut task2 = Task::new("2".to_string(), "Task 2".to_string(), "Desc 2".to_string());
148        task2.dependencies = vec!["1".to_string()];
149        // task2 is pending with deps met
150
151        let mut task3 = Task::new("3".to_string(), "Task 3".to_string(), "Desc 3".to_string());
152        task3.dependencies = vec!["2".to_string()];
153        // task3 is pending with deps NOT met
154
155        phase.add_task(task1);
156        phase.add_task(task2);
157        phase.add_task(task3);
158
159        phase
160    }
161
162    /// Helper to get task refs from phase for testing
163    fn get_task_refs(phase: &Phase) -> Vec<&Task> {
164        phase.tasks.iter().collect()
165    }
166
167    #[test]
168    fn test_find_next_available_basic() {
169        let phase = create_test_phase();
170        let all_tasks = get_task_refs(&phase);
171
172        match find_next_available(&phase, &all_tasks) {
173            NextTaskResult::Available(task) => {
174                assert_eq!(task.id, "2");
175            }
176            _ => panic!("Expected Available result"),
177        }
178    }
179
180    #[test]
181    fn test_find_next_no_pending() {
182        let mut phase = Phase::new("test".to_string());
183        let mut task = Task::new("1".to_string(), "Done".to_string(), "Desc".to_string());
184        task.set_status(TaskStatus::Done);
185        phase.add_task(task);
186
187        let all_tasks = get_task_refs(&phase);
188
189        match find_next_available(&phase, &all_tasks) {
190            NextTaskResult::NoPendingTasks => {}
191            _ => panic!("Expected NoPendingTasks result"),
192        }
193    }
194
195    #[test]
196    fn test_find_next_blocked_by_deps() {
197        let mut phase = Phase::new("test".to_string());
198
199        let task1 = Task::new("1".to_string(), "Task 1".to_string(), "Desc".to_string());
200        // task1 is pending
201
202        let mut task2 = Task::new("2".to_string(), "Task 2".to_string(), "Desc".to_string());
203        task2.dependencies = vec!["1".to_string()];
204        // task2 depends on pending task1
205
206        // Add task2 first, task1 second (so task2 is checked first)
207        phase.add_task(task2);
208        phase.add_task(task1);
209
210        let all_tasks = get_task_refs(&phase);
211
212        // task1 should be found since it has no deps
213        match find_next_available(&phase, &all_tasks) {
214            NextTaskResult::Available(task) => {
215                assert_eq!(task.id, "1");
216            }
217            _ => panic!("Expected task 1 to be available"),
218        }
219    }
220
221    #[test]
222    fn test_find_next_all_blocked() {
223        let mut phase = Phase::new("test".to_string());
224
225        let mut task1 = Task::new("1".to_string(), "Task 1".to_string(), "Desc".to_string());
226        task1.dependencies = vec!["nonexistent".to_string()];
227        // task1 depends on non-existent task
228
229        phase.add_task(task1);
230
231        let all_tasks = get_task_refs(&phase);
232
233        match find_next_available(&phase, &all_tasks) {
234            NextTaskResult::BlockedByDependencies => {}
235            _ => panic!("Expected BlockedByDependencies result"),
236        }
237    }
238
239    #[test]
240    fn test_find_next_cross_tag_dependency() {
241        // Create a phase with a task that depends on a task from another "phase"
242        let mut phase = Phase::new("api".to_string());
243        let mut api_task = Task::new(
244            "api:1".to_string(),
245            "API Task".to_string(),
246            "Desc".to_string(),
247        );
248        api_task.dependencies = vec!["auth:1".to_string()]; // Depends on auth phase
249        phase.add_task(api_task);
250
251        // Create "auth" task (simulating another phase)
252        let mut auth_task = Task::new(
253            "auth:1".to_string(),
254            "Auth Task".to_string(),
255            "Desc".to_string(),
256        );
257        auth_task.set_status(TaskStatus::Done);
258
259        // Combine all tasks (simulating flattened all_phases)
260        let all_tasks: Vec<&Task> = vec![&phase.tasks[0], &auth_task];
261
262        // With cross-tag tasks included, dependency should be met
263        match find_next_available(&phase, &all_tasks) {
264            NextTaskResult::Available(task) => {
265                assert_eq!(task.id, "api:1");
266            }
267            _ => panic!("Expected Available result with cross-tag dependency met"),
268        }
269    }
270
271    #[test]
272    fn test_find_next_cross_tag_dependency_not_met() {
273        // Create a phase with a task that depends on a task from another "phase"
274        let mut phase = Phase::new("api".to_string());
275        let mut api_task = Task::new(
276            "api:1".to_string(),
277            "API Task".to_string(),
278            "Desc".to_string(),
279        );
280        api_task.dependencies = vec!["auth:1".to_string()]; // Depends on auth phase
281        phase.add_task(api_task);
282
283        // Create "auth" task (NOT done)
284        let auth_task = Task::new(
285            "auth:1".to_string(),
286            "Auth Task".to_string(),
287            "Desc".to_string(),
288        );
289
290        // Combine all tasks (simulating flattened all_phases)
291        let all_tasks: Vec<&Task> = vec![&phase.tasks[0], &auth_task];
292
293        // With cross-tag dep NOT met, should be blocked
294        match find_next_available(&phase, &all_tasks) {
295            NextTaskResult::BlockedByDependencies => {}
296            _ => panic!("Expected BlockedByDependencies with cross-tag dep not met"),
297        }
298    }
299}