Skip to main content

ralph/cli/
scan.rs

1//! `ralph scan` command: Clap types and handler.
2//!
3//! Responsibilities:
4//! - Define clap arguments for scan commands.
5//! - Dispatch scan execution with resolved runner overrides.
6//!
7//! Not handled here:
8//! - Queue storage and task persistence.
9//! - Runner implementation details or model execution.
10//! - Config precedence rules beyond loading the current repo config.
11//!
12//! Invariants/assumptions:
13//! - Configuration is resolved from the current working directory.
14//! - Runner overrides are validated by the agent resolution helpers.
15
16use anyhow::Result;
17use clap::{Args, ValueEnum};
18
19use crate::{agent, commands::scan as scan_cmd, config};
20
21/// Scan mode determining the focus of the repository scan.
22#[derive(Clone, Copy, Debug, Default, ValueEnum, PartialEq, Eq)]
23pub enum ScanMode {
24    /// General mode: user provides focus prompt without specifying a mode.
25    /// Uses task building instructions without maintenance or innovation specific criteria.
26    #[default]
27    General,
28    /// Maintenance mode: find bugs, workflow gaps, design flaws, repo rules violations.
29    /// Focused on break-fix maintenance and code hygiene.
30    Maintenance,
31    /// Innovation mode: find feature gaps, use-case completeness issues, enhancement opportunities.
32    /// Focus on new features and strategic additions.
33    Innovation,
34}
35
36pub fn handle_scan(args: ScanArgs, force: bool) -> Result<()> {
37    let resolved = config::resolve_from_cwd_with_profile(args.profile.as_deref())?;
38    let overrides = agent::resolve_agent_overrides(&agent::AgentArgs {
39        runner: args.runner.clone(),
40        model: args.model.clone(),
41        effort: args.effort.clone(),
42        repo_prompt: args.repo_prompt,
43        runner_cli: args.runner_cli.clone(),
44    })?;
45
46    // Merge positional prompt and --focus flag: positional takes precedence
47    let focus = if !args.prompt.is_empty() {
48        args.prompt.join(" ")
49    } else {
50        args.focus.clone()
51    };
52
53    // Determine the effective scan mode
54    let mode = match (args.mode, focus.trim().is_empty()) {
55        // No mode specified and no focus prompt → show help/error
56        (None, true) => {
57            return Err(anyhow::anyhow!(
58                "Please provide one of:\n\
59                 • A focus prompt: ralph scan \"your focus here\"\n\
60                 • A scan mode: ralph scan --mode maintenance\n\
61                 • Both: ralph scan --mode innovation \"your focus here\"\n\n\
62                 Run 'ralph scan --help' for more information."
63            ));
64        }
65        // Mode specified → use that mode
66        (Some(mode), _) => mode,
67        // No mode specified but focus prompt provided → use General mode
68        (None, false) => ScanMode::General,
69    };
70
71    scan_cmd::run_scan(
72        &resolved,
73        scan_cmd::ScanOptions {
74            focus,
75            mode,
76            runner_override: overrides.runner,
77            model_override: overrides.model,
78            reasoning_effort_override: overrides.reasoning_effort,
79            runner_cli_overrides: overrides.runner_cli,
80            force,
81            repoprompt_tool_injection: agent::resolve_rp_required(args.repo_prompt, &resolved),
82            git_revert_mode: resolved
83                .config
84                .agent
85                .git_revert_mode
86                .unwrap_or(crate::contracts::GitRevertMode::Ask),
87            lock_mode: if force {
88                scan_cmd::ScanLockMode::Held
89            } else {
90                scan_cmd::ScanLockMode::Acquire
91            },
92            output_handler: None,
93            revert_prompt: None,
94        },
95    )
96}
97
98#[derive(Args)]
99#[command(
100    about = "Scan repository for new tasks and focus areas",
101    after_long_help = "Runner selection:\n  - Override runner/model/effort for this invocation using flags.\n  - Defaults come from config when flags are omitted.\n  - Use --profile to apply a named profile (quick, thorough, or custom).\n\nRunner CLI options:\n  - Override approval/sandbox/verbosity/plan-mode via flags.\n  - Unsupported options follow --unsupported-option-policy.\n\nProfile precedence:\n  - CLI flags > task.agent > selected profile > base config\n\nSafety:\n  - Clean-repo checks allow changes to `.ralph/queue.{json,jsonc}`, `.ralph/done.{json,jsonc}`, and `.ralph/config.{json,jsonc}`.\n  - Use `--force` to bypass the clean-repo check (and stale queue locks) entirely if needed.\n\nExamples:\n  ralph scan \"production readiness gaps\"                              # General mode with focus prompt\n  ralph scan --focus \"production readiness gaps\"                     # General mode with --focus flag\n  ralph scan --mode maintenance \"security audit\"                     # Maintenance mode with focus\n  ralph scan --mode maintenance                                        # Maintenance mode without focus\n  ralph scan --mode innovation \"feature gaps for CLI\"                # Innovation mode with focus\n  ralph scan --mode innovation                                         # Innovation mode without focus\n  ralph scan -m innovation \"enhancement opportunities\"               # Short flag for mode\n  ralph scan --profile thorough \"deep risk audit\"                    # Use thorough profile (codex/gpt-5.4/high/3-phase)\n  ralph scan --profile quick \"quick bug fixes\"                       # Use quick profile (codex/gpt-5.4/low/1-phase)\n  ralph scan --runner opencode --model gpt-5.2 \"CI and safety gaps\"  # With runner overrides\n  ralph scan --runner gemini --model gemini-3-flash-preview \"risk audit\"\n  ralph scan --runner codex --model gpt-5.4 --effort high \"queue correctness\"\n  ralph scan --approval-mode auto-edits --runner claude \"auto edits review\"\n  ralph scan --sandbox disabled --runner codex \"sandbox audit\"\n  ralph scan --repo-prompt plan \"Deep codebase analysis\"\n  ralph scan --repo-prompt off \"Quick surface scan\"\n  ralph scan --runner kimi \"risk audit\"\n  ralph scan --runner pi \"risk audit\""
102)]
103pub struct ScanArgs {
104    /// Optional focus prompt as positional argument (alternative to --focus).
105    #[arg(value_name = "PROMPT")]
106    pub prompt: Vec<String>,
107
108    /// Optional focus prompt to guide the scan.
109    #[arg(long, default_value = "")]
110    pub focus: String,
111
112    /// Scan mode: maintenance for code hygiene and bug finding,
113    /// innovation for feature discovery and enhancement opportunities,
114    /// general (default) when only focus prompt is provided.
115    #[arg(short = 'm', long, value_enum)]
116    pub mode: Option<ScanMode>,
117
118    /// Named configuration profile to apply before resolving CLI overrides.
119    /// Examples: quick, thorough, quick-fix
120    #[arg(long, value_name = "NAME")]
121    pub profile: Option<String>,
122
123    /// Runner to use. CLI flag overrides config defaults (project > global > built-in).
124    #[arg(long)]
125    pub runner: Option<String>,
126
127    /// Model to use. CLI flag overrides config defaults (project > global > built-in).
128    #[arg(long)]
129    pub model: Option<String>,
130
131    /// Codex reasoning effort. CLI flag overrides config defaults (project > global > built-in).
132    /// Ignored for opencode and gemini.
133    #[arg(short = 'e', long)]
134    pub effort: Option<String>,
135
136    /// RepoPrompt mode (tools, plan, off). Alias: -rp.
137    #[arg(long = "repo-prompt", value_enum, value_name = "MODE")]
138    pub repo_prompt: Option<agent::RepoPromptMode>,
139
140    #[command(flatten)]
141    pub runner_cli: agent::RunnerCliArgs,
142}
143
144#[cfg(test)]
145mod tests {
146    use clap::{CommandFactory, Parser};
147
148    use crate::cli::Cli;
149    use crate::cli::scan::ScanMode;
150
151    #[test]
152    fn scan_help_examples_include_repo_prompt_focus() {
153        let mut cmd = Cli::command();
154        let scan = cmd.find_subcommand_mut("scan").expect("scan subcommand");
155        let help = scan.render_long_help().to_string();
156
157        assert!(
158            help.contains("--repo-prompt plan \"Deep codebase analysis\""),
159            "missing repo-prompt plan example: {help}"
160        );
161        assert!(
162            help.contains("--repo-prompt off \"Quick surface scan\""),
163            "missing repo-prompt off example: {help}"
164        );
165    }
166
167    #[test]
168    fn scan_help_examples_include_positional_prompt() {
169        let mut cmd = Cli::command();
170        let scan = cmd.find_subcommand_mut("scan").expect("scan subcommand");
171        let help = scan.render_long_help().to_string();
172
173        assert!(
174            help.contains("ralph scan \"production readiness gaps\""),
175            "missing positional prompt example: {help}"
176        );
177        assert!(
178            help.contains("# General mode with focus prompt"),
179            "missing general mode comment: {help}"
180        );
181        assert!(
182            help.contains("# General mode with --focus flag"),
183            "missing flag-based prompt comment: {help}"
184        );
185    }
186
187    #[test]
188    fn scan_help_examples_include_runner_cli_overrides() {
189        let mut cmd = Cli::command();
190        let scan = cmd.find_subcommand_mut("scan").expect("scan subcommand");
191        let help = scan.render_long_help().to_string();
192
193        assert!(
194            help.contains("--approval-mode auto-edits --runner claude"),
195            "missing approval-mode example: {help}"
196        );
197        assert!(
198            help.contains("--sandbox disabled --runner codex"),
199            "missing sandbox example: {help}"
200        );
201    }
202
203    #[test]
204    fn scan_parses_repo_prompt_and_effort_alias() {
205        let cli = Cli::try_parse_from(["ralph", "scan", "--repo-prompt", "tools", "-e", "high"])
206            .expect("parse");
207
208        match cli.command {
209            crate::cli::Command::Scan(args) => {
210                assert_eq!(args.repo_prompt, Some(crate::agent::RepoPromptMode::Tools));
211                assert_eq!(args.effort.as_deref(), Some("high"));
212            }
213            _ => panic!("expected scan command"),
214        }
215    }
216
217    #[test]
218    fn scan_parses_runner_cli_overrides() {
219        let cli = Cli::try_parse_from([
220            "ralph",
221            "scan",
222            "--approval-mode",
223            "auto-edits",
224            "--sandbox",
225            "disabled",
226        ])
227        .expect("parse");
228
229        match cli.command {
230            crate::cli::Command::Scan(args) => {
231                assert_eq!(args.runner_cli.approval_mode.as_deref(), Some("auto-edits"));
232                assert_eq!(args.runner_cli.sandbox.as_deref(), Some("disabled"));
233            }
234            _ => panic!("expected scan command"),
235        }
236    }
237
238    #[test]
239    fn scan_parses_positional_prompt() {
240        let cli = Cli::try_parse_from(["ralph", "scan", "production", "readiness", "gaps"])
241            .expect("parse");
242
243        match cli.command {
244            crate::cli::Command::Scan(args) => {
245                assert_eq!(args.prompt, vec!["production", "readiness", "gaps"]);
246                assert!(args.focus.is_empty());
247            }
248            _ => panic!("expected scan command"),
249        }
250    }
251
252    #[test]
253    fn scan_parses_positional_prompt_with_flags() {
254        let cli = Cli::try_parse_from([
255            "ralph", "scan", "--runner", "opencode", "--model", "gpt-5.2", "CI", "and", "safety",
256            "gaps",
257        ])
258        .expect("parse");
259
260        match cli.command {
261            crate::cli::Command::Scan(args) => {
262                assert_eq!(args.runner.as_deref(), Some("opencode"));
263                assert_eq!(args.model.as_deref(), Some("gpt-5.2"));
264                assert_eq!(args.prompt, vec!["CI", "and", "safety", "gaps"]);
265            }
266            _ => panic!("expected scan command"),
267        }
268    }
269
270    #[test]
271    fn scan_backward_compatible_with_focus_flag() {
272        let cli = Cli::try_parse_from(["ralph", "scan", "--focus", "production readiness gaps"])
273            .expect("parse");
274
275        match cli.command {
276            crate::cli::Command::Scan(args) => {
277                assert_eq!(args.focus, "production readiness gaps");
278                assert!(args.prompt.is_empty());
279            }
280            _ => panic!("expected scan command"),
281        }
282    }
283
284    #[test]
285    fn scan_positional_takes_precedence_over_focus_flag() {
286        // When both positional and --focus are provided, positional takes precedence
287        let cli = Cli::try_parse_from([
288            "ralph",
289            "scan",
290            "--focus",
291            "flag-based focus",
292            "positional",
293            "focus",
294        ])
295        .expect("parse");
296
297        match cli.command {
298            crate::cli::Command::Scan(args) => {
299                // Both should be parsed correctly
300                assert_eq!(args.focus, "flag-based focus");
301                assert_eq!(args.prompt, vec!["positional", "focus"]);
302            }
303            _ => panic!("expected scan command"),
304        }
305    }
306
307    #[test]
308    fn scan_parses_mode_maintenance() {
309        let cli = Cli::try_parse_from(["ralph", "scan", "--mode", "maintenance"]).expect("parse");
310
311        match cli.command {
312            crate::cli::Command::Scan(args) => {
313                assert_eq!(args.mode, Some(ScanMode::Maintenance));
314            }
315            _ => panic!("expected scan command"),
316        }
317    }
318
319    #[test]
320    fn scan_parses_mode_innovation() {
321        let cli = Cli::try_parse_from(["ralph", "scan", "--mode", "innovation"]).expect("parse");
322
323        match cli.command {
324            crate::cli::Command::Scan(args) => {
325                assert_eq!(args.mode, Some(ScanMode::Innovation));
326            }
327            _ => panic!("expected scan command"),
328        }
329    }
330
331    #[test]
332    fn scan_parses_mode_general() {
333        let cli = Cli::try_parse_from(["ralph", "scan", "--mode", "general"]).expect("parse");
334
335        match cli.command {
336            crate::cli::Command::Scan(args) => {
337                assert_eq!(args.mode, Some(ScanMode::General));
338            }
339            _ => panic!("expected scan command"),
340        }
341    }
342
343    #[test]
344    fn scan_parses_mode_short_flag() {
345        let cli = Cli::try_parse_from(["ralph", "scan", "-m", "innovation"]).expect("parse");
346
347        match cli.command {
348            crate::cli::Command::Scan(args) => {
349                assert_eq!(args.mode, Some(ScanMode::Innovation));
350            }
351            _ => panic!("expected scan command"),
352        }
353    }
354
355    #[test]
356    fn scan_no_mode_no_focus_requires_input() {
357        // When no --mode flag and no focus prompt provided, mode should be None
358        // This will result in an error telling the user to provide input
359        let cli = Cli::try_parse_from(["ralph", "scan"]).expect("parse");
360
361        match cli.command {
362            crate::cli::Command::Scan(args) => {
363                assert_eq!(args.mode, None);
364                assert!(args.prompt.is_empty());
365                assert!(args.focus.is_empty());
366            }
367            _ => panic!("expected scan command"),
368        }
369    }
370
371    #[test]
372    fn scan_focus_only_defaults_to_general_mode() {
373        // When only focus prompt is provided (no --mode), mode is None at parse time
374        // but handle_scan will resolve it to General
375        let cli = Cli::try_parse_from(["ralph", "scan", "production", "readiness"]).expect("parse");
376
377        match cli.command {
378            crate::cli::Command::Scan(args) => {
379                assert_eq!(args.mode, None);
380                assert_eq!(args.prompt, vec!["production", "readiness"]);
381            }
382            _ => panic!("expected scan command"),
383        }
384    }
385
386    #[test]
387    fn scan_explicit_maintenance_mode_with_focus() {
388        let cli = Cli::try_parse_from([
389            "ralph",
390            "scan",
391            "--mode",
392            "maintenance",
393            "security",
394            "audit",
395        ])
396        .expect("parse");
397
398        match cli.command {
399            crate::cli::Command::Scan(args) => {
400                assert_eq!(args.mode, Some(ScanMode::Maintenance));
401                assert_eq!(args.prompt, vec!["security", "audit"]);
402            }
403            _ => panic!("expected scan command"),
404        }
405    }
406
407    #[test]
408    fn scan_explicit_innovation_mode_without_focus() {
409        let cli = Cli::try_parse_from(["ralph", "scan", "--mode", "innovation"]).expect("parse");
410
411        match cli.command {
412            crate::cli::Command::Scan(args) => {
413                assert_eq!(args.mode, Some(ScanMode::Innovation));
414                assert!(args.prompt.is_empty());
415            }
416            _ => panic!("expected scan command"),
417        }
418    }
419
420    #[test]
421    fn scan_mode_with_positional_prompt() {
422        let cli = Cli::try_parse_from(["ralph", "scan", "--mode", "innovation", "feature gaps"])
423            .expect("parse");
424
425        match cli.command {
426            crate::cli::Command::Scan(args) => {
427                assert_eq!(args.mode, Some(ScanMode::Innovation));
428                assert_eq!(args.prompt, vec!["feature gaps"]);
429            }
430            _ => panic!("expected scan command"),
431        }
432    }
433
434    #[test]
435    fn scan_general_mode_explicit() {
436        let cli = Cli::try_parse_from(["ralph", "scan", "--mode", "general", "some", "focus"])
437            .expect("parse");
438
439        match cli.command {
440            crate::cli::Command::Scan(args) => {
441                assert_eq!(args.mode, Some(ScanMode::General));
442                assert_eq!(args.prompt, vec!["some", "focus"]);
443            }
444            _ => panic!("expected scan command"),
445        }
446    }
447
448    #[test]
449    fn scan_explicit_general_mode_equivalent_to_implicit_with_focus() {
450        // Regression test for RQ-0891: ensure --mode general works equivalently to default
451        let cli_explicit =
452            Cli::try_parse_from(["ralph", "scan", "--mode", "general", "test", "focus"])
453                .expect("parse explicit mode");
454
455        let cli_implicit =
456            Cli::try_parse_from(["ralph", "scan", "test", "focus"]).expect("parse implicit mode");
457
458        match (cli_explicit.command, cli_implicit.command) {
459            (
460                crate::cli::Command::Scan(args_explicit),
461                crate::cli::Command::Scan(args_implicit),
462            ) => {
463                // Explicit --mode general should parse to Some(General)
464                assert_eq!(args_explicit.mode, Some(ScanMode::General));
465                assert_eq!(args_explicit.prompt, vec!["test", "focus"]);
466
467                // Implicit (no --mode) should parse to None, but handle_scan resolves to General
468                assert_eq!(args_implicit.mode, None);
469                assert_eq!(args_implicit.prompt, vec!["test", "focus"]);
470
471                // Both should have the same focus resolution logic in handle_scan
472                // Explicit: (Some(General), false) => General
473                // Implicit: (None, false) => General
474            }
475            _ => panic!("expected scan commands"),
476        }
477    }
478}