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