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}
15
16#[derive(Debug, Default, Deserialize)]
18pub struct ShellConfig {
19 pub unix: Option<Vec<String>>,
21 pub windows: Option<Vec<String>>,
23}
24
25pub fn discover_config_path() -> Option<PathBuf> {
30 use directories::BaseDirs;
31 let base = BaseDirs::new()?;
32 Some(base.config_dir().join("agent-exec").join("config.toml"))
33}
34
35pub fn load_config(path: &Path) -> Result<Option<AgentExecConfig>> {
40 if !path.exists() {
41 return Ok(None);
42 }
43 let raw = std::fs::read_to_string(path)
44 .with_context(|| format!("read config file {}", path.display()))?;
45 let cfg: AgentExecConfig = toml::from_str(&raw)
46 .with_context(|| format!("parse config file {}", path.display()))?;
47 Ok(Some(cfg))
48}
49
50pub fn default_shell_wrapper() -> Vec<String> {
52 #[cfg(not(windows))]
53 return vec!["sh".to_string(), "-lc".to_string()];
54 #[cfg(windows)]
55 return vec!["cmd".to_string(), "/C".to_string()];
56}
57
58pub fn parse_shell_wrapper_str(s: &str) -> Result<Vec<String>> {
62 let argv: Vec<String> = s.split_whitespace().map(|p| p.to_string()).collect();
63 if argv.is_empty() {
64 anyhow::bail!("--shell-wrapper must not be empty");
65 }
66 Ok(argv)
67}
68
69pub fn resolve_shell_wrapper(
77 cli_override: Option<&str>,
78 config_path_override: Option<&str>,
79) -> Result<Vec<String>> {
80 if let Some(s) = cli_override {
82 return parse_shell_wrapper_str(s);
83 }
84
85 let config_path: Option<PathBuf> = if let Some(p) = config_path_override {
87 Some(PathBuf::from(p))
88 } else {
89 discover_config_path()
90 };
91
92 if let Some(ref path) = config_path {
93 if let Some(cfg) = load_config(path)? {
94 if let Some(w) = platform_wrapper_from_config(&cfg.shell) {
95 if w.is_empty() {
96 anyhow::bail!(
97 "config file shell wrapper must not be empty (from {})",
98 path.display()
99 );
100 }
101 return Ok(w);
102 }
103 }
104 }
105
106 Ok(default_shell_wrapper())
108}
109
110fn platform_wrapper_from_config(cfg: &ShellConfig) -> Option<Vec<String>> {
112 #[cfg(not(windows))]
113 return cfg.unix.clone();
114 #[cfg(windows)]
115 return cfg.windows.clone();
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn default_wrapper_is_nonempty() {
124 let w = default_shell_wrapper();
125 assert!(!w.is_empty());
126 }
127
128 #[test]
129 fn parse_shell_wrapper_str_splits_whitespace() {
130 let w = parse_shell_wrapper_str("bash -lc").unwrap();
131 assert_eq!(w, vec!["bash", "-lc"]);
132 }
133
134 #[test]
135 fn parse_shell_wrapper_str_rejects_empty() {
136 assert!(parse_shell_wrapper_str("").is_err());
137 assert!(parse_shell_wrapper_str(" ").is_err());
138 }
139
140 #[test]
141 fn resolve_cli_override_takes_precedence() {
142 let w = resolve_shell_wrapper(Some("bash -lc"), None).unwrap();
143 assert_eq!(w, vec!["bash", "-lc"]);
144 }
145
146 #[test]
147 fn resolve_missing_config_returns_default() {
148 let w = resolve_shell_wrapper(None, Some("/nonexistent/config.toml")).unwrap();
150 assert_eq!(w, default_shell_wrapper());
151 }
152
153 #[test]
154 fn load_config_parses_unix_wrapper() {
155 let tmp = tempfile::NamedTempFile::new().unwrap();
156 std::fs::write(tmp.path(), r#"[shell]
157unix = ["bash", "-lc"]
158"#)
159 .unwrap();
160 let cfg = load_config(tmp.path()).unwrap().unwrap();
161 assert_eq!(cfg.shell.unix, Some(vec!["bash".to_string(), "-lc".to_string()]));
162 }
163
164 #[test]
165 fn resolve_config_file_override_is_used() {
166 let tmp = tempfile::NamedTempFile::new().unwrap();
167 std::fs::write(
168 tmp.path(),
169 "[shell]\nunix = [\"bash\", \"-lc\"]\nwindows = [\"cmd\", \"/C\"]\n",
170 )
171 .unwrap();
172 let w = resolve_shell_wrapper(None, Some(tmp.path().to_str().unwrap())).unwrap();
173 #[cfg(not(windows))]
175 assert_eq!(w, vec!["bash", "-lc"]);
176 #[cfg(windows)]
177 assert_eq!(w, vec!["cmd", "/C"]);
178 }
179}