Skip to main content

nils_common/
process.rs

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    // Treat both separators as paths, even on unix. It is harmless and avoids surprises when a
269    // caller passes a Windows-style path.
270    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}