1use crate::cli::scan::ScanMode;
19use crate::commands::run::PhaseType;
20use crate::contracts::{
21 ClaudePermissionMode, GitRevertMode, Model, ProjectType, ReasoningEffort, Runner,
22 RunnerCliOptionsPatch,
23};
24use crate::{config, fsutil, git, prompts, queue, runner, runutil, timeutil};
25use std::sync::atomic::{AtomicBool, Ordering};
26
27static DEBUG_MODE: AtomicBool = AtomicBool::new(false);
30
31pub fn set_debug_mode(enabled: bool) {
33 DEBUG_MODE.store(enabled, Ordering::SeqCst);
34}
35
36fn is_debug_mode() -> bool {
38 DEBUG_MODE.load(Ordering::SeqCst)
39}
40use anyhow::{Context, Result};
41
42pub struct ScanOptions {
43 pub focus: String,
44 pub mode: ScanMode,
45 pub runner_override: Option<Runner>,
46 pub model_override: Option<Model>,
47 pub reasoning_effort_override: Option<ReasoningEffort>,
48 pub runner_cli_overrides: RunnerCliOptionsPatch,
49 pub force: bool,
50 pub repoprompt_tool_injection: bool,
51 pub git_revert_mode: GitRevertMode,
52 pub lock_mode: ScanLockMode,
54 pub output_handler: Option<runner::OutputHandler>,
56 pub revert_prompt: Option<runutil::RevertPromptHandler>,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum ScanLockMode {
62 Acquire,
63 Held,
64}
65
66#[derive(Debug, Clone)]
67struct ScanRunnerSettings {
68 runner: Runner,
69 model: Model,
70 reasoning_effort: Option<ReasoningEffort>,
71 runner_cli: runner::ResolvedRunnerCliOptions,
72 permission_mode: Option<ClaudePermissionMode>,
73}
74
75fn resolve_scan_runner_settings(
76 resolved: &config::Resolved,
77 opts: &ScanOptions,
78) -> Result<ScanRunnerSettings> {
79 let settings = runner::resolve_agent_settings(
80 opts.runner_override.clone(),
81 opts.model_override.clone(),
82 opts.reasoning_effort_override,
83 &opts.runner_cli_overrides,
84 None,
85 &resolved.config.agent,
86 )?;
87
88 Ok(ScanRunnerSettings {
89 runner: settings.runner,
90 model: settings.model,
91 reasoning_effort: settings.reasoning_effort,
92 runner_cli: settings.runner_cli,
93 permission_mode: resolved.config.agent.claude_permission_mode,
94 })
95}
96
97pub fn run_scan(resolved: &config::Resolved, opts: ScanOptions) -> Result<()> {
98 git::require_clean_repo_ignoring_paths(
100 &resolved.repo_root,
101 opts.force,
102 git::RALPH_RUN_CLEAN_ALLOWED_PATHS,
103 )?;
104
105 let _queue_lock = match opts.lock_mode {
106 ScanLockMode::Acquire => Some(queue::acquire_queue_lock(
107 &resolved.repo_root,
108 "scan",
109 opts.force,
110 )?),
111 ScanLockMode::Held => None,
112 };
113
114 let before = queue::load_queue(&resolved.queue_path)
115 .with_context(|| format!("read queue {}", resolved.queue_path.display()))?;
116 let done = queue::load_queue_or_default(&resolved.done_path)
117 .with_context(|| format!("read done {}", resolved.done_path.display()))?;
118 let done_ref = if done.tasks.is_empty() && !resolved.done_path.exists() {
119 None
120 } else {
121 Some(&done)
122 };
123 let max_depth = resolved.config.queue.max_dependency_depth.unwrap_or(10);
124 match queue::validate_queue_set(
125 &before,
126 done_ref,
127 &resolved.id_prefix,
128 resolved.id_width,
129 max_depth,
130 )
131 .context("validate queue set before scan")
132 {
133 Ok(warnings) => {
134 queue::log_warnings(&warnings);
135 }
136 Err(err) => {
137 let preface = format!("Scan validation failed before run.\n{err:#}");
138 let outcome = runutil::apply_git_revert_mode_with_context(
139 &resolved.repo_root,
140 opts.git_revert_mode,
141 runutil::RevertPromptContext::new("Scan validation failure (pre-run)", false)
142 .with_preface(preface),
143 opts.revert_prompt.as_ref(),
144 )?;
145 return Err(err).context(runutil::format_revert_failure_message(
146 "Scan validation failed before run.",
147 outcome,
148 ));
149 }
150 }
151 let before_ids = queue::task_id_set(&before);
152
153 let scan_version = resolved
154 .config
155 .agent
156 .scan_prompt_version
157 .unwrap_or_default();
158 let template = prompts::load_scan_prompt(&resolved.repo_root, scan_version, opts.mode)?;
159 let project_type = resolved.config.project_type.unwrap_or(ProjectType::Code);
160 let mut prompt = prompts::render_scan_prompt(
161 &template,
162 &opts.focus,
163 opts.mode,
164 scan_version,
165 project_type,
166 &resolved.config,
167 )?;
168
169 prompt = prompts::wrap_with_repoprompt_requirement(&prompt, opts.repoprompt_tool_injection);
170 prompt = prompts::wrap_with_instruction_files(&resolved.repo_root, &prompt, &resolved.config)?;
171
172 let settings = resolve_scan_runner_settings(resolved, &opts)?;
173 let bins = runner::resolve_binaries(&resolved.config.agent);
174 let retry_policy = runutil::RunnerRetryPolicy::from_config(&resolved.config.agent.runner_retry)
177 .unwrap_or_default();
178
179 let output = runutil::run_prompt_with_handling(
180 runutil::RunnerInvocation {
181 repo_root: &resolved.repo_root,
182 runner_kind: settings.runner,
183 bins,
184 model: settings.model,
185 reasoning_effort: settings.reasoning_effort,
186 runner_cli: settings.runner_cli,
187 prompt: &prompt,
188 timeout: None,
189 permission_mode: settings.permission_mode,
190 revert_on_error: true,
191 git_revert_mode: opts.git_revert_mode,
192 output_handler: opts.output_handler.clone(),
193 output_stream: if opts.output_handler.is_some() {
194 runner::OutputStream::HandlerOnly
195 } else {
196 runner::OutputStream::Terminal
197 },
198 revert_prompt: opts.revert_prompt.clone(),
199 phase_type: PhaseType::SinglePhase,
200 session_id: None,
201 retry_policy,
202 },
203 runutil::RunnerErrorMessages {
204 log_label: "scan runner",
205 interrupted_msg: "Scan runner interrupted: the agent run was canceled.",
206 timeout_msg: "Scan runner timed out: the agent run exceeded the time limit. Changes in the working tree were NOT reverted; review the repo state manually.",
207 terminated_msg: "Scan runner terminated: the agent was stopped by a signal. Rerunning the command is recommended.",
208 non_zero_msg: |code| {
209 format!(
210 "Scan runner failed: the agent exited with a non-zero code ({code}). Rerunning the command is recommended after investigating the cause."
211 )
212 },
213 other_msg: |err| {
214 format!(
215 "Scan runner failed: the agent could not be started or encountered an error. Error: {:#}",
216 err
217 )
218 },
219 },
220 )?;
221
222 let mut after = match queue::load_queue(&resolved.queue_path)
223 .with_context(|| format!("read queue {}", resolved.queue_path.display()))
224 {
225 Ok(queue) => queue,
226 Err(err) => {
227 let mut safeguard_msg = String::new();
228 match fsutil::safeguard_text_dump_redacted("scan_error", &output.stdout) {
229 Ok(path) => {
230 let dump_type = if is_debug_mode() { "raw" } else { "redacted" };
231 safeguard_msg = format!("\n({dump_type} stdout saved to {})", path.display());
232 }
233 Err(e) => {
234 log::warn!("failed to save safeguard dump: {}", e);
235 }
236 }
237 let context = format!(
238 "{}{}",
239 "Scan failed to reload queue after runner output.", safeguard_msg
240 );
241 let preface = format!("{context}\n{err:#}");
242 let outcome = runutil::apply_git_revert_mode_with_context(
243 &resolved.repo_root,
244 opts.git_revert_mode,
245 runutil::RevertPromptContext::new("Scan queue read failure", false)
246 .with_preface(preface),
247 opts.revert_prompt.as_ref(),
248 )?;
249 return Err(err).context(runutil::format_revert_failure_message(&context, outcome));
250 }
251 };
252
253 let done_after = queue::load_queue_or_default(&resolved.done_path)
254 .with_context(|| format!("read done {}", resolved.done_path.display()))?;
255 let done_after_ref = if done_after.tasks.is_empty() && !resolved.done_path.exists() {
256 None
257 } else {
258 Some(&done_after)
259 };
260 match queue::validate_queue_set(
261 &after,
262 done_after_ref,
263 &resolved.id_prefix,
264 resolved.id_width,
265 max_depth,
266 )
267 .context("validate queue set after scan")
268 {
269 Ok(warnings) => {
270 queue::log_warnings(&warnings);
271 }
272 Err(err) => {
273 let mut safeguard_msg = String::new();
274 match fsutil::safeguard_text_dump_redacted("scan_validation_error", &output.stdout) {
275 Ok(path) => {
276 let dump_type = if is_debug_mode() { "raw" } else { "redacted" };
277 safeguard_msg = format!("\n({dump_type} stdout saved to {})", path.display());
278 }
279 Err(e) => {
280 log::warn!("failed to save safeguard dump: {}", e);
281 }
282 }
283 let context = format!("{}{}", "Scan validation failed after run.", safeguard_msg);
284 let preface = format!("{context}\n{err:#}");
285 let outcome = runutil::apply_git_revert_mode_with_context(
286 &resolved.repo_root,
287 opts.git_revert_mode,
288 runutil::RevertPromptContext::new("Scan validation failure (post-run)", false)
289 .with_preface(preface),
290 opts.revert_prompt.as_ref(),
291 )?;
292 return Err(err).context(runutil::format_revert_failure_message(&context, outcome));
293 }
294 }
295
296 let added = queue::added_tasks(&before_ids, &after);
297 if !added.is_empty() {
298 let added_ids: Vec<String> = added.iter().map(|(id, _)| id.clone()).collect();
299 let now = timeutil::now_utc_rfc3339_or_fallback();
300 let default_request = format!("scan: {}", opts.focus);
301 queue::backfill_missing_fields(&mut after, &added_ids, &default_request, &now);
302 queue::save_queue(&resolved.queue_path, &after)
303 .context("save queue with backfilled fields")?;
304 }
305 if added.is_empty() {
306 log::info!("Scan completed. No new tasks detected.");
307 } else {
308 log::info!("Scan added {} task(s):", added.len());
309 for (id, title) in added.iter().take(15) {
310 log::info!("- {}: {}", id, title);
311 }
312 if added.len() > 15 {
313 log::info!("...and {} more.", added.len() - 15);
314 }
315 }
316 Ok(())
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::contracts::{
323 ClaudePermissionMode, Config, GitRevertMode, RunnerApprovalMode, RunnerCliConfigRoot,
324 RunnerCliOptionsPatch, RunnerOutputFormat, RunnerPlanMode, RunnerSandboxMode,
325 RunnerVerbosity, UnsupportedOptionPolicy,
326 };
327 use std::collections::BTreeMap;
328 use std::path::PathBuf;
329 use tempfile::TempDir;
330
331 fn resolved_with_config(config: Config) -> (config::Resolved, TempDir) {
332 let dir = TempDir::new().expect("temp dir");
333 let repo_root = dir.path().to_path_buf();
334 let queue_rel = config
335 .queue
336 .file
337 .clone()
338 .unwrap_or_else(|| PathBuf::from(".ralph/queue.json"));
339 let done_rel = config
340 .queue
341 .done_file
342 .clone()
343 .unwrap_or_else(|| PathBuf::from(".ralph/done.json"));
344 let id_prefix = config
345 .queue
346 .id_prefix
347 .clone()
348 .unwrap_or_else(|| "RQ".to_string());
349 let id_width = config.queue.id_width.unwrap_or(4) as usize;
350
351 (
352 config::Resolved {
353 config,
354 repo_root: repo_root.clone(),
355 queue_path: repo_root.join(queue_rel),
356 done_path: repo_root.join(done_rel),
357 id_prefix,
358 id_width,
359 global_config_path: None,
360 project_config_path: Some(repo_root.join(".ralph/config.json")),
361 },
362 dir,
363 )
364 }
365
366 fn scan_opts() -> ScanOptions {
367 ScanOptions {
368 focus: "scan".to_string(),
369 mode: ScanMode::Maintenance,
370 runner_override: None,
371 model_override: None,
372 reasoning_effort_override: None,
373 runner_cli_overrides: RunnerCliOptionsPatch::default(),
374 force: false,
375 repoprompt_tool_injection: false,
376 git_revert_mode: GitRevertMode::Ask,
377 lock_mode: ScanLockMode::Held,
378 output_handler: None,
379 revert_prompt: None,
380 }
381 }
382
383 #[test]
384 fn scan_respects_config_permission_mode_when_approval_default() {
385 let mut config = Config::default();
386 config.agent.claude_permission_mode = Some(ClaudePermissionMode::AcceptEdits);
387 config.agent.runner_cli = Some(RunnerCliConfigRoot {
388 defaults: RunnerCliOptionsPatch {
389 output_format: Some(RunnerOutputFormat::StreamJson),
390 verbosity: Some(RunnerVerbosity::Normal),
391 approval_mode: Some(RunnerApprovalMode::Default),
392 sandbox: Some(RunnerSandboxMode::Default),
393 plan_mode: Some(RunnerPlanMode::Default),
394 unsupported_option_policy: Some(UnsupportedOptionPolicy::Warn),
395 },
396 runners: BTreeMap::new(),
397 });
398
399 let (resolved, _dir) = resolved_with_config(config);
400 let settings = resolve_scan_runner_settings(&resolved, &scan_opts()).expect("settings");
401 let effective = settings
402 .runner_cli
403 .effective_claude_permission_mode(settings.permission_mode);
404 assert_eq!(effective, Some(ClaudePermissionMode::AcceptEdits));
405 }
406
407 #[test]
408 fn scan_cli_override_yolo_bypasses_permission_mode() {
409 let mut config = Config::default();
410 config.agent.claude_permission_mode = Some(ClaudePermissionMode::AcceptEdits);
411 config.agent.runner_cli = Some(RunnerCliConfigRoot {
412 defaults: RunnerCliOptionsPatch {
413 output_format: Some(RunnerOutputFormat::StreamJson),
414 verbosity: Some(RunnerVerbosity::Normal),
415 approval_mode: Some(RunnerApprovalMode::Default),
416 sandbox: Some(RunnerSandboxMode::Default),
417 plan_mode: Some(RunnerPlanMode::Default),
418 unsupported_option_policy: Some(UnsupportedOptionPolicy::Warn),
419 },
420 runners: BTreeMap::new(),
421 });
422
423 let mut opts = scan_opts();
424 opts.runner_cli_overrides = RunnerCliOptionsPatch {
425 approval_mode: Some(RunnerApprovalMode::Yolo),
426 ..RunnerCliOptionsPatch::default()
427 };
428
429 let (resolved, _dir) = resolved_with_config(config);
430 let settings = resolve_scan_runner_settings(&resolved, &opts).expect("settings");
431 let effective = settings
432 .runner_cli
433 .effective_claude_permission_mode(settings.permission_mode);
434 assert_eq!(effective, Some(ClaudePermissionMode::BypassPermissions));
435 }
436
437 #[test]
438 fn scan_fails_fast_when_safe_approval_requires_prompt() {
439 let mut config = Config::default();
440 config.agent.runner_cli = Some(RunnerCliConfigRoot {
441 defaults: RunnerCliOptionsPatch {
442 output_format: Some(RunnerOutputFormat::StreamJson),
443 approval_mode: Some(RunnerApprovalMode::Safe),
444 unsupported_option_policy: Some(UnsupportedOptionPolicy::Error),
445 ..RunnerCliOptionsPatch::default()
446 },
447 runners: BTreeMap::new(),
448 });
449
450 let (resolved, _dir) = resolved_with_config(config);
451 let err = resolve_scan_runner_settings(&resolved, &scan_opts()).expect_err("error");
452 assert!(err.to_string().contains("approval_mode=safe"));
453 }
454}