Skip to main content

ralph/commands/
scan.rs

1//! Task scanning command that inspects repo state and updates the queue.
2//!
3//! Responsibilities:
4//! - Validate queue state before/after scanning and persist updated tasks.
5//! - Render scan prompts with repo context and dispatch runner execution.
6//! - Enforce clean-repo and queue-lock safety around scan operations.
7//!
8//! Not handled here:
9//! - CLI parsing or interactive UI wiring.
10//! - Runner process implementation details or output parsing.
11//! - Queue schema definitions or config persistence.
12//!
13//! Invariants/assumptions:
14//! - Queue/done files are the source of truth for task ordering and status.
15//! - Runner execution requires stream-json output for parsing.
16//! - Permission/approval defaults come from config unless overridden at CLI.
17
18use 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
27/// Global flag indicating if debug mode is enabled.
28/// This is set by the CLI when `--debug` flag is used.
29static DEBUG_MODE: AtomicBool = AtomicBool::new(false);
30
31/// Set the global debug mode flag.
32pub fn set_debug_mode(enabled: bool) {
33    DEBUG_MODE.store(enabled, Ordering::SeqCst);
34}
35
36/// Check if debug mode is enabled.
37fn 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    /// How to handle queue locking (acquire vs already-held by caller).
53    pub lock_mode: ScanLockMode,
54    /// Optional output handler for streaming scan output.
55    pub output_handler: Option<runner::OutputHandler>,
56    /// Optional revert prompt handler for interactive UIs.
57    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    // Prevents catastrophic data loss if scan fails and reverts uncommitted changes.
99    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    // Two-pass mode disabled for scan (only generates findings, should not implement)
175
176    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}