Skip to main content

embacle/
config.rs

1// ABOUTME: Shared configuration types for CLI-based LLM runners
2// ABOUTME: Defines runner types, runner configuration, and environment key parsing
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7use std::fmt;
8use std::path::PathBuf;
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12
13/// Default timeout for CLI command execution (120 seconds)
14const DEFAULT_TIMEOUT_SECS: u64 = 120;
15
16/// Supported CLI runner types
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub enum CliRunnerType {
19    /// Claude Code CLI (`claude`)
20    ClaudeCode,
21    /// Cursor Agent CLI (`cursor-agent`)
22    CursorAgent,
23    /// `OpenCode` CLI (`opencode`)
24    OpenCode,
25    /// GitHub Copilot CLI (`copilot`)
26    Copilot,
27    /// Gemini CLI (`gemini`)
28    GeminiCli,
29    /// Codex CLI (`codex`)
30    CodexCli,
31    /// Goose CLI (`goose`)
32    GooseCli,
33    /// Cline CLI (`cline`)
34    ClineCli,
35    /// Continue CLI (`cn`)
36    ContinueCli,
37}
38
39impl CliRunnerType {
40    /// Binary name used to locate the CLI tool on disk
41    #[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    /// Environment variable that can override the binary path
57    #[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/// Configuration for a CLI runner instance
90#[derive(Debug, Clone)]
91pub struct RunnerConfig {
92    /// Path to the CLI binary
93    pub binary_path: PathBuf,
94    /// Model override (provider-specific format)
95    pub model: Option<String>,
96    /// Maximum time to wait for a CLI command to complete
97    pub timeout: Duration,
98    /// Additional CLI arguments appended to every invocation
99    pub extra_args: Vec<String>,
100    /// Environment variable keys passed through to the subprocess
101    pub allowed_env_keys: Vec<String>,
102    /// Working directory for the subprocess
103    pub working_directory: Option<PathBuf>,
104}
105
106impl RunnerConfig {
107    /// Create a new runner configuration with the given binary path
108    #[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    /// Set the model to use
121    #[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    /// Set the command timeout
128    #[must_use]
129    pub const fn with_timeout(mut self, timeout: Duration) -> Self {
130        self.timeout = timeout;
131        self
132    }
133
134    /// Set extra CLI arguments
135    #[must_use]
136    pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
137        self.extra_args = args;
138        self
139    }
140
141    /// Set the environment variable keys passed through to the subprocess
142    #[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    /// Set the working directory for the subprocess
149    #[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/// Default set of environment variable keys safe to pass through to subprocesses
157#[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/// Parse a comma-separated list of environment variable keys
166#[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
178/// Parse a timeout value from a string (in seconds)
179///
180/// # Errors
181///
182/// Returns an error if the string cannot be parsed as a `u64`.
183pub 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}