1use 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#[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#[derive(Debug, Default, Deserialize)]
73pub struct ShellConfig {
74 pub unix: Option<Vec<String>>,
76 pub windows: Option<Vec<String>>,
78}
79
80pub 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
90pub 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
109pub 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
117pub 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
128pub fn resolve_shell_wrapper(
136 cli_override: Option<&str>,
137 config_path_override: Option<&str>,
138) -> Result<Vec<String>> {
139 if let Some(s) = cli_override {
141 return parse_shell_wrapper_str(s);
142 }
143
144 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 Ok(default_shell_wrapper())
166}
167
168fn 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
176pub 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 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 #[cfg(not(windows))]
286 assert_eq!(w, vec!["bash", "-lc"]);
287 #[cfg(windows)]
288 assert_eq!(w, vec!["cmd", "/C"]);
289 }
290}