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