Skip to main content

ralph/cli/queue/
explain.rs

1//! Queue explain subcommand.
2//!
3//! Responsibilities:
4//! - Report why tasks are (not) runnable with human-readable text or JSON output.
5//!
6//! Does not handle:
7//! - Task selection or execution (see `crate::commands::run`).
8//! - Queue persistence or mutations.
9//!
10//! Invariants/assumptions:
11//! - JSON output is versioned and stable for scripting.
12//! - Text output includes actionable hints for next steps.
13
14use 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/// Arguments for `ralph queue explain`.
23#[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    /// Output format (text or json).
34    #[arg(long, value_enum, default_value_t = QueueReportFormat::Text)]
35    pub format: QueueReportFormat,
36
37    /// Include draft tasks in the analysis.
38    #[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    // Summary header
67    println!("Queue Runnability Report (generated at {})", report.now);
68    println!();
69
70    // Selection context
71    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            // Shouldn't happen, but keep text output resilient.
85            println!("Selected task: {} (status: unknown)", id);
86        }
87        (None, _) => {
88            println!("Selected task: none (no runnable tasks found)");
89        }
90    }
91    println!();
92
93    // Summary counts
94    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 no runnable tasks, show first few blockers
121    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            // Only show candidates (Todo or Draft if include_draft)
126            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        // Hints
196        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 there is a selected task, show its details
207    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}