ralph/cli/queue/
explain.rs1use anyhow::Result;
15use clap::Args;
16
17use crate::cli::load_and_validate_queues_read_only;
18use crate::cli::queue::shared::QueueReportFormat;
19use crate::config::Resolved;
20use crate::queue::operations::{RunnableSelectionOptions, queue_runnability_report};
21
22#[derive(Args)]
24#[command(
25 about = "Explain why tasks are (not) runnable",
26 after_long_help = "Examples:\n\
27 ralph queue explain\n\
28 ralph queue explain --format json\n\
29 ralph queue explain --include-draft\n\
30 ralph queue explain --format json --include-draft"
31)]
32pub struct QueueExplainArgs {
33 #[arg(long, value_enum, default_value_t = QueueReportFormat::Text)]
35 pub format: QueueReportFormat,
36
37 #[arg(long)]
39 pub include_draft: bool,
40}
41
42pub(crate) fn handle(resolved: &Resolved, args: QueueExplainArgs) -> Result<()> {
43 let (queue_file, done_file) = load_and_validate_queues_read_only(resolved, true)?;
44 let done_ref = done_file
45 .as_ref()
46 .filter(|d| !d.tasks.is_empty() || resolved.done_path.exists());
47
48 let options = RunnableSelectionOptions::new(args.include_draft, true);
49 let report = queue_runnability_report(&queue_file, done_ref, options)?;
50
51 match args.format {
52 QueueReportFormat::Json => {
53 println!("{}", serde_json::to_string_pretty(&report)?);
54 }
55 QueueReportFormat::Text => {
56 print_text_explanation(&report);
57 }
58 }
59
60 Ok(())
61}
62
63fn print_text_explanation(report: &crate::queue::operations::QueueRunnabilityReport) {
64 use crate::queue::operations::NotRunnableReason;
65
66 println!("Queue Runnability Report (generated at {})", report.now);
68 println!();
69
70 println!(
72 "Selection: include_draft={}, prefer_doing={}",
73 report.selection.include_draft, report.selection.prefer_doing
74 );
75
76 match (
77 report.selection.selected_task_id.as_deref(),
78 report.selection.selected_task_status,
79 ) {
80 (Some(id), Some(status)) => {
81 println!("Selected task: {} (status: {:?})", id, status);
82 }
83 (Some(id), None) => {
84 println!("Selected task: {} (status: unknown)", id);
86 }
87 (None, _) => {
88 println!("Selected task: none (no runnable tasks found)");
89 }
90 }
91 println!();
92
93 println!("Summary:");
95 println!(" Total tasks: {}", report.summary.total_active);
96 println!(
97 " Candidates: {} (runnable: {})",
98 report.summary.candidates_total, report.summary.runnable_candidates
99 );
100 if report.summary.blocked_by_dependencies > 0 {
101 println!(
102 " Blocked by dependencies: {}",
103 report.summary.blocked_by_dependencies
104 );
105 }
106 if report.summary.blocked_by_schedule > 0 {
107 println!(
108 " Blocked by schedule: {}",
109 report.summary.blocked_by_schedule
110 );
111 }
112 if report.summary.blocked_by_status_or_flags > 0 {
113 println!(
114 " Blocked by status/flags: {}",
115 report.summary.blocked_by_status_or_flags
116 );
117 }
118 println!();
119
120 if report.selection.selected_task_id.is_none() && report.summary.candidates_total > 0 {
122 println!("Blocking reasons (first 10 candidates):");
123 let mut shown = 0;
124 for row in &report.tasks {
125 let is_candidate = row.status == crate::contracts::TaskStatus::Todo
127 || (report.selection.include_draft
128 && row.status == crate::contracts::TaskStatus::Draft);
129 if !is_candidate || row.runnable {
130 continue;
131 }
132
133 println!(" {} (status: {:?}):", row.id, row.status);
134 for reason in &row.reasons {
135 match reason {
136 NotRunnableReason::StatusNotRunnable { status } => {
137 println!(" - Status prevents running: {}", status);
138 }
139 NotRunnableReason::DraftExcluded => {
140 println!(" - Draft tasks excluded (use --include-draft)");
141 }
142 NotRunnableReason::UnmetDependencies { dependencies } => {
143 println!(" - Blocked by unmet dependencies:");
144 for dep in dependencies {
145 match dep {
146 crate::queue::operations::DependencyIssue::Missing { id } => {
147 println!(" * {}: dependency not found", id);
148 }
149 crate::queue::operations::DependencyIssue::NotComplete {
150 id,
151 status,
152 } => {
153 println!(
154 " * {}: status is '{}' (must be done/rejected)",
155 id, status
156 );
157 }
158 }
159 }
160 }
161 NotRunnableReason::ScheduledStartInFuture {
162 scheduled_start,
163 seconds_until_runnable,
164 ..
165 } => {
166 let hours = seconds_until_runnable / 3600;
167 let minutes = (seconds_until_runnable % 3600) / 60;
168 if hours > 0 {
169 println!(
170 " - Scheduled for future: {} (in {}h {}m)",
171 scheduled_start, hours, minutes
172 );
173 } else {
174 println!(
175 " - Scheduled for future: {} (in {}m)",
176 scheduled_start, minutes
177 );
178 }
179 }
180 }
181 }
182
183 shown += 1;
184 if shown >= 10 {
185 let remaining =
186 report.summary.candidates_total - report.summary.runnable_candidates - shown;
187 if remaining > 0 {
188 println!(" ... and {} more blocked tasks", remaining);
189 }
190 break;
191 }
192 }
193 println!();
194
195 println!("Hints:");
197 if report.summary.blocked_by_dependencies > 0 {
198 println!(" - Run 'ralph queue graph --task <ID>' to visualize dependencies");
199 }
200 if report.summary.blocked_by_schedule > 0 {
201 println!(" - Run 'ralph queue list --scheduled' to see scheduled tasks");
202 }
203 println!(" - Run 'ralph run one --dry-run' to see what would be selected");
204 }
205
206 if let Some(ref id) = report.selection.selected_task_id
208 && let Some(row) = report.tasks.iter().find(|t| &t.id == id)
209 {
210 println!("Selected task details:");
211 println!(" ID: {}", row.id);
212 println!(" Status: {:?}", row.status);
213 if row.runnable {
214 println!(" Runnability: ready to run");
215 } else {
216 println!(" Runnability: NOT runnable");
217 if !row.reasons.is_empty() {
218 println!(" Reasons:");
219 for reason in &row.reasons {
220 match reason {
221 NotRunnableReason::StatusNotRunnable { status } => {
222 println!(" - Status prevents running: {}", status);
223 }
224 NotRunnableReason::DraftExcluded => {
225 println!(" - Draft tasks excluded (use --include-draft)");
226 }
227 NotRunnableReason::UnmetDependencies { dependencies } => {
228 println!(" - Blocked by unmet dependencies:");
229 for dep in dependencies {
230 match dep {
231 crate::queue::operations::DependencyIssue::Missing { id } => {
232 println!(" * {}: dependency not found", id);
233 }
234 crate::queue::operations::DependencyIssue::NotComplete {
235 id,
236 status,
237 } => {
238 println!(
239 " * {}: status is '{}' (must be done/rejected)",
240 id, status
241 );
242 }
243 }
244 }
245 }
246 NotRunnableReason::ScheduledStartInFuture {
247 scheduled_start,
248 seconds_until_runnable,
249 ..
250 } => {
251 let hours = seconds_until_runnable / 3600;
252 let minutes = (seconds_until_runnable % 3600) / 60;
253 if hours > 0 {
254 println!(
255 " - Scheduled for future: {} (in {}h {}m)",
256 scheduled_start, hours, minutes
257 );
258 } else {
259 println!(
260 " - Scheduled for future: {} (in {}m)",
261 scheduled_start, minutes
262 );
263 }
264 }
265 }
266 }
267 }
268 }
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::QueueExplainArgs;
275 use crate::cli::queue::shared::QueueReportFormat;
276 use clap::Parser;
277
278 #[derive(Parser)]
279 struct TestCli {
280 #[command(flatten)]
281 args: QueueExplainArgs,
282 }
283
284 #[test]
285 fn explain_args_default_format_is_text() {
286 let cli = TestCli::parse_from(["test"]);
287 assert!(matches!(cli.args.format, QueueReportFormat::Text));
288 assert!(!cli.args.include_draft);
289 }
290
291 #[test]
292 fn explain_args_json_format() {
293 let cli = TestCli::parse_from(["test", "--format", "json"]);
294 assert!(matches!(cli.args.format, QueueReportFormat::Json));
295 }
296
297 #[test]
298 fn explain_args_include_draft() {
299 let cli = TestCli::parse_from(["test", "--include-draft"]);
300 assert!(cli.args.include_draft);
301 }
302}