use clap::{Parser, Subcommand};
pub(crate) const DEFAULT_MAX_RETRIES: usize = 10;
pub(crate) const DEFAULT_RATE_LIMIT_RETRIES: usize = 5;
pub(crate) const PLAN_STDIN_SENTINEL: &str = "stdin";
#[derive(Parser, Debug)]
#[command(
name = "cruise",
version,
about = "YAML-driven coding agent workflow orchestrator",
args_conflicts_with_subcommands = true
)]
pub struct Cli {
#[arg(long, value_name = "INPUT", conflicts_with = "input")]
pub plan: Option<String>,
#[command(subcommand)]
pub command: Option<Commands>,
#[arg(conflicts_with = "plan")]
pub input: Option<String>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Plan(PlanArgs),
#[command(hide = true)]
PlanWorker(PlanWorkerArgs),
Run(RunArgs),
List(ListArgs),
Clean(CleanArgs),
Config(ConfigArgs),
}
#[derive(Parser, Debug)]
pub struct PlanArgs {
pub input: Option<String>,
#[arg(short = 'c', long)]
pub config: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(long, default_value_t = DEFAULT_RATE_LIMIT_RETRIES)]
pub rate_limit_retries: usize,
}
#[derive(Parser, Debug)]
pub struct PlanWorkerArgs {
#[arg(long)]
pub session: String,
#[arg(long, default_value_t = DEFAULT_RATE_LIMIT_RETRIES)]
pub rate_limit_retries: usize,
}
#[derive(Parser, Debug)]
pub struct RunArgs {
#[arg(conflicts_with = "all")]
pub session: Option<String>,
#[arg(long)]
pub all: bool,
#[arg(long, default_value_t = DEFAULT_MAX_RETRIES)]
pub max_retries: usize,
#[arg(long, default_value_t = DEFAULT_RATE_LIMIT_RETRIES)]
pub rate_limit_retries: usize,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Parser, Debug)]
pub struct CleanArgs {}
#[derive(Parser, Debug)]
pub struct ListArgs {
#[arg(long)]
pub json: bool,
}
#[derive(Parser, Debug)]
pub struct ConfigArgs {
#[arg(long, value_name = "N")]
pub set_parallelism: Option<usize>,
}
pub fn parse_cli() -> Cli {
let mut cli = Cli::parse();
if cli.command.is_none()
&& cli.plan.is_none()
&& cli.input.is_none()
&& !std::io::IsTerminal::is_terminal(&std::io::stdin())
{
use std::io::Read;
let mut input = String::new();
std::io::stdin().read_to_string(&mut input).ok();
let trimmed = input.trim().to_string();
if !trimmed.is_empty() {
cli.input = Some(trimmed);
}
}
cli
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn test_cli_verify() {
Cli::command().debug_assert();
}
#[test]
fn test_plan_subcommand_with_input() {
let cli = Cli::parse_from(["cruise", "plan", "add feature X"]);
match cli.command {
Some(Commands::Plan(args)) => {
assert_eq!(args.input, Some("add feature X".to_string()));
assert!(!args.dry_run);
assert_eq!(args.rate_limit_retries, DEFAULT_RATE_LIMIT_RETRIES);
}
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_plan_subcommand_with_config() {
let cli = Cli::parse_from(["cruise", "plan", "-c", "my.yaml", "task"]);
match cli.command {
Some(Commands::Plan(args)) => {
assert_eq!(args.config, Some("my.yaml".to_string()));
assert_eq!(args.input, Some("task".to_string()));
}
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_plan_subcommand_dry_run() {
let cli = Cli::parse_from(["cruise", "plan", "--dry-run", "task"]);
match cli.command {
Some(Commands::Plan(args)) => {
assert!(args.dry_run);
}
_ => panic!("expected Plan subcommand"),
}
}
#[test]
fn test_run_subcommand_defaults() {
let cli = Cli::parse_from(["cruise", "run"]);
match cli.command {
Some(Commands::Run(args)) => {
assert_eq!(args.session, None);
assert_eq!(args.max_retries, DEFAULT_MAX_RETRIES);
assert_eq!(args.rate_limit_retries, DEFAULT_RATE_LIMIT_RETRIES);
assert!(!args.dry_run);
}
_ => panic!("expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_with_session() {
let cli = Cli::parse_from(["cruise", "run", "20260306143000"]);
match cli.command {
Some(Commands::Run(args)) => {
assert_eq!(args.session, Some("20260306143000".to_string()));
}
_ => panic!("expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_flags() {
let cli = Cli::parse_from([
"cruise",
"run",
"--max-retries",
"20",
"--rate-limit-retries",
"3",
]);
match cli.command {
Some(Commands::Run(args)) => {
assert_eq!(args.max_retries, 20);
assert_eq!(args.rate_limit_retries, 3);
}
_ => panic!("expected Run subcommand"),
}
}
#[test]
fn test_root_plan_flag_with_inline_input_parses() {
let cli = Cli::try_parse_from(["cruise", "--plan", "add feature X"])
.unwrap_or_else(|e| panic!("expected --plan to parse successfully: {e}"));
assert!(cli.command.is_none(), "expected no subcommand: {cli:?}");
assert_eq!(cli.plan, Some("add feature X".to_string()));
assert_eq!(cli.input, None, "legacy positional input should stay empty");
}
#[test]
fn test_root_plan_flag_with_stdin_literal_parses() {
let cli = Cli::try_parse_from(["cruise", "--plan", "stdin"])
.unwrap_or_else(|e| panic!("expected --plan stdin to parse successfully: {e}"));
assert!(cli.command.is_none(), "expected no subcommand: {cli:?}");
assert_eq!(cli.plan, Some(PLAN_STDIN_SENTINEL.to_string()));
assert_eq!(cli.input, None, "legacy positional input should stay empty");
}
#[test]
fn test_list_subcommand() {
let cli = Cli::parse_from(["cruise", "list"]);
assert!(matches!(cli.command, Some(Commands::List(_))));
}
#[test]
fn test_list_subcommand_json_flag_defaults_to_false() {
let cli = Cli::parse_from(["cruise", "list"]);
match cli.command {
Some(Commands::List(args)) => {
assert!(!args.json, "--json should default to false");
}
_ => panic!("expected List subcommand"),
}
}
#[test]
fn test_list_subcommand_json_flag_is_true_with_flag() {
let cli = Cli::parse_from(["cruise", "list", "--json"]);
match cli.command {
Some(Commands::List(args)) => {
assert!(args.json, "--json should be true");
}
_ => panic!("expected List subcommand"),
}
}
#[test]
fn test_clean_subcommand_default() {
let cli = Cli::parse_from(["cruise", "clean"]);
assert!(matches!(cli.command, Some(Commands::Clean(_))));
}
#[test]
fn test_backward_compat_no_subcommand() {
let cli = Cli::parse_from(["cruise", "add hello world"]);
assert!(cli.command.is_none());
assert_eq!(cli.input, Some("add hello world".to_string()));
}
#[test]
fn test_no_args() {
let cli = Cli::parse_from(["cruise"]);
assert!(cli.command.is_none());
assert_eq!(cli.input, None);
}
#[test]
fn test_run_subcommand_all_flag() {
let cli = Cli::parse_from(["cruise", "run", "--all"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(args.all, "--all should be true");
assert_eq!(args.session, None);
assert!(!args.dry_run);
}
_ => panic!("expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_all_flag_default_is_false() {
let cli = Cli::parse_from(["cruise", "run"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(!args.all, "--all should default to false");
}
_ => panic!("expected Run subcommand"),
}
}
#[test]
fn test_run_subcommand_all_with_dry_run() {
let cli = Cli::parse_from(["cruise", "run", "--all", "--dry-run"]);
match cli.command {
Some(Commands::Run(args)) => {
assert!(args.all);
assert!(args.dry_run);
assert_eq!(args.session, None);
}
_ => panic!("expected Run subcommand"),
}
}
#[test]
fn test_config_subcommand_no_flags_shows_current_config() {
let cli = Cli::parse_from(["cruise", "config"]);
match cli.command {
Some(Commands::Config(args)) => {
assert_eq!(
args.set_parallelism, None,
"no flags means show-only mode (set_parallelism is None)"
);
}
_ => panic!("expected Config subcommand"),
}
}
#[test]
fn test_config_subcommand_set_parallelism_parses_value() {
let cli = Cli::parse_from(["cruise", "config", "--set-parallelism", "4"]);
match cli.command {
Some(Commands::Config(args)) => {
assert_eq!(
args.set_parallelism,
Some(4),
"expected set_parallelism = Some(4)"
);
}
_ => panic!("expected Config subcommand"),
}
}
#[test]
fn test_config_subcommand_set_parallelism_one() {
let cli = Cli::parse_from(["cruise", "config", "--set-parallelism", "1"]);
match cli.command {
Some(Commands::Config(args)) => {
assert_eq!(args.set_parallelism, Some(1));
}
_ => panic!("expected Config subcommand"),
}
}
#[test]
fn test_config_subcommand_is_registered_in_cli_verify() {
Cli::command().debug_assert();
}
}