Skip to main content

ralph/cli/queue/
next.rs

1//! Queue next subcommand.
2//!
3//! Responsibilities:
4//! - Print the next runnable task ID (or ID+title with --with-title).
5//! - Provide explanation of why no task is runnable with --explain.
6//! - Optionally display ETA estimate from execution history with --with-eta.
7//!
8//! Does not handle:
9//! - Task execution (see `crate::commands::run`).
10//! - Queue mutations.
11//! - Real-time progress tracking (handled by external UI clients).
12//!
13//! Invariants/assumptions:
14//! - When no runnable task exists, prints next available ID (for script compatibility).
15//! - Explanations go to stderr to preserve stdout contract.
16//! - ETA is based on execution history only; missing history shows "n/a".
17
18use std::io::Write;
19
20use anyhow::Result;
21use clap::Args;
22
23use crate::cli::load_and_validate_queues_read_only;
24use crate::cli::queue::shared::task_eta_display;
25use crate::config::Resolved;
26use crate::eta_calculator::EtaCalculator;
27use crate::queue::operations::{
28    NotRunnableReason, RunnableSelectionOptions, queue_runnability_report,
29};
30use crate::{outpututil, queue};
31
32/// Arguments for `ralph queue next`.
33#[derive(Args)]
34pub struct QueueNextArgs {
35    /// Include the task title after the ID.
36    #[arg(long)]
37    pub with_title: bool,
38
39    /// Include an execution-history-based ETA estimate.
40    #[arg(long)]
41    pub with_eta: bool,
42
43    /// Print an explanation when no runnable task is found.
44    #[arg(long)]
45    pub explain: bool,
46}
47
48pub(crate) fn handle(resolved: &Resolved, args: QueueNextArgs) -> Result<()> {
49    let (queue_file, done_file) = load_and_validate_queues_read_only(resolved, true)?;
50    let done_ref = done_file
51        .as_ref()
52        .filter(|d| !d.tasks.is_empty() || resolved.done_path.exists());
53
54    // Load ETA calculator if needed
55    let eta_calculator = args.with_eta.then(|| {
56        let cache_dir = resolved.repo_root.join(".ralph/cache");
57        EtaCalculator::load(&cache_dir)
58    });
59
60    // Get runnable task (same logic as run one)
61    if let Some(next) = queue::next_runnable_task(&queue_file, done_ref) {
62        if args.with_eta {
63            let calc = eta_calculator
64                .as_ref()
65                .expect("with_eta implies eta_calculator exists");
66            let eta = task_eta_display(resolved, calc, next);
67
68            if args.with_title {
69                println!(
70                    "{}\t{}",
71                    outpututil::format_task_id_title(&next.id, &next.title),
72                    eta
73                );
74            } else {
75                println!("{}\t{}", outpututil::format_task_id(&next.id), eta);
76            }
77        } else if args.with_title {
78            println!(
79                "{}",
80                outpututil::format_task_id_title(&next.id, &next.title)
81            );
82        } else {
83            println!("{}", outpututil::format_task_id(&next.id));
84        }
85
86        if args.explain {
87            eprintln!("Task {} is runnable (status: {:?})", next.id, next.status);
88        }
89        return Ok(());
90    }
91
92    // No runnable task - get next available ID
93    let max_depth = resolved.config.queue.max_dependency_depth.unwrap_or(10);
94    let next_id = queue::next_id_across(
95        &queue_file,
96        done_ref,
97        &resolved.id_prefix,
98        resolved.id_width,
99        max_depth,
100    )?;
101
102    // Print with ETA column if requested (stable column count)
103    if args.with_eta {
104        println!("{}\tn/a", next_id);
105    } else {
106        println!("{next_id}");
107    }
108
109    // If --explain, provide detailed explanation to stderr
110    if args.explain {
111        let stderr = std::io::stderr();
112        let mut handle = stderr.lock();
113
114        writeln!(handle, "No runnable task found.")?;
115
116        // Build runnability report with same options as queue next (no draft, no doing preference)
117        let options = RunnableSelectionOptions::new(false, false);
118        match queue_runnability_report(&queue_file, done_ref, options) {
119            Ok(report) => {
120                // Find first blocking task
121                let mut found_blocker = false;
122                for row in &report.tasks {
123                    // Only consider Todo candidates (same as next_runnable_task logic)
124                    if row.status != crate::contracts::TaskStatus::Todo || row.runnable {
125                        continue;
126                    }
127
128                    if !row.reasons.is_empty() {
129                        found_blocker = true;
130                        write!(handle, "First blocking task: {} (", row.id)?;
131
132                        // Print first reason concisely
133                        match &row.reasons[0] {
134                            NotRunnableReason::StatusNotRunnable { status } => {
135                                writeln!(handle, "status: {})", status)?;
136                            }
137                            NotRunnableReason::DraftExcluded => {
138                                writeln!(handle, "draft excluded)")?;
139                            }
140                            NotRunnableReason::UnmetDependencies { dependencies } => {
141                                if dependencies.len() == 1 {
142                                    match &dependencies[0] {
143                                        crate::queue::operations::DependencyIssue::Missing { id } => {
144                                            writeln!(handle, "missing dependency: {})", id)?;
145                                        }
146                                        crate::queue::operations::DependencyIssue::NotComplete { id, status } => {
147                                            writeln!(handle, "dependency {} not done: status={})", id, status)?;
148                                        }
149                                    }
150                                } else {
151                                    writeln!(handle, "{} unmet dependencies)", dependencies.len())?;
152                                }
153                            }
154                            NotRunnableReason::ScheduledStartInFuture {
155                                scheduled_start, ..
156                            } => {
157                                writeln!(handle, "scheduled: {})", scheduled_start)?;
158                            }
159                        }
160                        break;
161                    }
162                }
163
164                if !found_blocker {
165                    writeln!(
166                        handle,
167                        "No blocking tasks found (queue may be empty or all done)."
168                    )?;
169                }
170
171                writeln!(handle)?;
172                writeln!(handle, "Run 'ralph queue explain' for a full report.")?;
173
174                // Add hints based on blockers
175                if report.summary.blocked_by_dependencies > 0 {
176                    writeln!(
177                        handle,
178                        "Run 'ralph queue graph --task <ID>' to see dependencies."
179                    )?;
180                }
181                if report.summary.blocked_by_schedule > 0 {
182                    writeln!(
183                        handle,
184                        "Run 'ralph queue list --scheduled' to see scheduled tasks."
185                    )?;
186                }
187            }
188            Err(e) => {
189                writeln!(handle, "Could not generate runnability report: {}", e)?;
190            }
191        }
192    }
193
194    Ok(())
195}
196
197#[cfg(test)]
198mod tests {
199    use super::QueueNextArgs;
200    use clap::Parser;
201
202    #[derive(Parser)]
203    struct TestCli {
204        #[command(flatten)]
205        args: QueueNextArgs,
206    }
207
208    #[test]
209    fn next_args_default() {
210        let cli = TestCli::parse_from(["test"]);
211        assert!(!cli.args.with_title);
212        assert!(!cli.args.explain);
213    }
214
215    #[test]
216    fn next_args_with_title() {
217        let cli = TestCli::parse_from(["test", "--with-title"]);
218        assert!(cli.args.with_title);
219    }
220
221    #[test]
222    fn next_args_explain() {
223        let cli = TestCli::parse_from(["test", "--explain"]);
224        assert!(cli.args.explain);
225    }
226
227    #[test]
228    fn next_args_both_flags() {
229        let cli = TestCli::parse_from(["test", "--with-title", "--explain"]);
230        assert!(cli.args.with_title);
231        assert!(cli.args.explain);
232    }
233
234    #[test]
235    fn next_args_with_eta() {
236        let cli = TestCli::parse_from(["test", "--with-eta"]);
237        assert!(cli.args.with_eta);
238        assert!(!cli.args.with_title);
239        assert!(!cli.args.explain);
240    }
241
242    #[test]
243    fn next_args_with_eta_and_title() {
244        let cli = TestCli::parse_from(["test", "--with-eta", "--with-title"]);
245        assert!(cli.args.with_eta);
246        assert!(cli.args.with_title);
247        assert!(!cli.args.explain);
248    }
249
250    #[test]
251    fn next_args_all_flags() {
252        let cli = TestCli::parse_from(["test", "--with-eta", "--with-title", "--explain"]);
253        assert!(cli.args.with_eta);
254        assert!(cli.args.with_title);
255        assert!(cli.args.explain);
256    }
257}