1use 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#[derive(Args)]
34pub struct QueueNextArgs {
35 #[arg(long)]
37 pub with_title: bool,
38
39 #[arg(long)]
41 pub with_eta: bool,
42
43 #[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 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 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 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 if args.with_eta {
104 println!("{}\tn/a", next_id);
105 } else {
106 println!("{next_id}");
107 }
108
109 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 let options = RunnableSelectionOptions::new(false, false);
118 match queue_runnability_report(&queue_file, done_ref, options) {
119 Ok(report) => {
120 let mut found_blocker = false;
122 for row in &report.tasks {
123 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 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 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}