Skip to main content

ralph/commands/run/
dry_run.rs

1//! Dry-run selection for `ralph run one --dry-run` and `ralph run loop --dry-run`.
2//!
3//! Responsibilities:
4//! - Perform task selection without acquiring queue lock or modifying files.
5//! - Explain why tasks are blocked using runnability reports.
6//!
7//! Not handled here:
8//! - Actual task execution (see `run_one`).
9//! - Queue lock management (see `queue_lock`).
10//!
11//! Invariants/assumptions:
12//! - Dry-run mode must not modify queue/done files or start runner sessions.
13//! - Selection messaging must keep key phrases stable (tests look for exact strings).
14
15use crate::agent::AgentOverrides;
16use crate::config;
17use crate::contracts::TaskStatus;
18use crate::queue;
19use crate::queue::RunnableSelectionOptions;
20use crate::queue::operations::queue_runnability_report;
21use anyhow::Result;
22
23use super::selection::select_run_one_task_index;
24
25/// Dry-run selection for `ralph run one --dry-run`.
26///
27/// Performs task selection without:
28/// - Acquiring queue lock
29/// - Marking task as doing
30/// - Starting any runner session
31/// - Modifying queue/done files
32pub fn dry_run_one(
33    resolved: &config::Resolved,
34    agent_overrides: &AgentOverrides,
35    target_task_id: Option<&str>,
36) -> Result<()> {
37    // Load queue and done (no lock in dry-run mode).
38    let queue_file = queue::load_queue(&resolved.queue_path)?;
39    let done = queue::load_queue_or_default(&resolved.done_path)?;
40    let done_ref = if done.tasks.is_empty() && !resolved.done_path.exists() {
41        None
42    } else {
43        Some(&done)
44    };
45
46    let include_draft = agent_overrides.include_draft.unwrap_or(false);
47
48    // Match run-time validation behavior without mutating anything.
49    let max_depth = resolved.config.queue.max_dependency_depth.unwrap_or(10);
50    let warnings = queue::validate_queue_set(
51        &queue_file,
52        done_ref,
53        &resolved.id_prefix,
54        resolved.id_width,
55        max_depth,
56    )?;
57    queue::log_warnings(&warnings);
58
59    // Try to select a task
60    let selected = select_run_one_task_index(&queue_file, done_ref, target_task_id, include_draft)?;
61
62    if let Some(idx) = selected {
63        let task = &queue_file.tasks[idx];
64        println!("Dry run: would run {} (status: {:?})", task.id, task.status);
65
66        // Also show any blockers (in case task is runnable but has caveats)
67        if task.status == TaskStatus::Doing {
68            println!("  Note: Task is already in 'doing' status (resuming).");
69        }
70        return Ok(());
71    }
72
73    // No task selected - explain why
74    println!("Dry run: no task would be run.");
75    println!();
76
77    // Count candidates
78    let candidates: Vec<_> = queue_file
79        .tasks
80        .iter()
81        .filter(|t| {
82            t.status == TaskStatus::Todo || (include_draft && t.status == TaskStatus::Draft)
83        })
84        .collect();
85
86    if candidates.is_empty() {
87        // Keep this phrase stable; some tests look for it.
88        if include_draft {
89            println!("No todo or draft tasks found.");
90        } else {
91            println!("No todo tasks found.");
92        }
93        return Ok(());
94    }
95
96    // Build runnability report to explain blockers
97    let options = RunnableSelectionOptions::new(include_draft, true);
98    match queue_runnability_report(&queue_file, done_ref, options) {
99        Ok(report) => {
100            if report.summary.runnable_candidates > 0 {
101                // This shouldn't happen if selection returned None, but handle it
102                println!(
103                    "Warning: runnability report found {} runnable candidates but selection returned none.",
104                    report.summary.runnable_candidates
105                );
106            } else {
107                println!("Blockers preventing task execution:");
108
109                if report.summary.blocked_by_dependencies > 0 {
110                    println!(
111                        "  - {} task(s) blocked by unmet dependencies",
112                        report.summary.blocked_by_dependencies
113                    );
114                }
115                if report.summary.blocked_by_schedule > 0 {
116                    println!(
117                        "  - {} task(s) blocked by future schedule",
118                        report.summary.blocked_by_schedule
119                    );
120                }
121                if report.summary.blocked_by_status_or_flags > 0 {
122                    println!(
123                        "  - {} task(s) blocked by status/flags (e.g., draft excluded)",
124                        report.summary.blocked_by_status_or_flags
125                    );
126                }
127
128                // Show first blocking task
129                println!();
130                for row in &report.tasks {
131                    let is_candidate = row.status == TaskStatus::Todo
132                        || (include_draft && row.status == TaskStatus::Draft);
133                    if !is_candidate || row.runnable || row.reasons.is_empty() {
134                        continue;
135                    }
136
137                    println!("First blocking task: {} (status: {:?})", row.id, row.status);
138                    for reason in &row.reasons {
139                        match reason {
140                            crate::queue::operations::NotRunnableReason::UnmetDependencies { dependencies } => {
141                                if dependencies.len() == 1 {
142                                    match &dependencies[0] {
143                                        crate::queue::operations::DependencyIssue::Missing { id } => {
144                                            println!("  - Missing dependency: {}", id);
145                                        }
146                                        crate::queue::operations::DependencyIssue::NotComplete { id, status } => {
147                                            println!("  - Dependency {} not complete (status: {})", id, status);
148                                        }
149                                    }
150                                } else {
151                                    println!("  - {} unmet dependencies", dependencies.len());
152                                }
153                            }
154                            crate::queue::operations::NotRunnableReason::ScheduledStartInFuture { scheduled_start, seconds_until_runnable, .. } => {
155                                let hours = seconds_until_runnable / 3600;
156                                let minutes = (seconds_until_runnable % 3600) / 60;
157                                if hours > 0 {
158                                    println!("  - Scheduled for: {} (in {}h {}m)",
159                                        scheduled_start, hours, minutes);
160                                } else {
161                                    println!("  - Scheduled for: {} (in {}m)",
162                                        scheduled_start, minutes);
163                                }
164                            }
165                            crate::queue::operations::NotRunnableReason::DraftExcluded => {
166                                println!("  - Draft tasks excluded (use --include-draft)");
167                            }
168                            crate::queue::operations::NotRunnableReason::StatusNotRunnable { status } => {
169                                println!("  - Status prevents running: {}", status);
170                            }
171                        }
172                    }
173                    break;
174                }
175            }
176        }
177        Err(e) => {
178            println!("Could not generate runnability report: {}", e);
179        }
180    }
181
182    println!();
183    println!("Run 'ralph queue explain' for a full report.");
184
185    Ok(())
186}
187
188/// Dry-run selection for `ralph run loop --dry-run`.
189///
190/// Reports the first selection only since subsequent tasks depend on outcomes.
191pub fn dry_run_loop(resolved: &config::Resolved, agent_overrides: &AgentOverrides) -> Result<()> {
192    println!("Dry run: simulating run loop (reporting first selection only).");
193    println!("Note: Subsequent tasks depend on outcomes of earlier tasks.");
194    println!();
195
196    dry_run_one(resolved, agent_overrides, None)
197}