rumdl_lib/code_block_tools/
lookup.rs1use std::ffi::OsStr;
12#[cfg(any(unix, windows))]
13use std::path::Path;
14use std::path::PathBuf;
15
16pub fn resolve_program(program: &OsStr, search_path: Option<&OsStr>) -> Option<PathBuf> {
37 if program.is_empty() {
38 return None;
39 }
40 resolve_for_platform(program, search_path)
41}
42
43#[cfg(unix)]
44fn resolve_for_platform(program: &OsStr, search_path: Option<&OsStr>) -> Option<PathBuf> {
45 use std::os::unix::ffi::OsStrExt;
46
47 if program.as_bytes().contains(&b'/') {
48 let path = Path::new(program);
49 return is_executable_file(path).then(|| path.to_path_buf());
50 }
51
52 let default_path = OsStr::new("/usr/bin:/bin");
53 let search_path = search_path.unwrap_or(default_path);
54 std::env::split_paths(search_path)
55 .map(|dir| dir.join(program))
56 .find(|candidate| is_executable_file(candidate))
57}
58
59#[cfg(unix)]
60fn is_executable_file(path: &Path) -> bool {
61 use std::os::unix::fs::PermissionsExt;
62
63 std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
64}
65
66#[cfg(windows)]
67fn resolve_for_platform(program: &OsStr, search_path: Option<&OsStr>) -> Option<PathBuf> {
68 use std::ffi::OsString;
69
70 let bytes = program.as_encoded_bytes();
71 if bytes.ends_with(b"\\") || bytes.ends_with(b"/") {
72 return None;
73 }
74 let has_separator = bytes.contains(&b'\\') || bytes.contains(&b'/');
75
76 if has_separator {
77 let path = Path::new(program);
78 let has_exe_suffix = bytes.len() >= 4 && bytes[bytes.len() - 4..].eq_ignore_ascii_case(b".exe");
79 if has_exe_suffix {
80 return path.is_file().then(|| path.to_path_buf());
81 }
82 let mut with_exe: OsString = program.to_os_string();
85 with_exe.push(".exe");
86 let with_exe = PathBuf::from(with_exe);
87 if with_exe.is_file() {
88 return Some(with_exe);
89 }
90 return path.is_file().then(|| path.to_path_buf());
91 }
92
93 let has_extension = bytes.contains(&b'.');
96
97 let mut dirs: Vec<PathBuf> = Vec::new();
98 if let Some(dir) = std::env::current_exe()
99 .ok()
100 .and_then(|exe| exe.parent().map(Path::to_path_buf))
101 {
102 dirs.push(dir);
103 }
104 dirs.extend(windows_system_directories());
105 if let Some(search_path) = search_path {
106 dirs.extend(std::env::split_paths(search_path).filter(|dir| !dir.as_os_str().is_empty()));
107 }
108
109 dirs.into_iter().find_map(|dir| {
110 let mut candidate = dir.join(program);
111 if !has_extension {
112 candidate.set_extension("exe");
113 }
114 candidate.is_file().then_some(candidate)
115 })
116}
117
118#[cfg(windows)]
123fn windows_system_directories() -> Vec<PathBuf> {
124 use std::ffi::OsString;
125 use std::os::windows::ffi::OsStringExt;
126 use windows_sys::Win32::System::SystemInformation::{GetSystemDirectoryW, GetWindowsDirectoryW};
127
128 let query = |get: unsafe extern "system" fn(*mut u16, u32) -> u32| -> Option<PathBuf> {
131 let mut buf = vec![0u16; 260];
132 loop {
133 let len = unsafe { get(buf.as_mut_ptr(), u32::try_from(buf.len()).ok()?) } as usize;
136 if len == 0 {
137 return None;
138 }
139 if len < buf.len() {
140 buf.truncate(len);
141 return Some(PathBuf::from(OsString::from_wide(&buf)));
142 }
143 buf.resize(len, 0);
144 }
145 };
146 [GetSystemDirectoryW, GetWindowsDirectoryW]
147 .into_iter()
148 .filter_map(query)
149 .collect()
150}
151
152#[cfg(not(any(unix, windows)))]
153fn resolve_for_platform(_program: &OsStr, _search_path: Option<&OsStr>) -> Option<PathBuf> {
154 None
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 #[cfg(unix)]
162 use std::ffi::OsString;
163
164 #[cfg(unix)]
165 fn write_tool(dir: &Path, name: &str, executable: bool) -> PathBuf {
166 use std::os::unix::fs::PermissionsExt;
167
168 let path = dir.join(name);
169 std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
170 let mode = if executable { 0o755 } else { 0o644 };
171 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
172 path
173 }
174
175 #[test]
176 fn empty_program_name_never_resolves() {
177 assert_eq!(resolve_program(OsStr::new(""), Some(OsStr::new("/usr/bin"))), None);
178 }
179
180 #[cfg(unix)]
181 #[test]
182 fn tool_on_a_path_without_which_resolves() {
183 let dir = tempfile::tempdir().unwrap();
187 let tool = write_tool(dir.path(), "shellcheck", true);
188 assert_eq!(
189 resolve_program(OsStr::new("shellcheck"), Some(dir.path().as_os_str())),
190 Some(tool)
191 );
192 }
193
194 #[cfg(unix)]
195 #[test]
196 fn file_without_execute_bit_does_not_resolve() {
197 let dir = tempfile::tempdir().unwrap();
200 write_tool(dir.path(), "shellcheck", false);
201 assert_eq!(
202 resolve_program(OsStr::new("shellcheck"), Some(dir.path().as_os_str())),
203 None
204 );
205 }
206
207 #[cfg(unix)]
208 #[test]
209 fn absent_tool_does_not_resolve() {
210 let dir = tempfile::tempdir().unwrap();
211 write_tool(dir.path(), "shellcheck", true);
212 assert_eq!(resolve_program(OsStr::new("shfmt"), Some(dir.path().as_os_str())), None);
213 }
214
215 #[cfg(unix)]
216 #[test]
217 fn directory_named_like_the_tool_does_not_resolve() {
218 let dir = tempfile::tempdir().unwrap();
219 std::fs::create_dir(dir.path().join("shellcheck")).unwrap();
220 assert_eq!(
221 resolve_program(OsStr::new("shellcheck"), Some(dir.path().as_os_str())),
222 None
223 );
224 }
225
226 #[cfg(unix)]
227 #[test]
228 fn first_path_entry_wins() {
229 let first = tempfile::tempdir().unwrap();
230 let second = tempfile::tempdir().unwrap();
231 let expected = write_tool(first.path(), "ruff", true);
232 write_tool(second.path(), "ruff", true);
233 let path = std::env::join_paths([first.path(), second.path()]).unwrap();
234 assert_eq!(resolve_program(OsStr::new("ruff"), Some(&path)), Some(expected));
235 }
236
237 #[cfg(unix)]
238 #[test]
239 fn a_name_with_a_slash_is_a_path_and_is_not_searched() {
240 let on_path = tempfile::tempdir().unwrap();
244 write_tool(on_path.path(), "tool", true);
245 let elsewhere = tempfile::tempdir().unwrap();
246 let absolute = write_tool(elsewhere.path(), "tool", true);
247
248 assert_eq!(
249 resolve_program(absolute.as_os_str(), Some(on_path.path().as_os_str())),
250 Some(absolute.clone())
251 );
252 let missing: OsString = elsewhere.path().join("bin").join("tool").into();
253 assert_eq!(resolve_program(&missing, Some(on_path.path().as_os_str())), None);
254 }
255
256 #[cfg(unix)]
257 #[test]
258 fn a_path_to_a_non_executable_file_does_not_resolve() {
259 let dir = tempfile::tempdir().unwrap();
260 let plain = write_tool(dir.path(), "tool", false);
261 assert_eq!(resolve_program(plain.as_os_str(), None), None);
262 }
263
264 #[cfg(unix)]
265 #[test]
266 fn unset_path_falls_back_to_the_libc_default() {
267 assert!(resolve_program(OsStr::new("sh"), None).is_some());
269 assert_eq!(resolve_program(OsStr::new("rumdl-no-such-tool-820"), None), None);
270 }
271
272 #[cfg(windows)]
273 #[test]
274 fn bare_name_resolves_to_exe_on_path() {
275 let dir = tempfile::tempdir().unwrap();
276 let exe = dir.path().join("shellcheck.exe");
277 std::fs::write(&exe, b"").unwrap();
278 assert_eq!(
279 resolve_program(OsStr::new("shellcheck"), Some(dir.path().as_os_str())),
280 Some(exe.clone())
281 );
282 assert_eq!(
283 resolve_program(OsStr::new("shellcheck.exe"), Some(dir.path().as_os_str())),
284 Some(exe)
285 );
286 }
287
288 #[cfg(windows)]
289 #[test]
290 fn cmd_shim_is_not_found_under_the_bare_name() {
291 let dir = tempfile::tempdir().unwrap();
294 std::fs::write(dir.path().join("tool.cmd"), b"").unwrap();
295 assert_eq!(resolve_program(OsStr::new("tool"), Some(dir.path().as_os_str())), None);
296 let shim = dir.path().join("tool.cmd");
297 assert_eq!(resolve_program(shim.as_os_str(), None), Some(shim));
298 }
299
300 #[cfg(windows)]
301 #[test]
302 fn absent_tool_does_not_resolve() {
303 let dir = tempfile::tempdir().unwrap();
304 std::fs::write(dir.path().join("shellcheck.exe"), b"").unwrap();
305 assert_eq!(resolve_program(OsStr::new("shfmt"), Some(dir.path().as_os_str())), None);
306 }
307
308 #[cfg(windows)]
309 #[test]
310 fn any_dot_in_a_bare_name_is_its_extension() {
311 let dir = tempfile::tempdir().unwrap();
315 let as_written = dir.path().join("tool.v2");
316 std::fs::write(&as_written, b"").unwrap();
317 assert_eq!(
318 resolve_program(OsStr::new("tool.v2"), Some(dir.path().as_os_str())),
319 Some(as_written)
320 );
321
322 let other = tempfile::tempdir().unwrap();
323 std::fs::write(other.path().join("tool.v2.exe"), b"").unwrap();
324 assert_eq!(
325 resolve_program(OsStr::new("tool.v2"), Some(other.path().as_os_str())),
326 None
327 );
328 }
329
330 #[cfg(windows)]
331 #[test]
332 fn a_sub_path_tries_exe_appended_and_then_the_name_as_written() {
333 let dir = tempfile::tempdir().unwrap();
334 let bare = dir.path().join("tool");
335 let exe = dir.path().join("tool.exe");
336 std::fs::write(&bare, b"").unwrap();
337 std::fs::write(&exe, b"").unwrap();
338 assert_eq!(resolve_program(bare.as_os_str(), None), Some(exe.clone()));
339 std::fs::remove_file(&exe).unwrap();
340 assert_eq!(resolve_program(bare.as_os_str(), None), Some(bare));
341
342 let shim = dir.path().join("tool.cmd");
345 let shim_exe = dir.path().join("tool.cmd.exe");
346 std::fs::write(&shim, b"").unwrap();
347 std::fs::write(&shim_exe, b"").unwrap();
348 assert_eq!(resolve_program(shim.as_os_str(), None), Some(shim_exe));
349
350 let missing = dir.path().join("absent.exe");
353 assert_eq!(resolve_program(missing.as_os_str(), None), None);
354 let trailing = dir.path().join("tool\\");
355 assert_eq!(resolve_program(trailing.as_os_str(), None), None);
356 }
357
358 #[cfg(windows)]
359 #[test]
360 fn the_system_directory_is_searched_before_path() {
361 let system32 = windows_system_directories().into_iter().next().unwrap();
365 assert!(system32.join("cmd.exe").is_file(), "{system32:?} lacks cmd.exe");
366 let dir = tempfile::tempdir().unwrap();
367 std::fs::write(dir.path().join("cmd.exe"), b"").unwrap();
368 assert_eq!(
369 resolve_program(OsStr::new("cmd"), Some(dir.path().as_os_str())),
370 Some(system32.join("cmd.exe"))
371 );
372 let empty = tempfile::tempdir().unwrap();
373 assert_eq!(
374 resolve_program(OsStr::new("cmd"), Some(empty.path().as_os_str())),
375 Some(system32.join("cmd.exe"))
376 );
377 }
378
379 #[cfg(windows)]
380 #[test]
381 fn the_working_directory_is_not_searched() {
382 let dir = tempfile::tempdir().unwrap();
386 let tool = dir.path().join("rumdl-lookup-probe.exe");
387 std::fs::write(&tool, b"").unwrap();
388 let elsewhere = tempfile::tempdir().unwrap();
389 let previous = std::env::current_dir().unwrap();
390 std::env::set_current_dir(dir.path()).unwrap();
391 let from_cwd = resolve_program(OsStr::new("rumdl-lookup-probe"), Some(elsewhere.path().as_os_str()));
392 let from_path = resolve_program(OsStr::new("rumdl-lookup-probe"), Some(dir.path().as_os_str()));
393 std::env::set_current_dir(previous).unwrap();
394 assert_eq!(from_cwd, None);
395 assert_eq!(from_path, Some(tool));
396 }
397}