use std::fmt;
use std::path::PathBuf;
use std::time::Duration;
use serde::{Deserialize, Serialize};
const DEFAULT_TIMEOUT_SECS: u64 = 120;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CliRunnerType {
ClaudeCode,
CursorAgent,
OpenCode,
Copilot,
GeminiCli,
CodexCli,
GooseCli,
ClineCli,
ContinueCli,
WarpCli,
KiroCli,
KiloCli,
#[cfg(feature = "copilot-headless")]
CopilotHeadless,
}
impl CliRunnerType {
#[must_use]
pub const fn binary_name(&self) -> &'static str {
match self {
Self::ClaudeCode => "claude",
Self::CursorAgent => "cursor-agent",
Self::OpenCode => "opencode",
Self::Copilot => "copilot",
Self::GeminiCli => "gemini",
Self::CodexCli => "codex",
Self::GooseCli => "goose",
Self::ClineCli => "cline",
Self::ContinueCli => "cn",
Self::WarpCli => "oz",
Self::KiroCli => "kiro-cli",
Self::KiloCli => "kilo",
#[cfg(feature = "copilot-headless")]
Self::CopilotHeadless => "copilot",
}
}
#[must_use]
pub const fn env_override_key(&self) -> &'static str {
match self {
Self::ClaudeCode => "CLAUDE_CODE_BINARY",
Self::CursorAgent => "CURSOR_AGENT_BINARY",
Self::OpenCode => "OPENCODE_BINARY",
Self::Copilot => "COPILOT_BINARY",
Self::GeminiCli => "GEMINI_CLI_BINARY",
Self::CodexCli => "CODEX_CLI_BINARY",
Self::GooseCli => "GOOSE_CLI_BINARY",
Self::ClineCli => "CLINE_CLI_BINARY",
Self::ContinueCli => "CONTINUE_CLI_BINARY",
Self::WarpCli => "WARP_CLI_BINARY",
Self::KiroCli => "KIRO_CLI_BINARY",
Self::KiloCli => "KILO_CLI_BINARY",
#[cfg(feature = "copilot-headless")]
Self::CopilotHeadless => "COPILOT_CLI_PATH",
}
}
}
impl fmt::Display for CliRunnerType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ClaudeCode => write!(f, "claude_code"),
Self::CursorAgent => write!(f, "cursor_agent"),
Self::OpenCode => write!(f, "opencode"),
Self::Copilot => write!(f, "copilot"),
Self::GeminiCli => write!(f, "gemini_cli"),
Self::CodexCli => write!(f, "codex_cli"),
Self::GooseCli => write!(f, "goose_cli"),
Self::ClineCli => write!(f, "cline_cli"),
Self::ContinueCli => write!(f, "continue_cli"),
Self::WarpCli => write!(f, "warp_cli"),
Self::KiroCli => write!(f, "kiro_cli"),
Self::KiloCli => write!(f, "kilo_cli"),
#[cfg(feature = "copilot-headless")]
Self::CopilotHeadless => write!(f, "copilot_headless"),
}
}
}
#[derive(Debug, Clone)]
pub struct RunnerConfig {
pub binary_path: PathBuf,
pub model: Option<String>,
pub timeout: Duration,
pub extra_args: Vec<String>,
pub allowed_env_keys: Vec<String>,
pub working_directory: Option<PathBuf>,
}
impl RunnerConfig {
#[must_use]
pub fn new(binary_path: PathBuf) -> Self {
Self {
binary_path,
model: None,
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
extra_args: Vec::new(),
allowed_env_keys: default_allowed_env_keys(),
working_directory: None,
}
}
#[must_use]
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
#[must_use]
pub const fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
self.extra_args = args;
self
}
#[must_use]
pub fn with_allowed_env_keys(mut self, keys: Vec<String>) -> Self {
self.allowed_env_keys = keys;
self
}
#[must_use]
pub fn with_working_directory(mut self, dir: PathBuf) -> Self {
self.working_directory = Some(dir);
self
}
}
#[must_use]
pub fn default_allowed_env_keys() -> Vec<String> {
["HOME", "PATH", "TERM", "USER", "LANG"]
.iter()
.map(|k| (*k).to_owned())
.collect()
}
#[must_use]
pub fn parse_env_keys(input: &str) -> Vec<String> {
input
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.collect()
}
use std::num::ParseIntError;
pub fn parse_timeout(input: &str) -> Result<Duration, ParseIntError> {
input.trim().parse::<u64>().map(Duration::from_secs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_runner_config_defaults() {
let config = RunnerConfig::new(PathBuf::from("/usr/bin/claude"));
assert_eq!(config.binary_path, PathBuf::from("/usr/bin/claude"));
assert!(config.model.is_none());
assert_eq!(config.timeout, Duration::from_secs(120));
assert!(config.extra_args.is_empty());
assert!(config.working_directory.is_none());
}
#[test]
fn test_runner_config_builder() {
let config = RunnerConfig::new(PathBuf::from("claude"))
.with_model("opus")
.with_timeout(Duration::from_secs(60))
.with_extra_args(vec!["--verbose".to_owned()])
.with_working_directory(PathBuf::from("/tmp"));
assert_eq!(config.model.as_deref(), Some("opus"));
assert_eq!(config.timeout, Duration::from_secs(60));
assert_eq!(config.extra_args, vec!["--verbose"]);
assert_eq!(config.working_directory, Some(PathBuf::from("/tmp")));
}
#[test]
fn test_default_allowed_env_keys() {
let keys = default_allowed_env_keys();
assert!(keys.contains(&"HOME".to_owned()));
assert!(keys.contains(&"PATH".to_owned()));
assert!(keys.contains(&"TERM".to_owned()));
assert!(keys.contains(&"USER".to_owned()));
assert!(keys.contains(&"LANG".to_owned()));
assert_eq!(keys.len(), 5);
}
#[test]
fn test_parse_env_keys_basic() {
let keys = parse_env_keys("FOO,BAR,BAZ");
assert_eq!(keys, vec!["FOO", "BAR", "BAZ"]);
}
#[test]
fn test_parse_env_keys_with_whitespace() {
let keys = parse_env_keys(" FOO , BAR , BAZ ");
assert_eq!(keys, vec!["FOO", "BAR", "BAZ"]);
}
#[test]
fn test_parse_env_keys_empty_string() {
let keys = parse_env_keys("");
assert!(keys.is_empty());
}
#[test]
fn test_parse_env_keys_trailing_comma() {
let keys = parse_env_keys("FOO,BAR,");
assert_eq!(keys, vec!["FOO", "BAR"]);
}
#[test]
fn test_parse_timeout_valid() {
assert_eq!(parse_timeout("60"), Ok(Duration::from_secs(60)));
assert_eq!(parse_timeout(" 120 "), Ok(Duration::from_secs(120)));
}
#[test]
fn test_parse_timeout_invalid() {
assert!(parse_timeout("abc").is_err());
assert!(parse_timeout("").is_err());
}
#[test]
fn test_cli_runner_type_binary_names() {
assert_eq!(CliRunnerType::ClaudeCode.binary_name(), "claude");
assert_eq!(CliRunnerType::CursorAgent.binary_name(), "cursor-agent");
assert_eq!(CliRunnerType::OpenCode.binary_name(), "opencode");
assert_eq!(CliRunnerType::Copilot.binary_name(), "copilot");
assert_eq!(CliRunnerType::GeminiCli.binary_name(), "gemini");
assert_eq!(CliRunnerType::CodexCli.binary_name(), "codex");
assert_eq!(CliRunnerType::GooseCli.binary_name(), "goose");
assert_eq!(CliRunnerType::ClineCli.binary_name(), "cline");
assert_eq!(CliRunnerType::ContinueCli.binary_name(), "cn");
assert_eq!(CliRunnerType::WarpCli.binary_name(), "oz");
assert_eq!(CliRunnerType::KiroCli.binary_name(), "kiro-cli");
assert_eq!(CliRunnerType::KiloCli.binary_name(), "kilo");
}
#[test]
fn test_cli_runner_type_env_keys() {
assert_eq!(
CliRunnerType::ClaudeCode.env_override_key(),
"CLAUDE_CODE_BINARY"
);
assert_eq!(CliRunnerType::Copilot.env_override_key(), "COPILOT_BINARY");
assert_eq!(
CliRunnerType::GeminiCli.env_override_key(),
"GEMINI_CLI_BINARY"
);
assert_eq!(
CliRunnerType::CodexCli.env_override_key(),
"CODEX_CLI_BINARY"
);
assert_eq!(
CliRunnerType::GooseCli.env_override_key(),
"GOOSE_CLI_BINARY"
);
assert_eq!(
CliRunnerType::ClineCli.env_override_key(),
"CLINE_CLI_BINARY"
);
assert_eq!(
CliRunnerType::ContinueCli.env_override_key(),
"CONTINUE_CLI_BINARY"
);
assert_eq!(CliRunnerType::WarpCli.env_override_key(), "WARP_CLI_BINARY");
assert_eq!(CliRunnerType::KiroCli.env_override_key(), "KIRO_CLI_BINARY");
assert_eq!(CliRunnerType::KiloCli.env_override_key(), "KILO_CLI_BINARY");
}
#[test]
fn test_cli_runner_type_display() {
assert_eq!(format!("{}", CliRunnerType::ClaudeCode), "claude_code");
assert_eq!(format!("{}", CliRunnerType::Copilot), "copilot");
assert_eq!(format!("{}", CliRunnerType::CursorAgent), "cursor_agent");
assert_eq!(format!("{}", CliRunnerType::OpenCode), "opencode");
assert_eq!(format!("{}", CliRunnerType::GeminiCli), "gemini_cli");
assert_eq!(format!("{}", CliRunnerType::CodexCli), "codex_cli");
assert_eq!(format!("{}", CliRunnerType::GooseCli), "goose_cli");
assert_eq!(format!("{}", CliRunnerType::ClineCli), "cline_cli");
assert_eq!(format!("{}", CliRunnerType::ContinueCli), "continue_cli");
assert_eq!(format!("{}", CliRunnerType::WarpCli), "warp_cli");
assert_eq!(format!("{}", CliRunnerType::KiroCli), "kiro_cli");
assert_eq!(format!("{}", CliRunnerType::KiloCli), "kilo_cli");
}
}