1use anyhow::Result;
17use clap::{Args, ValueEnum};
18
19use crate::{agent, commands::scan as scan_cmd, config};
20
21#[derive(Clone, Copy, Debug, Default, ValueEnum, PartialEq, Eq)]
23pub enum ScanMode {
24 #[default]
27 General,
28 Maintenance,
31 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 let focus = if !args.prompt.is_empty() {
48 args.prompt.join(" ")
49 } else {
50 args.focus.clone()
51 };
52
53 let mode = match (args.mode, focus.trim().is_empty()) {
55 (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 (Some(mode), _) => mode,
67 (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 #[arg(value_name = "PROMPT")]
106 pub prompt: Vec<String>,
107
108 #[arg(long, default_value = "")]
110 pub focus: String,
111
112 #[arg(short = 'm', long, value_enum)]
116 pub mode: Option<ScanMode>,
117
118 #[arg(long, value_name = "NAME")]
121 pub profile: Option<String>,
122
123 #[arg(long)]
125 pub runner: Option<String>,
126
127 #[arg(long)]
129 pub model: Option<String>,
130
131 #[arg(short = 'e', long)]
134 pub effort: Option<String>,
135
136 #[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 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 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 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 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 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 assert_eq!(args_explicit.mode, Some(ScanMode::General));
465 assert_eq!(args_explicit.prompt, vec!["test", "focus"]);
466
467 assert_eq!(args_implicit.mode, None);
469 assert_eq!(args_implicit.prompt, vec!["test", "focus"]);
470
471 }
475 _ => panic!("expected scan commands"),
476 }
477 }
478}