Skip to main content

scud/commands/
helpers.rs

1use anyhow::Result;
2use colored::Colorize;
3use dialoguer::Select;
4use std::collections::HashMap;
5
6use crate::models::phase::Phase;
7use crate::models::task::{Task, TaskStatus};
8use crate::storage::Storage;
9
10/// Flatten all tasks from all phases into a single Vec for cross-tag dependency checking
11pub fn flatten_all_tasks(all_phases: &HashMap<String, Phase>) -> Vec<&Task> {
12    all_phases
13        .values()
14        .flat_map(|phase| phase.tasks.iter())
15        .collect()
16}
17
18/// Check if we're running in an interactive terminal
19pub fn is_interactive() -> bool {
20    atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout)
21}
22
23/// Find the next available task across phases
24///
25/// Returns the task and its tag if found
26pub fn find_next_task(
27    storage: &Storage,
28    tag: Option<&str>,
29    all_tags: bool,
30) -> Option<(Task, String)> {
31    let tasks = storage.load_tasks().ok()?;
32    let all_tasks_flat = flatten_all_tasks(&tasks);
33
34    if all_tags {
35        for (phase_tag, phase) in &tasks {
36            for task in &phase.tasks {
37                if is_task_ready(task, phase, &all_tasks_flat) {
38                    return Some((task.clone(), phase_tag.clone()));
39                }
40            }
41        }
42        None
43    } else {
44        let phase_tag = tag
45            .map(String::from)
46            .or_else(|| storage.get_active_group().ok().flatten())?;
47        let phase = tasks.get(&phase_tag)?;
48
49        for task in &phase.tasks {
50            if is_task_ready(task, phase, &all_tasks_flat) {
51                return Some((task.clone(), phase_tag.clone()));
52            }
53        }
54        None
55    }
56}
57
58/// Check if a task is a candidate for execution (pending, not expanded, parent expanded).
59///
60/// This is the shared base predicate used by spawn, swarm, and beads modes.
61/// It does NOT check dependency satisfaction — callers that need that should
62/// use [`is_task_ready`] instead, or handle deps separately (e.g., Kahn's algorithm).
63pub fn is_task_spawnable(task: &Task, phase: &Phase) -> bool {
64    if task.status != TaskStatus::Pending {
65        return false;
66    }
67    if task.is_expanded() {
68        return false;
69    }
70    if let Some(ref parent_id) = task.parent_id {
71        let parent_expanded = phase
72            .get_task(parent_id)
73            .map(|p| p.is_expanded())
74            .unwrap_or(false);
75        if !parent_expanded {
76            return false;
77        }
78    }
79    true
80}
81
82/// Check if a task is ready to execute: spawnable AND all dependencies met.
83pub fn is_task_ready(task: &Task, phase: &Phase, all_tasks: &[&Task]) -> bool {
84    is_task_spawnable(task, phase) && task.has_dependencies_met_refs(all_tasks)
85}
86
87/// Resolve task group tag with fallback to active group and interactive selection
88///
89/// Priority:
90/// 1. Explicit --tag argument
91/// 2. Active group (from .scud/active-tag)
92/// 3. Interactive selection (if TTY available)
93/// 4. Error with helpful message
94pub fn resolve_group_tag(
95    storage: &Storage,
96    explicit_tag: Option<&str>,
97    allow_interactive: bool,
98) -> Result<String> {
99    // Priority 1: Explicit --tag argument
100    if let Some(tag) = explicit_tag {
101        let tasks = storage.load_tasks()?;
102        if !tasks.contains_key(tag) {
103            anyhow::bail!("Task group '{}' not found. Run: scud tags", tag);
104        }
105        return Ok(tag.to_string());
106    }
107
108    // Priority 2: Active group
109    if let Some(active) = storage.get_active_group()? {
110        return Ok(active);
111    }
112
113    // Priority 3: Interactive selection
114    if allow_interactive && is_interactive() {
115        let tasks = storage.load_tasks()?;
116        if tasks.is_empty() {
117            anyhow::bail!(
118                "No task groups found. Create one with: scud parse-prd <file> --tag <tag>"
119            );
120        }
121
122        let mut tags: Vec<&String> = tasks.keys().collect();
123        tags.sort();
124
125        // Show selection prompt
126        println!("{}", "No active task group set.".yellow());
127        let selection = Select::new()
128            .with_prompt("Select a task group")
129            .items(&tags)
130            .default(0)
131            .interact()?;
132
133        let selected = tags[selection].clone();
134
135        // Set as active for next time
136        storage.set_active_group(&selected)?;
137        println!("{} {}", "Active group set to:".green(), selected.green());
138
139        return Ok(selected);
140    }
141
142    // Priority 4: Error
143    anyhow::bail!("No active task group. Use --tag <tag> or run: scud tags <tag>")
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::models::phase::Phase;
150    use crate::models::task::Task;
151
152    // ── is_task_spawnable ───────────────────────────────────────────────
153
154    #[test]
155    fn spawnable_pending_task() {
156        let mut phase = Phase::new("t".into());
157        phase.add_task(Task::new("1".into(), "A".into(), "d".into()));
158        assert!(is_task_spawnable(&phase.tasks[0], &phase));
159    }
160
161    #[test]
162    fn not_spawnable_in_progress() {
163        let mut phase = Phase::new("t".into());
164        let mut task = Task::new("1".into(), "A".into(), "d".into());
165        task.set_status(TaskStatus::InProgress);
166        phase.add_task(task);
167        assert!(!is_task_spawnable(&phase.tasks[0], &phase));
168    }
169
170    #[test]
171    fn not_spawnable_done() {
172        let mut phase = Phase::new("t".into());
173        let mut task = Task::new("1".into(), "A".into(), "d".into());
174        task.set_status(TaskStatus::Done);
175        phase.add_task(task);
176        assert!(!is_task_spawnable(&phase.tasks[0], &phase));
177    }
178
179    #[test]
180    fn not_spawnable_failed() {
181        let mut phase = Phase::new("t".into());
182        let mut task = Task::new("1".into(), "A".into(), "d".into());
183        task.set_status(TaskStatus::Failed);
184        phase.add_task(task);
185        assert!(!is_task_spawnable(&phase.tasks[0], &phase));
186    }
187
188    #[test]
189    fn not_spawnable_expanded_status() {
190        let mut phase = Phase::new("t".into());
191        let mut task = Task::new("1".into(), "A".into(), "d".into());
192        task.set_status(TaskStatus::Expanded);
193        phase.add_task(task);
194        assert!(!is_task_spawnable(&phase.tasks[0], &phase));
195    }
196
197    #[test]
198    fn not_spawnable_has_subtasks() {
199        let mut phase = Phase::new("t".into());
200        let mut parent = Task::new("1".into(), "Parent".into(), "d".into());
201        parent.subtasks = vec!["1.1".into()];
202        phase.add_task(parent);
203        // Parent with subtasks is considered expanded regardless of status
204        assert!(!is_task_spawnable(&phase.tasks[0], &phase));
205    }
206
207    #[test]
208    fn spawnable_subtask_of_expanded_parent() {
209        let mut phase = Phase::new("t".into());
210
211        let mut parent = Task::new("1".into(), "Parent".into(), "d".into());
212        parent.subtasks = vec!["1.1".into()];
213        phase.add_task(parent);
214
215        let mut child = Task::new("1.1".into(), "Child".into(), "d".into());
216        child.parent_id = Some("1".into());
217        phase.add_task(child);
218
219        // Child of expanded parent is spawnable
220        assert!(is_task_spawnable(&phase.tasks[1], &phase));
221    }
222
223    #[test]
224    fn not_spawnable_subtask_of_unexpanded_parent() {
225        let mut phase = Phase::new("t".into());
226
227        // Parent with NO subtasks list (not expanded)
228        let parent = Task::new("1".into(), "Parent".into(), "d".into());
229        phase.add_task(parent);
230
231        let mut child = Task::new("1.1".into(), "Child".into(), "d".into());
232        child.parent_id = Some("1".into());
233        phase.add_task(child);
234
235        // Child of unexpanded parent is NOT spawnable
236        assert!(!is_task_spawnable(&phase.tasks[1], &phase));
237    }
238
239    #[test]
240    fn spawnable_subtask_with_missing_parent() {
241        // Edge case: parent_id points to a task not in the phase
242        let mut phase = Phase::new("t".into());
243        let mut orphan = Task::new("1.1".into(), "Orphan".into(), "d".into());
244        orphan.parent_id = Some("nonexistent".into());
245        phase.add_task(orphan);
246
247        // parent not found → parent_expanded defaults to false → not spawnable
248        assert!(!is_task_spawnable(&phase.tasks[0], &phase));
249    }
250
251    #[test]
252    fn spawnable_task_no_parent() {
253        // Task with parent_id = None (top-level) is spawnable if pending
254        let mut phase = Phase::new("t".into());
255        let task = Task::new("1".into(), "Top".into(), "d".into());
256        phase.add_task(task);
257        assert!(is_task_spawnable(&phase.tasks[0], &phase));
258    }
259
260    // ── is_task_ready ───────────────────────────────────────────────────
261
262    #[test]
263    fn ready_no_deps() {
264        let mut phase = Phase::new("t".into());
265        phase.add_task(Task::new("1".into(), "A".into(), "d".into()));
266        let all: Vec<&Task> = phase.tasks.iter().collect();
267        assert!(is_task_ready(&phase.tasks[0], &phase, &all));
268    }
269
270    #[test]
271    fn not_ready_dep_pending() {
272        let mut phase = Phase::new("t".into());
273        phase.add_task(Task::new("1".into(), "First".into(), "d".into()));
274        let mut t2 = Task::new("2".into(), "Second".into(), "d".into());
275        t2.dependencies = vec!["1".into()];
276        phase.add_task(t2);
277        let all: Vec<&Task> = phase.tasks.iter().collect();
278
279        assert!(is_task_ready(&phase.tasks[0], &phase, &all));
280        assert!(!is_task_ready(&phase.tasks[1], &phase, &all));
281    }
282
283    #[test]
284    fn ready_dep_done() {
285        let mut phase = Phase::new("t".into());
286        let mut t1 = Task::new("1".into(), "First".into(), "d".into());
287        t1.set_status(TaskStatus::Done);
288        phase.add_task(t1);
289
290        let mut t2 = Task::new("2".into(), "Second".into(), "d".into());
291        t2.dependencies = vec!["1".into()];
292        phase.add_task(t2);
293        let all: Vec<&Task> = phase.tasks.iter().collect();
294
295        // Task 1 is done so not ready (not Pending)
296        assert!(!is_task_ready(&phase.tasks[0], &phase, &all));
297        // Task 2 is pending, dep is done → ready
298        assert!(is_task_ready(&phase.tasks[1], &phase, &all));
299    }
300
301    #[test]
302    fn not_ready_expanded_even_with_deps_met() {
303        let mut phase = Phase::new("t".into());
304        let mut t1 = Task::new("1".into(), "First".into(), "d".into());
305        t1.set_status(TaskStatus::Done);
306        phase.add_task(t1);
307
308        let mut t2 = Task::new("2".into(), "Second".into(), "d".into());
309        t2.dependencies = vec!["1".into()];
310        t2.subtasks = vec!["2.1".into()]; // expanded
311        phase.add_task(t2);
312        let all: Vec<&Task> = phase.tasks.iter().collect();
313
314        // Deps met but task is expanded → not ready
315        assert!(!is_task_ready(&phase.tasks[1], &phase, &all));
316    }
317
318    #[test]
319    fn not_ready_subtask_parent_unexpanded_deps_met() {
320        let mut phase = Phase::new("t".into());
321        // Parent is pending with no subtasks (unexpanded)
322        phase.add_task(Task::new("1".into(), "Parent".into(), "d".into()));
323
324        let mut child = Task::new("1.1".into(), "Child".into(), "d".into());
325        child.parent_id = Some("1".into());
326        // No dependencies → deps trivially met
327        phase.add_task(child);
328        let all: Vec<&Task> = phase.tasks.iter().collect();
329
330        // Parent is unexpanded → child not ready
331        assert!(!is_task_ready(&phase.tasks[1], &phase, &all));
332    }
333
334    #[test]
335    fn ready_chain_of_three() {
336        let mut phase = Phase::new("t".into());
337        let mut t1 = Task::new("1".into(), "A".into(), "d".into());
338        t1.set_status(TaskStatus::Done);
339        phase.add_task(t1);
340
341        let mut t2 = Task::new("2".into(), "B".into(), "d".into());
342        t2.dependencies = vec!["1".into()];
343        t2.set_status(TaskStatus::Done);
344        phase.add_task(t2);
345
346        let mut t3 = Task::new("3".into(), "C".into(), "d".into());
347        t3.dependencies = vec!["2".into()];
348        phase.add_task(t3);
349
350        let all: Vec<&Task> = phase.tasks.iter().collect();
351
352        assert!(!is_task_ready(&phase.tasks[0], &phase, &all)); // done
353        assert!(!is_task_ready(&phase.tasks[1], &phase, &all)); // done
354        assert!(is_task_ready(&phase.tasks[2], &phase, &all));  // pending, dep chain met
355    }
356
357    #[test]
358    fn not_ready_multiple_deps_one_unmet() {
359        let mut phase = Phase::new("t".into());
360        let mut t1 = Task::new("1".into(), "A".into(), "d".into());
361        t1.set_status(TaskStatus::Done);
362        phase.add_task(t1);
363
364        phase.add_task(Task::new("2".into(), "B".into(), "d".into())); // still pending
365
366        let mut t3 = Task::new("3".into(), "C".into(), "d".into());
367        t3.dependencies = vec!["1".into(), "2".into()];
368        phase.add_task(t3);
369
370        let all: Vec<&Task> = phase.tasks.iter().collect();
371
372        // Task 3 depends on both 1 (done) and 2 (pending) → not ready
373        assert!(!is_task_ready(&phase.tasks[2], &phase, &all));
374    }
375
376    #[test]
377    fn ready_multiple_deps_all_met() {
378        let mut phase = Phase::new("t".into());
379        let mut t1 = Task::new("1".into(), "A".into(), "d".into());
380        t1.set_status(TaskStatus::Done);
381        phase.add_task(t1);
382
383        let mut t2 = Task::new("2".into(), "B".into(), "d".into());
384        t2.set_status(TaskStatus::Done);
385        phase.add_task(t2);
386
387        let mut t3 = Task::new("3".into(), "C".into(), "d".into());
388        t3.dependencies = vec!["1".into(), "2".into()];
389        phase.add_task(t3);
390
391        let all: Vec<&Task> = phase.tasks.iter().collect();
392        assert!(is_task_ready(&phase.tasks[2], &phase, &all));
393    }
394
395    // ── flatten_all_tasks ───────────────────────────────────────────────
396
397    #[test]
398    fn flatten_empty() {
399        let phases: HashMap<String, Phase> = HashMap::new();
400        assert!(flatten_all_tasks(&phases).is_empty());
401    }
402
403    #[test]
404    fn flatten_multiple_phases() {
405        let mut phases = HashMap::new();
406
407        let mut p1 = Phase::new("a".into());
408        p1.add_task(Task::new("1".into(), "X".into(), "d".into()));
409        phases.insert("a".into(), p1);
410
411        let mut p2 = Phase::new("b".into());
412        p2.add_task(Task::new("2".into(), "Y".into(), "d".into()));
413        p2.add_task(Task::new("3".into(), "Z".into(), "d".into()));
414        phases.insert("b".into(), p2);
415
416        let flat = flatten_all_tasks(&phases);
417        assert_eq!(flat.len(), 3);
418    }
419}