1use std::ffi::{OsStr, OsString};
2use std::fmt;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::process::{Command, ExitStatus, Output, Stdio};
6
7#[derive(Debug)]
8pub struct ProcessOutput {
9 pub status: ExitStatus,
10 pub stdout: Vec<u8>,
11 pub stderr: Vec<u8>,
12}
13
14impl ProcessOutput {
15 pub fn into_std_output(self) -> Output {
16 Output {
17 status: self.status,
18 stdout: self.stdout,
19 stderr: self.stderr,
20 }
21 }
22
23 pub fn stdout_lossy(&self) -> String {
24 String::from_utf8_lossy(&self.stdout).to_string()
25 }
26
27 pub fn stderr_lossy(&self) -> String {
28 String::from_utf8_lossy(&self.stderr).to_string()
29 }
30
31 pub fn stdout_trimmed(&self) -> String {
32 self.stdout_lossy().trim().to_string()
33 }
34}
35
36impl From<Output> for ProcessOutput {
37 fn from(output: Output) -> Self {
38 Self {
39 status: output.status,
40 stdout: output.stdout,
41 stderr: output.stderr,
42 }
43 }
44}
45
46#[derive(Debug)]
47pub enum ProcessError {
48 Io(io::Error),
49 NonZero(ProcessOutput),
50}
51
52impl fmt::Display for ProcessError {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 match self {
55 Self::Io(err) => write!(f, "{err}"),
56 Self::NonZero(output) => write!(f, "process exited with status {}", output.status),
57 }
58 }
59}
60
61impl std::error::Error for ProcessError {
62 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
63 match self {
64 Self::Io(err) => Some(err),
65 Self::NonZero(_) => None,
66 }
67 }
68}
69
70impl From<io::Error> for ProcessError {
71 fn from(err: io::Error) -> Self {
72 Self::Io(err)
73 }
74}
75
76pub fn run_output(program: &str, args: &[&str]) -> io::Result<ProcessOutput> {
77 Command::new(program)
78 .args(args)
79 .stdout(Stdio::piped())
80 .stderr(Stdio::piped())
81 .output()
82 .map(ProcessOutput::from)
83}
84
85pub fn run_checked(program: &str, args: &[&str]) -> Result<ProcessOutput, ProcessError> {
86 let output = run_output(program, args)?;
87 if output.status.success() {
88 Ok(output)
89 } else {
90 Err(ProcessError::NonZero(output))
91 }
92}
93
94pub fn run_stdout(program: &str, args: &[&str]) -> Result<String, ProcessError> {
95 let output = run_checked(program, args)?;
96 Ok(output.stdout_lossy())
97}
98
99pub fn run_stdout_trimmed(program: &str, args: &[&str]) -> Result<String, ProcessError> {
100 let output = run_checked(program, args)?;
101 Ok(output.stdout_trimmed())
102}
103
104pub fn run_status_quiet(program: &str, args: &[&str]) -> io::Result<ExitStatus> {
105 Command::new(program)
106 .args(args)
107 .stdout(Stdio::null())
108 .stderr(Stdio::null())
109 .status()
110}
111
112pub fn run_status_inherit(program: &str, args: &[&str]) -> io::Result<ExitStatus> {
113 Command::new(program)
114 .args(args)
115 .stdout(Stdio::inherit())
116 .stderr(Stdio::inherit())
117 .status()
118}
119
120pub fn cmd_exists(program: &str) -> bool {
121 find_in_path(program).is_some()
122}
123
124pub fn browser_open_command() -> Option<&'static str> {
125 if cmd_exists("open") {
126 Some("open")
127 } else if cmd_exists("xdg-open") {
128 Some("xdg-open")
129 } else {
130 None
131 }
132}
133
134pub fn is_headless_browser_launch_failure(stdout: &[u8], stderr: &[u8]) -> bool {
135 let mut message = String::from_utf8_lossy(stderr).to_ascii_lowercase();
136 if !stdout.is_empty() {
137 message.push('\n');
138 message.push_str(&String::from_utf8_lossy(stdout).to_ascii_lowercase());
139 }
140
141 if message.contains("no method available for opening")
142 || message.contains("couldn't find a suitable web browser")
143 {
144 return true;
145 }
146
147 message.contains("not found")
148 && ["www-browser", "links2", "elinks", "links", "lynx", "w3m"]
149 .iter()
150 .any(|candidate| message.contains(candidate))
151}
152
153pub fn find_in_path(program: &str) -> Option<PathBuf> {
154 if looks_like_path(program) {
155 let p = PathBuf::from(program);
156 return is_executable_file(&p).then_some(p);
157 }
158
159 let path_var: OsString = std::env::var_os("PATH")?;
160 let windows_extensions = if cfg!(windows) {
161 Some(windows_pathext_extensions())
162 } else {
163 None
164 };
165
166 for dir in std::env::split_paths(&path_var) {
167 for candidate in path_lookup_candidates(&dir, program, windows_extensions.as_deref()) {
168 if is_executable_file(&candidate) {
169 return Some(candidate);
170 }
171 }
172 }
173 None
174}
175
176fn path_lookup_candidates(
177 dir: &Path,
178 program: &str,
179 windows_extensions: Option<&[OsString]>,
180) -> Vec<PathBuf> {
181 let mut candidates = vec![dir.join(program)];
182
183 if let Some(windows_extensions) = windows_extensions
184 && Path::new(program).extension().is_none()
185 {
186 for extension in windows_extensions {
187 let mut file_name = OsString::from(program);
188 file_name.push(extension);
189 candidates.push(dir.join(file_name));
190 }
191 }
192
193 candidates
194}
195
196fn windows_pathext_extensions() -> Vec<OsString> {
197 let raw = std::env::var_os("PATHEXT")
198 .unwrap_or_else(|| OsString::from(".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH"));
199 parse_windows_extensions(raw.as_os_str())
200}
201
202fn parse_windows_extensions(raw: &OsStr) -> Vec<OsString> {
203 let mut extensions = Vec::new();
204 let mut seen_lowercase = Vec::new();
205
206 for segment in raw.to_string_lossy().split(';') {
207 let segment = segment.trim();
208 if segment.is_empty() {
209 continue;
210 }
211
212 let normalized = if segment.starts_with('.') {
213 segment.to_string()
214 } else {
215 format!(".{segment}")
216 };
217 let lowercase = normalized.to_ascii_lowercase();
218 if seen_lowercase.iter().any(|existing| existing == &lowercase) {
219 continue;
220 }
221
222 seen_lowercase.push(lowercase);
223 extensions.push(OsString::from(normalized));
224 }
225
226 extensions
227}
228
229fn looks_like_path(program: &str) -> bool {
230 program.contains('/') || program.contains('\\')
233}
234
235fn is_executable_file(path: &Path) -> bool {
236 let Ok(meta) = std::fs::metadata(path) else {
237 return false;
238 };
239 if !meta.is_file() {
240 return false;
241 }
242 #[cfg(unix)]
243 {
244 use std::os::unix::fs::PermissionsExt;
245 meta.permissions().mode() & 0o111 != 0
246 }
247 #[cfg(not(unix))]
248 {
249 true
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use nils_test_support::{EnvGuard, GlobalStateLock, StubBinDir, prepend_path};
257 use std::fs;
258
259 #[cfg(unix)]
260 fn shell_program() -> &'static str {
261 "/bin/sh"
262 }
263
264 #[test]
265 fn find_in_path_with_explicit_missing_path_returns_none() {
266 let dir = tempfile::TempDir::new().expect("tempdir");
267 let path = dir.path().join("missing");
268
269 let found = find_in_path(path.to_string_lossy().as_ref());
270
271 assert!(found.is_none());
272 }
273
274 #[cfg(unix)]
275 #[test]
276 fn find_in_path_with_non_executable_file_returns_none() {
277 use std::os::unix::fs::PermissionsExt;
278
279 let dir = tempfile::TempDir::new().expect("tempdir");
280 let path = dir.path().join("file");
281 fs::write(&path, "data").expect("write file");
282
283 let mut perms = fs::metadata(&path).expect("metadata").permissions();
284 perms.set_mode(0o644);
285 fs::set_permissions(&path, perms).expect("set permissions");
286
287 let found = find_in_path(path.to_string_lossy().as_ref());
288
289 assert!(found.is_none());
290 }
291
292 #[cfg(unix)]
293 #[test]
294 fn find_in_path_with_executable_file_returns_path() {
295 use std::os::unix::fs::PermissionsExt;
296
297 let dir = tempfile::TempDir::new().expect("tempdir");
298 let path = dir.path().join("exec");
299 fs::write(&path, "data").expect("write file");
300
301 let mut perms = fs::metadata(&path).expect("metadata").permissions();
302 perms.set_mode(0o755);
303 fs::set_permissions(&path, perms).expect("set permissions");
304
305 let found = find_in_path(path.to_string_lossy().as_ref());
306
307 assert_eq!(found, Some(path));
308 }
309
310 #[test]
311 fn find_in_path_resolves_from_path_env() {
312 let lock = GlobalStateLock::new();
313 let stub = StubBinDir::new();
314 stub.write_exe("hello-stub", "#!/bin/sh\necho hi\n");
315
316 let _path_guard = prepend_path(&lock, stub.path());
317
318 let found = find_in_path("hello-stub").expect("found");
319 assert!(found.ends_with("hello-stub"));
320 }
321
322 #[test]
323 fn parse_windows_extensions_normalizes_and_deduplicates_entries() {
324 let parsed = parse_windows_extensions(OsStr::new("EXE; .Cmd ; ; .BAT ;.exe"));
325 assert_eq!(
326 parsed,
327 vec![
328 OsString::from(".EXE"),
329 OsString::from(".Cmd"),
330 OsString::from(".BAT"),
331 ]
332 );
333 }
334
335 #[test]
336 fn path_lookup_candidates_adds_windows_extensions_for_extensionless_program() {
337 let dir = Path::new("/tmp/path-candidates");
338 let windows_extensions = vec![OsString::from(".EXE"), OsString::from(".CMD")];
339
340 let candidates = path_lookup_candidates(dir, "git", Some(windows_extensions.as_slice()));
341
342 assert_eq!(
343 candidates,
344 vec![dir.join("git"), dir.join("git.EXE"), dir.join("git.CMD"),]
345 );
346 }
347
348 #[test]
349 fn path_lookup_candidates_skips_windows_extensions_when_program_already_has_extension() {
350 let dir = Path::new("/tmp/path-candidates");
351 let windows_extensions = vec![OsString::from(".EXE"), OsString::from(".CMD")];
352
353 let candidates =
354 path_lookup_candidates(dir, "git.exe", Some(windows_extensions.as_slice()));
355
356 assert_eq!(candidates, vec![dir.join("git.exe")]);
357 }
358
359 #[cfg(unix)]
360 #[test]
361 fn run_output_returns_output_for_nonzero_status() {
362 let output = run_output(
363 shell_program(),
364 &["-c", "printf 'oops' 1>&2; printf 'out'; exit 2"],
365 )
366 .expect("run output");
367
368 assert!(!output.status.success());
369 assert_eq!(output.stdout_lossy(), "out");
370 assert_eq!(output.stderr_lossy(), "oops");
371 }
372
373 #[cfg(unix)]
374 #[test]
375 fn run_checked_returns_nonzero_error_with_captured_output() {
376 let err = run_checked(
377 shell_program(),
378 &["-c", "printf 'e' 1>&2; printf 'o'; exit 7"],
379 )
380 .expect_err("expected nonzero error");
381
382 match err {
383 ProcessError::Io(_) => panic!("expected nonzero error"),
384 ProcessError::NonZero(output) => {
385 assert_eq!(output.stdout_lossy(), "o");
386 assert_eq!(output.stderr_lossy(), "e");
387 assert!(!output.status.success());
388 }
389 }
390 }
391
392 #[cfg(unix)]
393 #[test]
394 fn run_stdout_trimmed_trims_trailing_whitespace() {
395 let stdout =
396 run_stdout_trimmed(shell_program(), &["-c", "printf ' hello \\n\\n'"]).expect("stdout");
397
398 assert_eq!(stdout, "hello");
399 }
400
401 #[cfg(unix)]
402 #[test]
403 fn run_status_helpers_keep_stdio_contracts() {
404 let quiet = run_status_quiet(shell_program(), &["-c", "exit 0"]).expect("quiet status");
405 assert!(quiet.success());
406
407 let inherit =
408 run_status_inherit(shell_program(), &["-c", "exit 3"]).expect("inherit status");
409 assert_eq!(inherit.code(), Some(3));
410 }
411
412 #[test]
413 fn browser_open_command_prefers_open_then_xdg_open() {
414 let lock = GlobalStateLock::new();
415
416 let both = StubBinDir::new();
417 both.write_exe("open", "#!/bin/sh\nexit 0\n");
418 both.write_exe("xdg-open", "#!/bin/sh\nexit 0\n");
419 let _both_path_guard = EnvGuard::set(&lock, "PATH", &both.path_str());
420 assert_eq!(browser_open_command(), Some("open"));
421 drop(_both_path_guard);
422
423 let xdg_only = StubBinDir::new();
424 xdg_only.write_exe("xdg-open", "#!/bin/sh\nexit 0\n");
425 let _xdg_path_guard = EnvGuard::set(&lock, "PATH", &xdg_only.path_str());
426 assert_eq!(browser_open_command(), Some("xdg-open"));
427 drop(_xdg_path_guard);
428
429 let empty = tempfile::TempDir::new().expect("tempdir");
430 let empty_path = empty.path().to_string_lossy().to_string();
431 let _empty_path_guard = EnvGuard::set(&lock, "PATH", &empty_path);
432 assert_eq!(browser_open_command(), None);
433 }
434
435 #[test]
436 fn headless_browser_launch_failure_detection_matches_xdg_open_signals() {
437 let stderr =
438 b"/usr/bin/open: 882: www-browser: not found\nxdg-open: no method available for opening 'https://example.com'\n";
439 assert!(is_headless_browser_launch_failure(&[], stderr));
440 }
441
442 #[test]
443 fn headless_browser_launch_failure_detection_does_not_mask_other_errors() {
444 assert!(!is_headless_browser_launch_failure(
445 &[],
446 b"open: permission denied\n"
447 ));
448 }
449}