1use std::ffi::OsStr;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use crate::adapter::AdapterMetadata;
14use crate::error::{Error, NotInstalledReason, Result};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Origin {
19 Configured,
20 Path,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Found {
26 pub path: PathBuf,
27 pub origin: Origin,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum ExecutableStatus {
34 Found(Found),
36 NotOnPath,
38 ConfiguredMissing(PathBuf),
40 Unsupported(PathBuf),
42}
43
44impl ExecutableStatus {
45 pub fn discover(
48 metadata: &AdapterMetadata,
49 explicit: Option<&Path>,
50 path_var: Option<&OsStr>,
51 config_file: &Path,
52 ) -> Result<ExecutableStatus> {
53 match discover(metadata.id, metadata.executable, explicit, path_var, config_file) {
54 Ok(found) => Ok(ExecutableStatus::Found(found)),
55 Err(Error::AgentNotInstalled {
56 reason: NotInstalledReason::NotOnPath { .. }, ..
57 }) => Ok(ExecutableStatus::NotOnPath),
58 Err(Error::AgentNotInstalled {
59 reason: NotInstalledReason::ExplicitMissing(path),
60 ..
61 }) => Ok(ExecutableStatus::ConfiguredMissing(path)),
62 Err(Error::UnsupportedExecutable { path, .. }) => {
63 Ok(ExecutableStatus::Unsupported(path))
64 }
65 Err(error) => Err(error),
66 }
67 }
68}
69
70const SHELL_EXTENSIONS: [&str; 3] = ["bat", "cmd", "ps1"];
73
74#[cfg(windows)]
76const WINDOWS_FORMS: [&str; 5] = ["exe", "com", "cmd", "bat", "ps1"];
77
78pub fn discover(
81 agent: &str,
82 name: &str,
83 explicit: Option<&Path>,
84 path_var: Option<&OsStr>,
85 config_file: &Path,
86) -> Result<Found> {
87 let found = match explicit {
88 Some(path) => {
89 if !fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) {
90 return Err(not_installed(
91 agent,
92 NotInstalledReason::ExplicitMissing(path.to_path_buf()),
93 ));
94 }
95 Found { path: path.to_path_buf(), origin: Origin::Configured }
96 }
97 None => search_path(agent, name, path_var, config_file)?,
98 };
99 if has_shell_extension(&found.path) {
100 return Err(unsupported(agent, found.path, config_file));
101 }
102 Ok(found)
103}
104
105fn has_shell_extension(path: &Path) -> bool {
108 let Some(name) = path.file_name() else { return false };
109 let name = name.to_string_lossy();
110 let Some((_, ext)) = name.trim_end_matches([' ', '.']).rsplit_once('.') else { return false };
111 SHELL_EXTENSIONS.iter().any(|shell| ext.eq_ignore_ascii_case(shell))
112}
113
114fn search_path(
115 agent: &str,
116 name: &str,
117 path_var: Option<&OsStr>,
118 config_file: &Path,
119) -> Result<Found> {
120 #[cfg(not(windows))]
122 let _ = config_file;
123 let mut ignored_relative = 0;
124 for dir in path_var.into_iter().flat_map(std::env::split_paths) {
125 if dir.as_os_str().is_empty() {
126 continue;
127 }
128 if !dir.is_absolute() {
130 ignored_relative += 1;
131 continue;
132 }
133 match search_dir(&dir, name) {
134 Some(Hit::Native(path)) => return Ok(Found { path, origin: Origin::Path }),
135 #[cfg(windows)]
136 Some(Hit::Unsupported(path)) => return Err(unsupported(agent, path, config_file)),
137 None => {}
138 }
139 }
140 Err(not_installed(agent, NotInstalledReason::NotOnPath { ignored_relative }))
141}
142
143pub fn repository_local(name: &str, path_var: Option<&OsStr>, cwd: &Path) -> Option<PathBuf> {
146 for dir in path_var.into_iter().flat_map(std::env::split_paths) {
147 if dir.as_os_str().is_empty() || dir.is_absolute() {
148 continue;
149 }
150 match search_dir(&cwd.join(&dir), name) {
151 Some(Hit::Native(path)) => return Some(path),
152 #[cfg(windows)]
153 Some(Hit::Unsupported(path)) => return Some(path),
154 None => {}
155 }
156 }
157 None
158}
159
160enum Hit {
162 Native(PathBuf),
163 #[cfg(windows)]
166 Unsupported(PathBuf),
167}
168
169#[cfg(windows)]
170fn search_dir(dir: &Path, name: &str) -> Option<Hit> {
171 let mut forms = WINDOWS_FORMS
172 .iter()
173 .map(|ext| dir.join(format!("{name}.{ext}")))
174 .filter(|candidate| is_executable(candidate));
175 let first = forms.next()?;
176 if first.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) {
177 Some(Hit::Native(first))
178 } else {
179 Some(Hit::Unsupported(first))
180 }
181}
182
183#[cfg(unix)]
184fn search_dir(dir: &Path, name: &str) -> Option<Hit> {
185 let candidate = dir.join(name);
186 is_executable(&candidate).then_some(Hit::Native(candidate))
187}
188
189fn not_installed(agent: &str, reason: NotInstalledReason) -> Error {
190 Error::AgentNotInstalled { agent: agent.to_owned(), reason, unknown_configured: Vec::new() }
191}
192
193fn unsupported(agent: &str, path: PathBuf, config_file: &Path) -> Error {
194 Error::UnsupportedExecutable {
195 agent: agent.to_owned(),
196 path,
197 config_file: config_file.to_path_buf(),
198 }
199}
200
201#[cfg(unix)]
202pub(crate) fn is_executable(path: &Path) -> bool {
203 use std::os::unix::fs::PermissionsExt;
204 fs::metadata(path)
205 .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
206}
207
208#[cfg(windows)]
209pub(crate) fn is_executable(path: &Path) -> bool {
210 fs::metadata(path).is_ok_and(|metadata| metadata.is_file())
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 const CONFIG: &str = "/root/config.toml";
218
219 fn make_file(dir: &Path, name: &str, executable: bool) -> PathBuf {
220 let path = dir.join(name);
221 fs::write(&path, b"x").unwrap();
222 #[cfg(unix)]
223 {
224 use std::os::unix::fs::PermissionsExt;
225 let mode = if executable { 0o755 } else { 0o644 };
226 fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap();
227 }
228 #[cfg(windows)]
229 let _ = executable;
230 path
231 }
232
233 fn native(name: &str) -> String {
234 if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() }
235 }
236
237 fn find(name: &str, explicit: Option<&Path>, path_var: Option<&OsStr>) -> Result<Found> {
238 discover("fake", name, explicit, path_var, Path::new(CONFIG))
239 }
240
241 #[test]
242 fn explicit_override_wins_and_must_be_a_file() {
243 let dir = tempfile::tempdir().unwrap();
244 let exe = make_file(dir.path(), "agent-bin", true);
245 let found = find("fake-agent", Some(&exe), None).unwrap();
246 assert_eq!(found, Found { path: exe, origin: Origin::Configured });
247
248 let missing = dir.path().join("missing");
249 let error = find("fake-agent", Some(&missing), None).unwrap_err();
250 assert!(matches!(
251 error,
252 Error::AgentNotInstalled { reason: NotInstalledReason::ExplicitMissing(ref p), .. } if *p == missing
253 ));
254 let error = find("fake-agent", Some(dir.path()), None).unwrap_err();
255 assert!(
256 matches!(error, Error::AgentNotInstalled { .. }),
257 "a directory is not an executable"
258 );
259 }
260
261 #[test]
262 fn path_search_takes_the_first_match_in_order() {
263 let first = tempfile::tempdir().unwrap();
264 let second = tempfile::tempdir().unwrap();
265 let empty = tempfile::tempdir().unwrap();
266 make_file(first.path(), &native("tool"), true);
267 let expected = make_file(second.path(), &native("tool"), true);
268 let path_var = std::env::join_paths([empty.path(), second.path(), first.path()]).unwrap();
269 let found = find("tool", None, Some(&path_var)).unwrap();
270 assert_eq!(found, Found { path: expected, origin: Origin::Path });
271 }
272
273 #[test]
274 fn missing_from_path_is_not_installed() {
275 let empty = tempfile::tempdir().unwrap();
276 let path_var = std::env::join_paths([empty.path()]).unwrap();
277 for path_var in [Some(path_var.as_os_str()), Some(OsStr::new("")), None] {
278 let error = find("tool", None, path_var).unwrap_err();
279 assert!(matches!(
280 error,
281 Error::AgentNotInstalled {
282 reason: NotInstalledReason::NotOnPath { ignored_relative: 0 },
283 ..
284 }
285 ));
286 }
287 }
288
289 #[test]
290 fn relative_path_entries_are_ignored_and_counted() {
291 let mut entries =
292 vec![PathBuf::from("."), PathBuf::from("bin"), PathBuf::from("..").join("tools")];
293 if cfg!(windows) {
294 entries.push(PathBuf::from(r"\tools"));
295 entries.push(PathBuf::from("C:tools"));
296 }
297 let expected = entries.len();
298 let path_var = std::env::join_paths(&entries).unwrap();
299 let error = find("tool", None, Some(&path_var)).unwrap_err();
300 match error {
301 Error::AgentNotInstalled {
302 reason: NotInstalledReason::NotOnPath { ignored_relative },
303 ..
304 } => assert_eq!(ignored_relative, expected),
305 other => panic!("{other:?}"),
306 }
307 }
308
309 #[cfg(unix)]
310 #[test]
311 fn path_search_skips_files_without_an_execute_bit() {
312 let dir = tempfile::tempdir().unwrap();
313 make_file(dir.path(), "tool", false);
314 let path_var = std::env::join_paths([dir.path()]).unwrap();
315 assert!(find("tool", None, Some(&path_var)).is_err());
316 }
317
318 #[test]
319 fn shell_scripts_are_refused_when_configured() {
320 let dir = tempfile::tempdir().unwrap();
321 for name in ["agent.cmd", "agent.BAT", "agent.Cmd", "agent.ps1", "agent.PS1"] {
322 let path = make_file(dir.path(), name, true);
323 match find("fake-agent", Some(&path), None).unwrap_err() {
324 Error::UnsupportedExecutable { agent, path: refused, config_file } => {
325 assert_eq!((agent.as_str(), refused), ("fake", path));
326 assert_eq!(config_file, PathBuf::from(CONFIG));
327 }
328 other => panic!("{name}: {other:?}"),
329 }
330 }
331 }
332
333 #[test]
334 fn trailing_spaces_and_dots_do_not_hide_a_shell_extension() {
335 let dir = tempfile::tempdir().unwrap();
336 for name in ["agent.cmd ", "agent.cmd.", "agent.BAT. .", "agent.ps1 "] {
337 let path = make_file(dir.path(), name, true);
338 assert!(
339 matches!(
340 find("fake-agent", Some(&path), None),
341 Err(Error::UnsupportedExecutable { .. })
342 ),
343 "{name:?}"
344 );
345 }
346 let native = make_file(dir.path(), "agent.cmd.exe", true);
347 assert!(find("fake-agent", Some(&native), None).is_ok());
348 }
349
350 #[test]
351 fn a_configured_com_is_a_native_executable() {
352 let dir = tempfile::tempdir().unwrap();
353 let com = make_file(dir.path(), "agent.COM", true);
354 assert_eq!(
355 find("fake-agent", Some(&com), None).unwrap(),
356 Found { path: com, origin: Origin::Configured }
357 );
358 }
359
360 #[cfg(windows)]
361 fn refused_on_path(dirs: &[&Path]) -> PathBuf {
362 let path_var = std::env::join_paths(dirs).unwrap();
363 match find("codex", None, Some(&path_var)).unwrap_err() {
364 Error::UnsupportedExecutable { agent, path, config_file } => {
365 assert_eq!(agent, "fake");
366 assert_eq!(config_file, PathBuf::from(CONFIG));
367 path
368 }
369 other => panic!("{other:?}"),
370 }
371 }
372
373 #[cfg(windows)]
374 #[test]
375 fn windows_shim_only_directories_are_refused() {
376 for form in ["codex.cmd", "codex.bat", "codex.ps1", "codex.com"] {
377 let dir = tempfile::tempdir().unwrap();
378 let shim = make_file(dir.path(), form, true);
379 assert_eq!(refused_on_path(&[dir.path()]), shim, "{form}");
380 }
381 }
382
383 #[cfg(windows)]
384 #[test]
385 fn windows_first_directory_decides_and_names_the_first_form() {
386 let both = tempfile::tempdir().unwrap();
387 make_file(both.path(), "codex.ps1", true);
388 let cmd = make_file(both.path(), "codex.cmd", true);
389 assert_eq!(refused_on_path(&[both.path()]), cmd);
390
391 let later = tempfile::tempdir().unwrap();
392 make_file(later.path(), "codex.exe", true);
393 assert_eq!(refused_on_path(&[both.path(), later.path()]), cmd);
394
395 let beside = tempfile::tempdir().unwrap();
396 make_file(beside.path(), "codex.cmd", true);
397 let exe = make_file(beside.path(), "codex.exe", true);
398 let path_var = std::env::join_paths([beside.path()]).unwrap();
399 assert_eq!(
400 find("codex", None, Some(&path_var)).unwrap(),
401 Found { path: exe, origin: Origin::Path }
402 );
403 }
404
405 #[cfg(unix)]
406 #[test]
407 fn repository_local_finds_an_executable_through_a_relative_entry_only() {
408 let dir = tempfile::tempdir().unwrap();
409 let dir = dir.path().to_path_buf();
410 let bin = dir.join("bin");
411 let other = dir.join("other");
412 fs::create_dir_all(&bin).unwrap();
413 fs::create_dir_all(&other).unwrap();
414 make_file(&bin, "fake-agent", true);
415 make_file(&other, "only-absolute", true);
416 make_file(&bin, "not-executable", false);
417 let path_var = std::env::join_paths([Path::new("bin"), other.as_path()]).unwrap();
418 assert_eq!(
419 repository_local("fake-agent", Some(path_var.as_os_str()), &dir),
420 Some(dir.join("bin").join("fake-agent"))
421 );
422 assert_eq!(repository_local("only-absolute", Some(path_var.as_os_str()), &dir), None);
424 assert_eq!(repository_local("not-executable", Some(path_var.as_os_str()), &dir), None);
426 }
427
428 #[cfg(windows)]
429 #[test]
430 fn repository_local_uses_the_windows_forms() {
431 let dir = tempfile::tempdir().unwrap();
432 let dir = dir.path().to_path_buf();
433 let bin = dir.join("bin");
434 fs::create_dir_all(&bin).unwrap();
435 make_file(&bin, "fake-agent.exe", true);
436 make_file(&bin, "bare-agent", true);
437 let path_var = std::env::join_paths([Path::new("bin")]).unwrap();
438 assert_eq!(
439 repository_local("fake-agent", Some(path_var.as_os_str()), &dir),
440 Some(dir.join("bin").join("fake-agent.exe"))
441 );
442 assert_eq!(repository_local("bare-agent", Some(path_var.as_os_str()), &dir), None);
443 }
444}