1use anyhow::{Context, Result};
6use serde::Deserialize;
7use std::path::{Path, PathBuf};
8
9#[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#[derive(Debug, Default, Deserialize)]
47pub struct ShellConfig {
48 pub unix: Option<Vec<String>>,
50 pub windows: Option<Vec<String>>,
52}
53
54pub 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
64pub 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
79pub 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
87pub 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
98pub fn resolve_shell_wrapper(
106 cli_override: Option<&str>,
107 config_path_override: Option<&str>,
108) -> Result<Vec<String>> {
109 if let Some(s) = cli_override {
111 return parse_shell_wrapper_str(s);
112 }
113
114 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 Ok(default_shell_wrapper())
136}
137
138fn 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
146pub 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 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 #[cfg(not(windows))]
226 assert_eq!(w, vec!["bash", "-lc"]);
227 #[cfg(windows)]
228 assert_eq!(w, vec!["cmd", "/C"]);
229 }
230}