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