Skip to main content

ralph/cli/
run.rs

1//! `ralph run ...` command group: Clap types and handler.
2//!
3//! Responsibilities:
4//! - Define clap structures for run commands and flags.
5//! - Route run subcommands to supervisor execution entry points.
6//!
7//! Not handled here:
8//! - Queue persistence and task status transitions (see `crate::queue`).
9//! - Runner implementations or model execution (see `crate::runner`).
10//! - Global configuration precedence rules (see `crate::config`).
11//!
12//! Invariants/assumptions:
13//! - Configuration is resolved from the current working directory.
14//! - Queue mutations occur inside downstream command handlers.
15
16use std::path::PathBuf;
17
18use anyhow::Result;
19use clap::{Args, Subcommand};
20
21use crate::{agent, commands::run as run_cmd, config, debuglog};
22
23pub fn handle_run(cmd: RunCommand, force: bool) -> Result<()> {
24    // Extract profile from the command to resolve config with the selected profile
25    let profile = match &cmd {
26        RunCommand::Resume(args) => args.agent.profile.as_deref(),
27        RunCommand::One(args) => args.agent.profile.as_deref(),
28        RunCommand::Loop(args) => args.agent.profile.as_deref(),
29        RunCommand::Parallel(_) => None,
30    };
31    let resolved = config::resolve_from_cwd_with_profile(profile)?;
32    match cmd {
33        RunCommand::Resume(args) => {
34            if args.debug {
35                debuglog::enable(&resolved.repo_root)?;
36            }
37            // Profile already applied during config resolution; just resolve remaining overrides
38            let overrides = agent::resolve_run_agent_overrides(&args.agent)?;
39
40            // Resume is essentially a loop with auto_resume=true
41            run_cmd::run_loop(
42                &resolved,
43                run_cmd::RunLoopOptions {
44                    max_tasks: 0, // No limit when resuming
45                    agent_overrides: overrides,
46                    force: args.force || force,
47                    auto_resume: true,
48                    starting_completed: 0,
49                    non_interactive: args.non_interactive,
50                    parallel_workers: None,
51                    wait_when_blocked: false,
52                    wait_poll_ms: 1000,
53                    wait_timeout_seconds: 0,
54                    notify_when_unblocked: false,
55                    wait_when_empty: false,
56                    empty_poll_ms: 30_000,
57                },
58            )
59        }
60        RunCommand::One(args) => {
61            if args.debug {
62                debuglog::enable(&resolved.repo_root)?;
63            }
64            // Profile already applied during config resolution; just resolve remaining overrides
65            let overrides = agent::resolve_run_agent_overrides(&args.agent)?;
66
67            if args.dry_run {
68                if args.parallel_worker {
69                    return Err(anyhow::anyhow!(
70                        "--dry-run cannot be used with --parallel-worker"
71                    ));
72                }
73                run_cmd::dry_run_one(&resolved, &overrides, args.id.as_deref())
74            } else {
75                if args.parallel_worker {
76                    let task_id = args.id.as_deref().ok_or_else(|| {
77                        anyhow::anyhow!("--parallel-worker requires --id <TASK_ID>")
78                    })?;
79                    let target_branch =
80                        args.parallel_target_branch.as_deref().ok_or_else(|| {
81                            anyhow::anyhow!("--parallel-worker requires --parallel-target-branch")
82                        })?;
83
84                    let mut worker_resolved = resolved.clone();
85                    worker_resolved.queue_path =
86                        args.coordinator_queue_path.clone().ok_or_else(|| {
87                            anyhow::anyhow!("--parallel-worker requires --coordinator-queue-path")
88                        })?;
89                    worker_resolved.done_path =
90                        args.coordinator_done_path.clone().ok_or_else(|| {
91                            anyhow::anyhow!("--parallel-worker requires --coordinator-done-path")
92                        })?;
93                    log::debug!(
94                        "parallel worker using queue/done paths: queue={}, done={}, target_branch={}",
95                        worker_resolved.queue_path.display(),
96                        worker_resolved.done_path.display(),
97                        target_branch
98                    );
99
100                    run_cmd::run_one_parallel_worker(
101                        &worker_resolved,
102                        &overrides,
103                        force,
104                        task_id,
105                        target_branch,
106                    )?;
107                    return Ok(());
108                }
109
110                if let Some(task_id) = args.id.as_deref() {
111                    run_cmd::run_one_with_id(&resolved, &overrides, force, task_id, None, None)?;
112                } else {
113                    run_cmd::run_one(&resolved, &overrides, force, None)?;
114                }
115                Ok(())
116            }
117        }
118        RunCommand::Loop(args) => {
119            if args.debug {
120                debuglog::enable(&resolved.repo_root)?;
121            }
122            // Profile already applied during config resolution; just resolve remaining overrides
123            let overrides = agent::resolve_run_agent_overrides(&args.agent)?;
124
125            if args.dry_run {
126                run_cmd::dry_run_loop(&resolved, &overrides)
127            } else {
128                run_cmd::run_loop(
129                    &resolved,
130                    run_cmd::RunLoopOptions {
131                        max_tasks: args.max_tasks,
132                        agent_overrides: overrides,
133                        force,
134                        auto_resume: args.resume,
135                        starting_completed: 0,
136                        non_interactive: args.non_interactive,
137                        parallel_workers: args.parallel,
138                        wait_when_blocked: args.wait_when_blocked,
139                        wait_poll_ms: args.wait_poll_ms,
140                        wait_timeout_seconds: args.wait_timeout_seconds,
141                        notify_when_unblocked: args.notify_when_unblocked,
142                        wait_when_empty: args.wait_when_empty,
143                        empty_poll_ms: args.empty_poll_ms,
144                    },
145                )
146            }
147        }
148        RunCommand::Parallel(args) => match args.command {
149            ParallelSubcommand::Status(status_args) => {
150                run_cmd::parallel_status(&resolved, status_args.json)
151            }
152            ParallelSubcommand::Retry(retry_args) => {
153                run_cmd::parallel_retry(&resolved, &retry_args.task, force)
154            }
155        },
156    }
157}
158
159#[derive(Args)]
160#[command(
161    about = "Run Ralph supervisor (executes queued tasks via codex/opencode/gemini/claude/cursor/kimi/pi)",
162    after_long_help = "Runner selection:\n\
163  - `ralph run` selects runner/model/effort with this precedence:\n\
164  1) CLI overrides (flags on `run one` / `run loop`)\n\
165  2) task's `agent` override (runner/model plus `model_effort` if set)\n\
166  3) otherwise: resolved config defaults (`agent.runner`, `agent.model`, `agent.reasoning_effort`).\n\
167 \n\
168 Notes:\n\
169	  - Allowed runners: codex, opencode, gemini, claude, cursor, kimi, pi\n\
170	  - Allowed models: gpt-5.4, gpt-5.3-codex, gpt-5.3-codex-spark, gpt-5.3, gpt-5.2-codex, gpt-5.2, zai-coding-plan/glm-4.7, gemini-3-pro-preview, gemini-3-flash-preview, sonnet, opus, kimi-for-coding (codex supports only gpt-5.4 + gpt-5.3-codex + gpt-5.3-codex-spark + gpt-5.3 + gpt-5.2-codex + gpt-5.2; opencode/gemini/claude/cursor/kimi/pi accept arbitrary model ids)\n\
171	  - `--effort` is codex-only and is ignored for other runners.\n\
172	  - `--git-revert-mode` controls whether Ralph reverts uncommitted changes on errors (ask, enabled, disabled).\n\
173	  - `--git-commit-push-on` / `--git-commit-push-off` control automatic git commit/push after successful runs.\n\
174	     - `--parallel` runs loop tasks concurrently in workspaces (clone-based).\n\
175	     - Workers push directly to the target branch after phase execution.\n\
176	  - Clean-repo checks allow changes to `.ralph/config.{json,jsonc}` (plus `.ralph/queue.{json,jsonc}` and `.ralph/done.{json,jsonc}`); use `--force` to bypass entirely.\n\
177	 \n\
178Phase-specific overrides:\n\
179	  Use --runner-phaseN, --model-phaseN, --effort-phaseN to override settings for a specific phase.\n\
180  Phase-specific flags take precedence over global flags for that phase.\n\
181  Single-pass (--phases 1) uses Phase 2 overrides.\n\
182 \n\
183  Precedence per phase (highest to lowest):\n\
184    1) CLI phase override (--runner-phaseN, --model-phaseN, --effort-phaseN)\n\
185    2) Task phase override (task.agent.phase_overrides.phaseN.*)\n\
186    3) Config phase override (agent.phase_overrides.phaseN.*)\n\
187    4) CLI global override (--runner, --model, --effort)\n\
188    5) Task global override (task.agent.runner/model/model_effort)\n\
189    6) Config defaults (agent.*)\n\
190 \n\
191 To change defaults for this repo, edit .ralph/config.jsonc:\n\
192  version: 1\n\
193  agent:\n\
194  runner: codex\n\
195  model: gpt-5.4\n\
196  gemini_bin: gemini\n\
197 \n\
198Examples:\n\
199 ralph run one\n\
200 ralph run one --phases 2\n\
201 ralph run one --phases 1\n\
202 ralph run one --runner opencode --model gpt-5.2\n\
203 ralph run one --runner codex --model gpt-5.4 --effort high\n\
204 ralph run one --runner-phase1 codex --model-phase1 gpt-5.4 --effort-phase1 high\n\
205 ralph run one --runner-phase2 claude --model-phase2 opus\n\
206 ralph run one --runner gemini --model gemini-3-flash-preview\n\
207 ralph run one --runner pi --model gpt-5.2\n\
208 ralph run one --include-draft\n\
209 ralph run one --git-revert-mode disabled\n\
210 ralph run one --git-commit-push-off\n\
211 ralph run one --lfs-check\n\
212 ralph run loop --max-tasks 0\n\
213 ralph run loop --max-tasks 1 --runner opencode --model gpt-5.2\n\
214 ralph run loop --include-draft --max-tasks 1\n\
215 ralph run loop --git-revert-mode ask --max-tasks 1\n\
216 ralph run loop --git-commit-push-on --max-tasks 1\n\
217 ralph run loop --lfs-check --max-tasks 1\n\
218 ralph run loop --parallel --max-tasks 4\n\
219	 ralph run loop --parallel 4 --max-tasks 8\n\
220	 ralph run resume\n\
221	 ralph run resume --force\n\
222	 ralph run loop --resume --max-tasks 5"
223)]
224pub struct RunArgs {
225    #[command(subcommand)]
226    pub command: RunCommand,
227}
228
229#[derive(Subcommand)]
230pub enum RunCommand {
231    /// Resume an interrupted session from where it left off.
232    #[command(
233        about = "Resume an interrupted session from where it left off",
234        after_long_help = "Examples:
235 ralph run resume
236 ralph run resume --force"
237    )]
238    Resume(ResumeArgs),
239    #[command(
240        about = "Run exactly one task (the first todo in the configured queue file)",
241        after_long_help = "Runner selection (precedence):\n\
242 1) CLI overrides (--runner/--model/--effort)\n\
243 2) task.agent in the configured queue file (if present)\n\
244 3) selected profile (if --profile specified)\n\
245 4) config defaults (.ralph/config.jsonc then ~/.config/ralph/config.jsonc, with .json fallback)\n\
246\n\
247Examples:\n\
248 ralph run one\n\
249 ralph run one --id RQ-0001\n\
250 ralph run one --debug\n\
251 ralph run one --profile quick (codex/gpt-5.4, 1-phase, low effort)\n\
252 ralph run one --profile thorough (codex/gpt-5.4, 3-phase, high effort)\n\
253 ralph run one --phases 3 (plan/implement+CI/review+complete)\n\
254 ralph run one --phases 2 (plan/implement)\n\
255 ralph run one --phases 1 (single-pass)\n\
256 ralph run one --quick (single-pass, same as --phases 1)\n\
257 ralph run one --runner opencode --model gpt-5.2\n\
258 ralph run one --runner gemini --model gemini-3-flash-preview\n\
259 ralph run one --runner pi --model gpt-5.2\n\
260 ralph run one --runner codex --model gpt-5.4 --effort high\n\
261 ralph run one --runner-phase1 codex --model-phase1 gpt-5.4 --effort-phase1 high\n\
262 ralph run one --runner-phase2 claude --model-phase2 opus\n\
263 ralph run one --include-draft\n\
264 ralph run one --git-revert-mode enabled\n\
265 ralph run one --git-commit-push-off\n\
266 ralph run one --lfs-check\n\
267 ralph run one --repo-prompt plan\n\
268 ralph run one --repo-prompt off\n\
269 ralph run one --non-interactive\n\
270 ralph run one --dry-run\n\
271 ralph run one --dry-run --include-draft\n\
272 ralph run one --dry-run --id RQ-0001"
273    )]
274    One(RunOneArgs),
275    #[command(
276        about = "Run tasks repeatedly until no todo remain (or --max-tasks is reached)",
277        after_long_help = "Examples:\n\
278 ralph run loop --max-tasks 0\n\
279 ralph run loop --profile quick --max-tasks 5 (codex/gpt-5.4, 1-phase, low effort)\n\
280 ralph run loop --profile thorough --max-tasks 5 (codex/gpt-5.4, 3-phase, high effort)\n\
281 ralph run loop --phases 3 --max-tasks 0 (plan/implement+CI/review+complete)\n\
282 ralph run loop --phases 2 --max-tasks 0 (plan/implement)\n\
283 ralph run loop --phases 1 --max-tasks 1 (single-pass)\n\
284 ralph run loop --quick --max-tasks 1 (single-pass, same as --phases 1)\n\
285 ralph run loop --max-tasks 3\n\
286 ralph run loop --max-tasks 1 --debug\n\
287 ralph run loop --max-tasks 1 --runner opencode --model gpt-5.2\n\
288 ralph run loop --runner-phase1 codex --model-phase1 gpt-5.4 --effort-phase1 high --max-tasks 1\n\
289 ralph run loop --runner-phase2 claude --model-phase2 opus --max-tasks 1\n\
290 ralph run loop --include-draft --max-tasks 1\n\
291 ralph run loop --git-revert-mode disabled --max-tasks 1\n\
292 ralph run loop --git-commit-push-off --max-tasks 1\n\
293 ralph run loop --repo-prompt tools --max-tasks 1\n\
294 ralph run loop --repo-prompt off --max-tasks 1\n\
295	 ralph run loop --lfs-check --max-tasks 1\n\
296	 ralph run loop --dry-run\n\
297	 ralph run loop --wait-when-blocked\n\
298	 ralph run loop --wait-when-blocked --wait-timeout-seconds 600\n\
299	 ralph run loop --wait-when-blocked --wait-poll-ms 250\n\
300	 ralph run loop --wait-when-blocked --notify-when-unblocked"
301    )]
302    Loop(RunLoopArgs),
303    /// Manage parallel mode operations (status, retry blocked workers).
304    #[command(
305        about = "Manage parallel mode operations",
306        after_long_help = "Examples:\n\
307 ralph run parallel status\n\
308 ralph run parallel status --json\n\
309 ralph run parallel retry --task RQ-0001"
310    )]
311    Parallel(ParallelArgs),
312}
313
314// MergeAgent command removed in direct-push rewrite (Phase D)
315// Workers now push directly to the target branch without creating PRs
316
317#[derive(Args)]
318pub struct ResumeArgs {
319    /// Skip the confirmation prompt for stale sessions.
320    #[arg(long)]
321    pub force: bool,
322
323    /// Capture raw supervisor + runner output to .ralph/logs/debug.log.
324    #[arg(long)]
325    pub debug: bool,
326
327    /// Skip interactive prompts (for CI/non-interactive environments).
328    #[arg(long)]
329    pub non_interactive: bool,
330
331    #[command(flatten)]
332    pub agent: crate::agent::RunAgentArgs,
333}
334
335#[derive(Args)]
336pub struct RunOneArgs {
337    /// Capture raw supervisor + runner output to .ralph/logs/debug.log.
338    #[arg(long)]
339    pub debug: bool,
340
341    /// Run a specific task by ID.
342    #[arg(long, value_name = "TASK_ID")]
343    pub id: Option<String>,
344
345    /// Skip interactive prompts (for CI/non-interactive environments).
346    #[arg(long)]
347    pub non_interactive: bool,
348
349    /// Select a task and print why it would (or would not) run.
350    /// Does not invoke any runner and does not write queue/done.
351    #[arg(long, conflicts_with = "parallel_worker")]
352    pub dry_run: bool,
353
354    /// Internal: run as a parallel worker (skips queue lock, allows upstream creation).
355    #[arg(
356        long,
357        hide = true,
358        requires_all = [
359            "id",
360            "coordinator_queue_path",
361            "coordinator_done_path",
362            "parallel_target_branch"
363        ]
364    )]
365    pub parallel_worker: bool,
366
367    /// Internal: queue file path for the parallel worker workspace checkout.
368    #[arg(
369        long,
370        hide = true,
371        value_name = "PATH",
372        requires_all = ["parallel_worker", "coordinator_done_path"]
373    )]
374    pub coordinator_queue_path: Option<PathBuf>,
375
376    /// Internal: done file path for the parallel worker workspace checkout.
377    #[arg(
378        long,
379        hide = true,
380        value_name = "PATH",
381        requires_all = ["parallel_worker", "coordinator_queue_path"]
382    )]
383    pub coordinator_done_path: Option<PathBuf>,
384
385    /// Internal: explicit coordinator target branch for parallel integration.
386    #[arg(
387        long,
388        hide = true,
389        value_name = "BRANCH",
390        requires_all = ["parallel_worker", "coordinator_queue_path", "coordinator_done_path"]
391    )]
392    pub parallel_target_branch: Option<String>,
393
394    #[command(flatten)]
395    pub agent: crate::agent::RunAgentArgs,
396}
397
398#[derive(Args)]
399pub struct RunLoopArgs {
400    /// Maximum tasks to run before stopping (0 = no limit).
401    #[arg(long, default_value_t = 0)]
402    pub max_tasks: u32,
403
404    /// Capture raw supervisor + runner output to .ralph/logs/debug.log.
405    #[arg(long)]
406    pub debug: bool,
407
408    /// Automatically resume an interrupted session without prompting.
409    #[arg(long)]
410    pub resume: bool,
411
412    /// Skip interactive prompts (for CI/non-interactive environments).
413    #[arg(long)]
414    pub non_interactive: bool,
415
416    /// Select a task and print why it would (or would not) run.
417    /// Does not invoke any runner and does not write queue/done.
418    #[arg(long, conflicts_with = "parallel")]
419    pub dry_run: bool,
420
421    /// Run tasks in parallel using N workers (default when flag present: 2).
422    #[arg(
423        long,
424        value_parser = clap::value_parser!(u8).range(2..),
425        num_args = 0..=1,
426        default_missing_value = "2",
427        value_name = "N",
428    )]
429    pub parallel: Option<u8>,
430
431    /// Wait when blocked by dependencies/schedule instead of exiting.
432    /// The loop will poll until a runnable task appears or timeout is reached.
433    #[arg(long, conflicts_with = "parallel")]
434    pub wait_when_blocked: bool,
435
436    /// Poll interval in milliseconds while waiting for unblocked tasks (default: 1000, min: 50).
437    #[arg(
438        long,
439        default_value_t = 1000,
440        value_parser = clap::value_parser!(u64).range(50..),
441        value_name = "MS"
442    )]
443    pub wait_poll_ms: u64,
444
445    /// Timeout in seconds for waiting (0 = no timeout).
446    #[arg(long, default_value_t = 0, value_name = "SECONDS")]
447    pub wait_timeout_seconds: u64,
448
449    /// Notify when queue becomes unblocked (desktop + webhook).
450    #[arg(long)]
451    pub notify_when_unblocked: bool,
452
453    /// Wait when queue is empty instead of exiting (continuous mode).
454    /// Alias: --continuous
455    #[arg(long, alias = "continuous", conflicts_with = "parallel")]
456    pub wait_when_empty: bool,
457
458    /// Poll interval in milliseconds while waiting for new tasks when queue is empty
459    /// (default: 30000, min: 50). Only used with --wait-when-empty.
460    #[arg(
461        long,
462        default_value_t = 30_000,
463        value_parser = clap::value_parser!(u64).range(50..),
464        value_name = "MS"
465    )]
466    pub empty_poll_ms: u64,
467
468    #[command(flatten)]
469    pub agent: crate::agent::RunAgentArgs,
470}
471
472/// Arguments for `ralph run parallel` subcommand.
473#[derive(Args)]
474pub struct ParallelArgs {
475    #[command(subcommand)]
476    pub command: ParallelSubcommand,
477}
478
479/// Subcommands for `ralph run parallel`.
480#[derive(Subcommand)]
481pub enum ParallelSubcommand {
482    /// Show status of parallel workers (active, completed, failed, blocked).
483    #[command(
484        about = "Show status of parallel workers",
485        after_long_help = "Examples:\n\
486 ralph run parallel status\n\
487 ralph run parallel status --json"
488    )]
489    Status(ParallelStatusArgs),
490    /// Retry a blocked or failed parallel worker.
491    #[command(
492        about = "Retry a blocked or failed parallel worker",
493        after_long_help = "Examples:\n\
494 ralph run parallel retry --task RQ-0001"
495    )]
496    Retry(ParallelRetryArgs),
497}
498
499/// Arguments for `ralph run parallel status`.
500#[derive(Args)]
501pub struct ParallelStatusArgs {
502    /// Output as JSON.
503    #[arg(long)]
504    pub json: bool,
505}
506
507/// Arguments for `ralph run parallel retry`.
508#[derive(Args)]
509pub struct ParallelRetryArgs {
510    /// Task ID of the blocked/failed worker to retry.
511    #[arg(long, value_name = "TASK_ID", required = true)]
512    pub task: String,
513}
514
515#[cfg(test)]
516mod tests {
517    use std::path::PathBuf;
518
519    use clap::{CommandFactory, Parser};
520
521    use crate::cli::{Cli, run::RunCommand};
522
523    #[test]
524    fn run_one_help_includes_phase_semantics() {
525        let mut cmd = Cli::command();
526        let run = cmd.find_subcommand_mut("run").expect("run subcommand");
527        let run_one = run.find_subcommand_mut("one").expect("run one subcommand");
528        let help = run_one.render_long_help().to_string();
529
530        assert!(
531            help.contains("ralph run one --phases 3 (plan/implement+CI/review+complete)"),
532            "missing phases=3 example: {help}"
533        );
534        assert!(
535            help.contains("ralph run one --phases 2 (plan/implement)"),
536            "missing phases=2 example: {help}"
537        );
538        assert!(
539            help.contains("ralph run one --phases 1 (single-pass)"),
540            "missing phases=1 example: {help}"
541        );
542        assert!(
543            help.contains("ralph run one --quick (single-pass, same as --phases 1)"),
544            "missing --quick example: {help}"
545        );
546    }
547
548    #[test]
549    fn run_loop_help_mentions_repo_prompt_examples() {
550        let mut cmd = Cli::command();
551        let run = cmd.find_subcommand_mut("run").expect("run subcommand");
552        let run_loop = run
553            .find_subcommand_mut("loop")
554            .expect("run loop subcommand");
555        let help = run_loop.render_long_help().to_string();
556
557        assert!(
558            help.contains("ralph run loop --repo-prompt tools --max-tasks 1"),
559            "missing repo-prompt tools example: {help}"
560        );
561        assert!(
562            help.contains("ralph run loop --repo-prompt off --max-tasks 1"),
563            "missing repo-prompt off example: {help}"
564        );
565    }
566
567    #[test]
568    fn run_one_non_interactive_parses() {
569        let args = vec!["ralph", "run", "one", "--non-interactive"];
570        let cli = Cli::parse_from(args);
571        match cli.command {
572            crate::cli::Command::Run(run_args) => match run_args.command {
573                RunCommand::One(one_args) => {
574                    assert!(one_args.non_interactive);
575                }
576                _ => panic!("expected RunCommand::One"),
577            },
578            _ => panic!("expected Command::Run"),
579        }
580    }
581
582    #[test]
583    fn run_one_non_interactive_with_id_parses() {
584        let args = vec![
585            "ralph",
586            "run",
587            "one",
588            "--non-interactive",
589            "--id",
590            "RQ-0001",
591        ];
592        let cli = Cli::parse_from(args);
593        match cli.command {
594            crate::cli::Command::Run(run_args) => match run_args.command {
595                RunCommand::One(one_args) => {
596                    assert!(one_args.non_interactive);
597                    assert_eq!(one_args.id, Some("RQ-0001".to_string()));
598                }
599                _ => panic!("expected RunCommand::One"),
600            },
601            _ => panic!("expected Command::Run"),
602        }
603    }
604
605    #[test]
606    fn run_one_dry_run_parses() {
607        let args = vec!["ralph", "run", "one", "--dry-run"];
608        let cli = Cli::parse_from(args);
609        match cli.command {
610            crate::cli::Command::Run(run_args) => match run_args.command {
611                RunCommand::One(one_args) => {
612                    assert!(one_args.dry_run);
613                }
614                _ => panic!("expected RunCommand::One"),
615            },
616            _ => panic!("expected Command::Run"),
617        }
618    }
619
620    #[test]
621    fn run_one_dry_run_with_id_parses() {
622        let args = vec!["ralph", "run", "one", "--dry-run", "--id", "RQ-0001"];
623        let cli = Cli::parse_from(args);
624        match cli.command {
625            crate::cli::Command::Run(run_args) => match run_args.command {
626                RunCommand::One(one_args) => {
627                    assert!(one_args.dry_run);
628                    assert_eq!(one_args.id, Some("RQ-0001".to_string()));
629                }
630                _ => panic!("expected RunCommand::One"),
631            },
632            _ => panic!("expected Command::Run"),
633        }
634    }
635
636    #[test]
637    fn run_loop_dry_run_parses() {
638        let args = vec!["ralph", "run", "loop", "--dry-run"];
639        let cli = Cli::parse_from(args);
640        match cli.command {
641            crate::cli::Command::Run(run_args) => match run_args.command {
642                RunCommand::Loop(loop_args) => {
643                    assert!(loop_args.dry_run);
644                }
645                _ => panic!("expected RunCommand::Loop"),
646            },
647            _ => panic!("expected Command::Run"),
648        }
649    }
650
651    #[test]
652    fn run_loop_dry_run_conflicts_with_parallel() {
653        let args = vec!["ralph", "run", "loop", "--dry-run", "--parallel"];
654        let result = Cli::try_parse_from(args);
655        assert!(result.is_err(), "--dry-run and --parallel should conflict");
656    }
657
658    #[test]
659    fn run_one_help_includes_dry_run_examples() {
660        let mut cmd = Cli::command();
661        let run = cmd.find_subcommand_mut("run").expect("run subcommand");
662        let run_one = run.find_subcommand_mut("one").expect("run one subcommand");
663        let help = run_one.render_long_help().to_string();
664
665        assert!(
666            help.contains("ralph run one --dry-run"),
667            "missing dry-run example: {help}"
668        );
669        assert!(
670            help.contains("ralph run one --dry-run --include-draft"),
671            "missing dry-run --include-draft example: {help}"
672        );
673        assert!(
674            help.contains("ralph run one --dry-run --id RQ-0001"),
675            "missing dry-run --id example: {help}"
676        );
677    }
678
679    #[test]
680    fn run_loop_help_includes_dry_run_examples() {
681        let mut cmd = Cli::command();
682        let run = cmd.find_subcommand_mut("run").expect("run subcommand");
683        let run_loop = run
684            .find_subcommand_mut("loop")
685            .expect("run loop subcommand");
686        let help = run_loop.render_long_help().to_string();
687
688        assert!(
689            help.contains("ralph run loop --dry-run"),
690            "missing dry-run example: {help}"
691        );
692    }
693
694    #[test]
695    fn run_loop_wait_poll_ms_rejects_below_minimum() {
696        let args = vec!["ralph", "run", "loop", "--wait-poll-ms", "10"];
697        let result = Cli::try_parse_from(args);
698        assert!(
699            result.is_err(),
700            "--wait-poll-ms should reject values below 50"
701        );
702    }
703
704    #[test]
705    fn run_loop_empty_poll_ms_rejects_below_minimum() {
706        let args = vec!["ralph", "run", "loop", "--empty-poll-ms", "10"];
707        let result = Cli::try_parse_from(args);
708        assert!(
709            result.is_err(),
710            "--empty-poll-ms should reject values below 50"
711        );
712    }
713
714    #[test]
715    fn run_loop_wait_poll_ms_accepts_minimum() {
716        let args = vec!["ralph", "run", "loop", "--wait-poll-ms", "50"];
717        let cli = Cli::try_parse_from(args);
718        assert!(cli.is_ok(), "--wait-poll-ms should accept 50");
719        if let Ok(cli) = cli {
720            match cli.command {
721                crate::cli::Command::Run(run_args) => match run_args.command {
722                    RunCommand::Loop(loop_args) => {
723                        assert_eq!(loop_args.wait_poll_ms, 50);
724                    }
725                    _ => panic!("expected RunCommand::Loop"),
726                },
727                _ => panic!("expected Command::Run"),
728            }
729        }
730    }
731
732    #[test]
733    fn run_one_parallel_worker_with_coordinator_paths_parses() {
734        let args = vec![
735            "ralph",
736            "run",
737            "one",
738            "--parallel-worker",
739            "--id",
740            "RQ-0001",
741            "--coordinator-queue-path",
742            "/path/to/queue.json",
743            "--coordinator-done-path",
744            "/path/to/done.json",
745            "--parallel-target-branch",
746            "main",
747        ];
748        let cli = Cli::parse_from(args);
749        match cli.command {
750            crate::cli::Command::Run(run_args) => match run_args.command {
751                RunCommand::One(one_args) => {
752                    assert!(one_args.parallel_worker);
753                    assert_eq!(one_args.id, Some("RQ-0001".to_string()));
754                    assert_eq!(
755                        one_args.coordinator_queue_path,
756                        Some(PathBuf::from("/path/to/queue.json"))
757                    );
758                    assert_eq!(
759                        one_args.coordinator_done_path,
760                        Some(PathBuf::from("/path/to/done.json"))
761                    );
762                    assert_eq!(one_args.parallel_target_branch, Some("main".to_string()));
763                }
764                _ => panic!("expected RunCommand::One"),
765            },
766            _ => panic!("expected Command::Run"),
767        }
768    }
769
770    #[test]
771    fn run_one_parallel_worker_requires_coordinator_paths() {
772        let args = vec![
773            "ralph",
774            "run",
775            "one",
776            "--parallel-worker",
777            "--id",
778            "RQ-0001",
779        ];
780        let result = Cli::try_parse_from(args);
781        assert!(
782            result.is_err(),
783            "--parallel-worker should require coordinator queue/done paths and target branch"
784        );
785    }
786
787    #[test]
788    fn run_one_parallel_worker_requires_both_coordinator_paths() {
789        let args = vec![
790            "ralph",
791            "run",
792            "one",
793            "--parallel-worker",
794            "--id",
795            "RQ-0001",
796            "--coordinator-queue-path",
797            "/path/to/queue.json",
798        ];
799        let result = Cli::try_parse_from(args);
800        assert!(
801            result.is_err(),
802            "missing --coordinator-done-path should fail parsing"
803        );
804    }
805
806    #[test]
807    fn run_one_parallel_worker_requires_target_branch() {
808        let args = vec![
809            "ralph",
810            "run",
811            "one",
812            "--parallel-worker",
813            "--id",
814            "RQ-0001",
815            "--coordinator-queue-path",
816            "/path/to/queue.json",
817            "--coordinator-done-path",
818            "/path/to/done.json",
819        ];
820        let result = Cli::try_parse_from(args);
821        assert!(
822            result.is_err(),
823            "missing --parallel-target-branch should fail parsing"
824        );
825    }
826}