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 =
46 toml::from_str(&raw).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 && let Some(cfg) = load_config(path)?
94 && let Some(w) = platform_wrapper_from_config(&cfg.shell)
95 {
96 if w.is_empty() {
97 anyhow::bail!(
98 "config file shell wrapper must not be empty (from {})",
99 path.display()
100 );
101 }
102 return Ok(w);
103 }
104
105 Ok(default_shell_wrapper())
107}
108
109fn platform_wrapper_from_config(cfg: &ShellConfig) -> Option<Vec<String>> {
111 #[cfg(not(windows))]
112 return cfg.unix.clone();
113 #[cfg(windows)]
114 return cfg.windows.clone();
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn default_wrapper_is_nonempty() {
123 let w = default_shell_wrapper();
124 assert!(!w.is_empty());
125 }
126
127 #[test]
128 fn parse_shell_wrapper_str_splits_whitespace() {
129 let w = parse_shell_wrapper_str("bash -lc").unwrap();
130 assert_eq!(w, vec!["bash", "-lc"]);
131 }
132
133 #[test]
134 fn parse_shell_wrapper_str_rejects_empty() {
135 assert!(parse_shell_wrapper_str("").is_err());
136 assert!(parse_shell_wrapper_str(" ").is_err());
137 }
138
139 #[test]
140 fn resolve_cli_override_takes_precedence() {
141 let w = resolve_shell_wrapper(Some("bash -lc"), None).unwrap();
142 assert_eq!(w, vec!["bash", "-lc"]);
143 }
144
145 #[test]
146 fn resolve_missing_config_returns_default() {
147 let w = resolve_shell_wrapper(None, Some("/nonexistent/config.toml")).unwrap();
149 assert_eq!(w, default_shell_wrapper());
150 }
151
152 #[test]
153 fn load_config_parses_unix_wrapper() {
154 let tmp = tempfile::NamedTempFile::new().unwrap();
155 std::fs::write(
156 tmp.path(),
157 r#"[shell]
158unix = ["bash", "-lc"]
159"#,
160 )
161 .unwrap();
162 let cfg = load_config(tmp.path()).unwrap().unwrap();
163 assert_eq!(
164 cfg.shell.unix,
165 Some(vec!["bash".to_string(), "-lc".to_string()])
166 );
167 }
168
169 #[test]
170 fn resolve_config_file_override_is_used() {
171 let tmp = tempfile::NamedTempFile::new().unwrap();
172 std::fs::write(
173 tmp.path(),
174 "[shell]\nunix = [\"bash\", \"-lc\"]\nwindows = [\"cmd\", \"/C\"]\n",
175 )
176 .unwrap();
177 let w = resolve_shell_wrapper(None, Some(tmp.path().to_str().unwrap())).unwrap();
178 #[cfg(not(windows))]
180 assert_eq!(w, vec!["bash", "-lc"]);
181 #[cfg(windows)]
182 assert_eq!(w, vec!["cmd", "/C"]);
183 }
184}