use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Deserialize)]
pub struct AgentExecConfig {
#[serde(default)]
pub shell: ShellConfig,
#[serde(default)]
pub gc: GcConfig,
}
#[derive(Debug, Default, Deserialize)]
pub struct GcConfig {
pub auto: Option<bool>,
pub older_than: Option<String>,
pub max_jobs: Option<usize>,
pub max_bytes: Option<u64>,
pub scan_limit: Option<usize>,
pub delete_limit: Option<usize>,
}
impl GcConfig {
pub fn to_auto_gc_config(&self) -> crate::gc::AutoGcConfig {
let default = crate::gc::AutoGcConfig::default();
crate::gc::AutoGcConfig {
enabled: self.auto.unwrap_or(default.enabled),
older_than: self
.older_than
.clone()
.unwrap_or_else(|| default.older_than.clone()),
max_jobs: self.max_jobs,
max_bytes: self.max_bytes,
scan_limit: self.scan_limit.unwrap_or(default.scan_limit),
delete_limit: self.delete_limit.unwrap_or(default.delete_limit),
}
}
}
#[derive(Debug, Default, Deserialize)]
pub struct ShellConfig {
pub unix: Option<Vec<String>>,
pub windows: Option<Vec<String>>,
}
pub fn discover_config_path() -> Option<PathBuf> {
use directories::BaseDirs;
let base = BaseDirs::new()?;
Some(base.config_dir().join("agent-exec").join("config.toml"))
}
pub fn load_config(path: &Path) -> Result<Option<AgentExecConfig>> {
if !path.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(path)
.with_context(|| format!("read config file {}", path.display()))?;
let cfg: AgentExecConfig =
toml::from_str(&raw).with_context(|| format!("parse config file {}", path.display()))?;
Ok(Some(cfg))
}
pub fn default_shell_wrapper() -> Vec<String> {
#[cfg(not(windows))]
return vec!["sh".to_string(), "-lc".to_string()];
#[cfg(windows)]
return vec!["cmd".to_string(), "/C".to_string()];
}
pub fn parse_shell_wrapper_str(s: &str) -> Result<Vec<String>> {
let argv: Vec<String> = s.split_whitespace().map(|p| p.to_string()).collect();
if argv.is_empty() {
anyhow::bail!("--shell-wrapper must not be empty");
}
Ok(argv)
}
pub fn resolve_shell_wrapper(
cli_override: Option<&str>,
config_path_override: Option<&str>,
) -> Result<Vec<String>> {
if let Some(s) = cli_override {
return parse_shell_wrapper_str(s);
}
let config_path: Option<PathBuf> = if let Some(p) = config_path_override {
Some(PathBuf::from(p))
} else {
discover_config_path()
};
if let Some(ref path) = config_path
&& let Some(cfg) = load_config(path)?
&& let Some(w) = platform_wrapper_from_config(&cfg.shell)
{
if w.is_empty() {
anyhow::bail!(
"config file shell wrapper must not be empty (from {})",
path.display()
);
}
return Ok(w);
}
Ok(default_shell_wrapper())
}
fn platform_wrapper_from_config(cfg: &ShellConfig) -> Option<Vec<String>> {
#[cfg(not(windows))]
return cfg.unix.clone();
#[cfg(windows)]
return cfg.windows.clone();
}
pub fn resolve_config(config_path_override: Option<&str>) -> Result<AgentExecConfig> {
let path: Option<PathBuf> = if let Some(p) = config_path_override {
Some(PathBuf::from(p))
} else {
discover_config_path()
};
if let Some(path) = path
&& let Some(cfg) = load_config(&path)?
{
return Ok(cfg);
}
Ok(AgentExecConfig::default())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_wrapper_is_nonempty() {
let w = default_shell_wrapper();
assert!(!w.is_empty());
}
#[test]
fn parse_shell_wrapper_str_splits_whitespace() {
let w = parse_shell_wrapper_str("bash -lc").unwrap();
assert_eq!(w, vec!["bash", "-lc"]);
}
#[test]
fn parse_shell_wrapper_str_rejects_empty() {
assert!(parse_shell_wrapper_str("").is_err());
assert!(parse_shell_wrapper_str(" ").is_err());
}
#[test]
fn resolve_cli_override_takes_precedence() {
let w = resolve_shell_wrapper(Some("bash -lc"), None).unwrap();
assert_eq!(w, vec!["bash", "-lc"]);
}
#[test]
fn resolve_missing_config_returns_default() {
let w = resolve_shell_wrapper(None, Some("/nonexistent/config.toml")).unwrap();
assert_eq!(w, default_shell_wrapper());
}
#[test]
fn load_config_parses_unix_wrapper() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
r#"[shell]
unix = ["bash", "-lc"]
"#,
)
.unwrap();
let cfg = load_config(tmp.path()).unwrap().unwrap();
assert_eq!(
cfg.shell.unix,
Some(vec!["bash".to_string(), "-lc".to_string()])
);
}
#[test]
fn resolve_config_file_override_is_used() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
"[shell]\nunix = [\"bash\", \"-lc\"]\nwindows = [\"cmd\", \"/C\"]\n",
)
.unwrap();
let w = resolve_shell_wrapper(None, Some(tmp.path().to_str().unwrap())).unwrap();
#[cfg(not(windows))]
assert_eq!(w, vec!["bash", "-lc"]);
#[cfg(windows)]
assert_eq!(w, vec!["cmd", "/C"]);
}
}