1use std::fmt;
8use std::path::PathBuf;
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12
13const DEFAULT_TIMEOUT_SECS: u64 = 120;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub enum CliRunnerType {
19 ClaudeCode,
21 CursorAgent,
23 OpenCode,
25 Copilot,
27 GeminiCli,
29 CodexCli,
31 GooseCli,
33 ClineCli,
35 ContinueCli,
37}
38
39impl CliRunnerType {
40 #[must_use]
42 pub const fn binary_name(&self) -> &'static str {
43 match self {
44 Self::ClaudeCode => "claude",
45 Self::CursorAgent => "cursor-agent",
46 Self::OpenCode => "opencode",
47 Self::Copilot => "copilot",
48 Self::GeminiCli => "gemini",
49 Self::CodexCli => "codex",
50 Self::GooseCli => "goose",
51 Self::ClineCli => "cline",
52 Self::ContinueCli => "cn",
53 }
54 }
55
56 #[must_use]
58 pub const fn env_override_key(&self) -> &'static str {
59 match self {
60 Self::ClaudeCode => "CLAUDE_CODE_BINARY",
61 Self::CursorAgent => "CURSOR_AGENT_BINARY",
62 Self::OpenCode => "OPENCODE_BINARY",
63 Self::Copilot => "COPILOT_BINARY",
64 Self::GeminiCli => "GEMINI_CLI_BINARY",
65 Self::CodexCli => "CODEX_CLI_BINARY",
66 Self::GooseCli => "GOOSE_CLI_BINARY",
67 Self::ClineCli => "CLINE_CLI_BINARY",
68 Self::ContinueCli => "CONTINUE_CLI_BINARY",
69 }
70 }
71}
72
73impl fmt::Display for CliRunnerType {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 match self {
76 Self::ClaudeCode => write!(f, "claude_code"),
77 Self::CursorAgent => write!(f, "cursor_agent"),
78 Self::OpenCode => write!(f, "opencode"),
79 Self::Copilot => write!(f, "copilot"),
80 Self::GeminiCli => write!(f, "gemini_cli"),
81 Self::CodexCli => write!(f, "codex_cli"),
82 Self::GooseCli => write!(f, "goose_cli"),
83 Self::ClineCli => write!(f, "cline_cli"),
84 Self::ContinueCli => write!(f, "continue_cli"),
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
91pub struct RunnerConfig {
92 pub binary_path: PathBuf,
94 pub model: Option<String>,
96 pub timeout: Duration,
98 pub extra_args: Vec<String>,
100 pub allowed_env_keys: Vec<String>,
102 pub working_directory: Option<PathBuf>,
104}
105
106impl RunnerConfig {
107 #[must_use]
109 pub fn new(binary_path: PathBuf) -> Self {
110 Self {
111 binary_path,
112 model: None,
113 timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
114 extra_args: Vec::new(),
115 allowed_env_keys: default_allowed_env_keys(),
116 working_directory: None,
117 }
118 }
119
120 #[must_use]
122 pub fn with_model(mut self, model: impl Into<String>) -> Self {
123 self.model = Some(model.into());
124 self
125 }
126
127 #[must_use]
129 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
130 self.timeout = timeout;
131 self
132 }
133
134 #[must_use]
136 pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
137 self.extra_args = args;
138 self
139 }
140
141 #[must_use]
143 pub fn with_allowed_env_keys(mut self, keys: Vec<String>) -> Self {
144 self.allowed_env_keys = keys;
145 self
146 }
147
148 #[must_use]
150 pub fn with_working_directory(mut self, dir: PathBuf) -> Self {
151 self.working_directory = Some(dir);
152 self
153 }
154}
155
156#[must_use]
158pub fn default_allowed_env_keys() -> Vec<String> {
159 ["HOME", "PATH", "TERM", "USER", "LANG"]
160 .iter()
161 .map(|k| (*k).to_owned())
162 .collect()
163}
164
165#[must_use]
167pub fn parse_env_keys(input: &str) -> Vec<String> {
168 input
169 .split(',')
170 .map(str::trim)
171 .filter(|s| !s.is_empty())
172 .map(ToOwned::to_owned)
173 .collect()
174}
175
176use std::num::ParseIntError;
177
178pub fn parse_timeout(input: &str) -> Result<Duration, ParseIntError> {
184 input.trim().parse::<u64>().map(Duration::from_secs)
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn test_runner_config_defaults() {
193 let config = RunnerConfig::new(PathBuf::from("/usr/bin/claude"));
194 assert_eq!(config.binary_path, PathBuf::from("/usr/bin/claude"));
195 assert!(config.model.is_none());
196 assert_eq!(config.timeout, Duration::from_secs(120));
197 assert!(config.extra_args.is_empty());
198 assert!(config.working_directory.is_none());
199 }
200
201 #[test]
202 fn test_runner_config_builder() {
203 let config = RunnerConfig::new(PathBuf::from("claude"))
204 .with_model("opus")
205 .with_timeout(Duration::from_secs(60))
206 .with_extra_args(vec!["--verbose".to_owned()])
207 .with_working_directory(PathBuf::from("/tmp"));
208
209 assert_eq!(config.model.as_deref(), Some("opus"));
210 assert_eq!(config.timeout, Duration::from_secs(60));
211 assert_eq!(config.extra_args, vec!["--verbose"]);
212 assert_eq!(config.working_directory, Some(PathBuf::from("/tmp")));
213 }
214
215 #[test]
216 fn test_default_allowed_env_keys() {
217 let keys = default_allowed_env_keys();
218 assert!(keys.contains(&"HOME".to_owned()));
219 assert!(keys.contains(&"PATH".to_owned()));
220 assert!(keys.contains(&"TERM".to_owned()));
221 assert!(keys.contains(&"USER".to_owned()));
222 assert!(keys.contains(&"LANG".to_owned()));
223 assert_eq!(keys.len(), 5);
224 }
225
226 #[test]
227 fn test_parse_env_keys_basic() {
228 let keys = parse_env_keys("FOO,BAR,BAZ");
229 assert_eq!(keys, vec!["FOO", "BAR", "BAZ"]);
230 }
231
232 #[test]
233 fn test_parse_env_keys_with_whitespace() {
234 let keys = parse_env_keys(" FOO , BAR , BAZ ");
235 assert_eq!(keys, vec!["FOO", "BAR", "BAZ"]);
236 }
237
238 #[test]
239 fn test_parse_env_keys_empty_string() {
240 let keys = parse_env_keys("");
241 assert!(keys.is_empty());
242 }
243
244 #[test]
245 fn test_parse_env_keys_trailing_comma() {
246 let keys = parse_env_keys("FOO,BAR,");
247 assert_eq!(keys, vec!["FOO", "BAR"]);
248 }
249
250 #[test]
251 fn test_parse_timeout_valid() {
252 assert_eq!(parse_timeout("60"), Ok(Duration::from_secs(60)));
253 assert_eq!(parse_timeout(" 120 "), Ok(Duration::from_secs(120)));
254 }
255
256 #[test]
257 fn test_parse_timeout_invalid() {
258 assert!(parse_timeout("abc").is_err());
259 assert!(parse_timeout("").is_err());
260 }
261
262 #[test]
263 fn test_cli_runner_type_binary_names() {
264 assert_eq!(CliRunnerType::ClaudeCode.binary_name(), "claude");
265 assert_eq!(CliRunnerType::CursorAgent.binary_name(), "cursor-agent");
266 assert_eq!(CliRunnerType::OpenCode.binary_name(), "opencode");
267 assert_eq!(CliRunnerType::Copilot.binary_name(), "copilot");
268 assert_eq!(CliRunnerType::GeminiCli.binary_name(), "gemini");
269 assert_eq!(CliRunnerType::CodexCli.binary_name(), "codex");
270 assert_eq!(CliRunnerType::GooseCli.binary_name(), "goose");
271 assert_eq!(CliRunnerType::ClineCli.binary_name(), "cline");
272 assert_eq!(CliRunnerType::ContinueCli.binary_name(), "cn");
273 }
274
275 #[test]
276 fn test_cli_runner_type_env_keys() {
277 assert_eq!(
278 CliRunnerType::ClaudeCode.env_override_key(),
279 "CLAUDE_CODE_BINARY"
280 );
281 assert_eq!(CliRunnerType::Copilot.env_override_key(), "COPILOT_BINARY");
282 assert_eq!(
283 CliRunnerType::GeminiCli.env_override_key(),
284 "GEMINI_CLI_BINARY"
285 );
286 assert_eq!(
287 CliRunnerType::CodexCli.env_override_key(),
288 "CODEX_CLI_BINARY"
289 );
290 assert_eq!(
291 CliRunnerType::GooseCli.env_override_key(),
292 "GOOSE_CLI_BINARY"
293 );
294 assert_eq!(
295 CliRunnerType::ClineCli.env_override_key(),
296 "CLINE_CLI_BINARY"
297 );
298 assert_eq!(
299 CliRunnerType::ContinueCli.env_override_key(),
300 "CONTINUE_CLI_BINARY"
301 );
302 }
303
304 #[test]
305 fn test_cli_runner_type_display() {
306 assert_eq!(format!("{}", CliRunnerType::ClaudeCode), "claude_code");
307 assert_eq!(format!("{}", CliRunnerType::Copilot), "copilot");
308 assert_eq!(format!("{}", CliRunnerType::CursorAgent), "cursor_agent");
309 assert_eq!(format!("{}", CliRunnerType::OpenCode), "opencode");
310 assert_eq!(format!("{}", CliRunnerType::GeminiCli), "gemini_cli");
311 assert_eq!(format!("{}", CliRunnerType::CodexCli), "codex_cli");
312 assert_eq!(format!("{}", CliRunnerType::GooseCli), "goose_cli");
313 assert_eq!(format!("{}", CliRunnerType::ClineCli), "cline_cli");
314 assert_eq!(format!("{}", CliRunnerType::ContinueCli), "continue_cli");
315 }
316}