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 = toml::from_str(&raw)
46        .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        if let Some(cfg) = load_config(path)? {
94            if let Some(w) = platform_wrapper_from_config(&cfg.shell) {
95                if w.is_empty() {
96                    anyhow::bail!(
97                        "config file shell wrapper must not be empty (from {})",
98                        path.display()
99                    );
100                }
101                return Ok(w);
102            }
103        }
104    }
105
106    // 4. Built-in platform default.
107    Ok(default_shell_wrapper())
108}
109
110/// Extract the active platform's wrapper from `ShellConfig`.
111fn platform_wrapper_from_config(cfg: &ShellConfig) -> Option<Vec<String>> {
112    #[cfg(not(windows))]
113    return cfg.unix.clone();
114    #[cfg(windows)]
115    return cfg.windows.clone();
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn default_wrapper_is_nonempty() {
124        let w = default_shell_wrapper();
125        assert!(!w.is_empty());
126    }
127
128    #[test]
129    fn parse_shell_wrapper_str_splits_whitespace() {
130        let w = parse_shell_wrapper_str("bash -lc").unwrap();
131        assert_eq!(w, vec!["bash", "-lc"]);
132    }
133
134    #[test]
135    fn parse_shell_wrapper_str_rejects_empty() {
136        assert!(parse_shell_wrapper_str("").is_err());
137        assert!(parse_shell_wrapper_str("   ").is_err());
138    }
139
140    #[test]
141    fn resolve_cli_override_takes_precedence() {
142        let w = resolve_shell_wrapper(Some("bash -lc"), None).unwrap();
143        assert_eq!(w, vec!["bash", "-lc"]);
144    }
145
146    #[test]
147    fn resolve_missing_config_returns_default() {
148        // Point to a nonexistent config; should fall back to default.
149        let w = resolve_shell_wrapper(None, Some("/nonexistent/config.toml")).unwrap();
150        assert_eq!(w, default_shell_wrapper());
151    }
152
153    #[test]
154    fn load_config_parses_unix_wrapper() {
155        let tmp = tempfile::NamedTempFile::new().unwrap();
156        std::fs::write(tmp.path(), r#"[shell]
157unix = ["bash", "-lc"]
158"#)
159        .unwrap();
160        let cfg = load_config(tmp.path()).unwrap().unwrap();
161        assert_eq!(cfg.shell.unix, Some(vec!["bash".to_string(), "-lc".to_string()]));
162    }
163
164    #[test]
165    fn resolve_config_file_override_is_used() {
166        let tmp = tempfile::NamedTempFile::new().unwrap();
167        std::fs::write(
168            tmp.path(),
169            "[shell]\nunix = [\"bash\", \"-lc\"]\nwindows = [\"cmd\", \"/C\"]\n",
170        )
171        .unwrap();
172        let w = resolve_shell_wrapper(None, Some(tmp.path().to_str().unwrap())).unwrap();
173        // On non-Windows the unix key is used; on Windows the windows key.
174        #[cfg(not(windows))]
175        assert_eq!(w, vec!["bash", "-lc"]);
176        #[cfg(windows)]
177        assert_eq!(w, vec!["cmd", "/C"]);
178    }
179}