Skip to main content

agent_exec/
config.rs

1//! Configuration loading for agent-exec.
2//!
3//! Reads `config.toml` from the XDG config directory with optional CLI overrides.
4
5use anyhow::{Context, Result};
6use serde::Deserialize;
7use std::path::{Path, PathBuf};
8
9/// Top-level config struct for `config.toml`.
10#[derive(Debug, Default, Deserialize)]
11pub struct AgentExecConfig {
12    #[serde(default)]
13    pub shell: ShellConfig,
14}
15
16/// `[shell]` section of `config.toml`.
17#[derive(Debug, Default, Deserialize)]
18pub struct ShellConfig {
19    /// Shell wrapper argv for Unix-like platforms (e.g. `["sh", "-lc"]`).
20    pub unix: Option<Vec<String>>,
21    /// Shell wrapper argv for Windows (e.g. `["cmd", "/C"]`).
22    pub windows: Option<Vec<String>>,
23}
24
25/// Discover the default XDG config file path.
26///
27/// Returns `$XDG_CONFIG_HOME/agent-exec/config.toml` if `XDG_CONFIG_HOME` is set,
28/// otherwise returns `~/.config/agent-exec/config.toml`.
29pub fn discover_config_path() -> Option<PathBuf> {
30    use directories::BaseDirs;
31    let base = BaseDirs::new()?;
32    Some(base.config_dir().join("agent-exec").join("config.toml"))
33}
34
35/// Load and parse a config file from the given path.
36///
37/// Returns `Ok(None)` if the file does not exist.
38/// Returns `Err` if the file exists but cannot be parsed.
39pub fn load_config(path: &Path) -> Result<Option<AgentExecConfig>> {
40    if !path.exists() {
41        return Ok(None);
42    }
43    let raw = std::fs::read_to_string(path)
44        .with_context(|| format!("read config file {}", path.display()))?;
45    let cfg: AgentExecConfig =
46        toml::from_str(&raw).with_context(|| format!("parse config file {}", path.display()))?;
47    Ok(Some(cfg))
48}
49
50/// Return the built-in platform default shell wrapper argv.
51pub fn default_shell_wrapper() -> Vec<String> {
52    #[cfg(not(windows))]
53    return vec!["sh".to_string(), "-lc".to_string()];
54    #[cfg(windows)]
55    return vec!["cmd".to_string(), "/C".to_string()];
56}
57
58/// Parse a CLI `--shell-wrapper` string (e.g. `"bash -lc"`) into an argv vec.
59///
60/// Splits on whitespace; returns an error if the result is empty.
61pub fn parse_shell_wrapper_str(s: &str) -> Result<Vec<String>> {
62    let argv: Vec<String> = s.split_whitespace().map(|p| p.to_string()).collect();
63    if argv.is_empty() {
64        anyhow::bail!("--shell-wrapper must not be empty");
65    }
66    Ok(argv)
67}
68
69/// Resolve the effective shell wrapper from CLI override, config file, and built-in defaults.
70///
71/// Resolution order:
72/// 1. `cli_override` from `--shell-wrapper`
73/// 2. Config file at `config_path_override` (from `--config`)
74/// 3. Default XDG config file
75/// 4. Built-in platform default
76pub fn resolve_shell_wrapper(
77    cli_override: Option<&str>,
78    config_path_override: Option<&str>,
79) -> Result<Vec<String>> {
80    // 1. CLI override takes highest precedence.
81    if let Some(s) = cli_override {
82        return parse_shell_wrapper_str(s);
83    }
84
85    // 2 & 3. Try explicit config path, then default XDG path.
86    let config_path: Option<PathBuf> = if let Some(p) = config_path_override {
87        Some(PathBuf::from(p))
88    } else {
89        discover_config_path()
90    };
91
92    if let Some(ref path) = config_path
93        && let Some(cfg) = load_config(path)?
94        && let Some(w) = platform_wrapper_from_config(&cfg.shell)
95    {
96        if w.is_empty() {
97            anyhow::bail!(
98                "config file shell wrapper must not be empty (from {})",
99                path.display()
100            );
101        }
102        return Ok(w);
103    }
104
105    // 4. Built-in platform default.
106    Ok(default_shell_wrapper())
107}
108
109/// Extract the active platform's wrapper from `ShellConfig`.
110fn platform_wrapper_from_config(cfg: &ShellConfig) -> Option<Vec<String>> {
111    #[cfg(not(windows))]
112    return cfg.unix.clone();
113    #[cfg(windows)]
114    return cfg.windows.clone();
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn default_wrapper_is_nonempty() {
123        let w = default_shell_wrapper();
124        assert!(!w.is_empty());
125    }
126
127    #[test]
128    fn parse_shell_wrapper_str_splits_whitespace() {
129        let w = parse_shell_wrapper_str("bash -lc").unwrap();
130        assert_eq!(w, vec!["bash", "-lc"]);
131    }
132
133    #[test]
134    fn parse_shell_wrapper_str_rejects_empty() {
135        assert!(parse_shell_wrapper_str("").is_err());
136        assert!(parse_shell_wrapper_str("   ").is_err());
137    }
138
139    #[test]
140    fn resolve_cli_override_takes_precedence() {
141        let w = resolve_shell_wrapper(Some("bash -lc"), None).unwrap();
142        assert_eq!(w, vec!["bash", "-lc"]);
143    }
144
145    #[test]
146    fn resolve_missing_config_returns_default() {
147        // Point to a nonexistent config; should fall back to default.
148        let w = resolve_shell_wrapper(None, Some("/nonexistent/config.toml")).unwrap();
149        assert_eq!(w, default_shell_wrapper());
150    }
151
152    #[test]
153    fn load_config_parses_unix_wrapper() {
154        let tmp = tempfile::NamedTempFile::new().unwrap();
155        std::fs::write(
156            tmp.path(),
157            r#"[shell]
158unix = ["bash", "-lc"]
159"#,
160        )
161        .unwrap();
162        let cfg = load_config(tmp.path()).unwrap().unwrap();
163        assert_eq!(
164            cfg.shell.unix,
165            Some(vec!["bash".to_string(), "-lc".to_string()])
166        );
167    }
168
169    #[test]
170    fn resolve_config_file_override_is_used() {
171        let tmp = tempfile::NamedTempFile::new().unwrap();
172        std::fs::write(
173            tmp.path(),
174            "[shell]\nunix = [\"bash\", \"-lc\"]\nwindows = [\"cmd\", \"/C\"]\n",
175        )
176        .unwrap();
177        let w = resolve_shell_wrapper(None, Some(tmp.path().to_str().unwrap())).unwrap();
178        // On non-Windows the unix key is used; on Windows the windows key.
179        #[cfg(not(windows))]
180        assert_eq!(w, vec!["bash", "-lc"]);
181        #[cfg(windows)]
182        assert_eq!(w, vec!["cmd", "/C"]);
183    }
184}