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