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 find_in_path(program: &str) -> Option<PathBuf> {
125    if looks_like_path(program) {
126        let p = PathBuf::from(program);
127        return is_executable_file(&p).then_some(p);
128    }
129
130    let path_var: OsString = std::env::var_os("PATH")?;
131    let windows_extensions = if cfg!(windows) {
132        Some(windows_pathext_extensions())
133    } else {
134        None
135    };
136
137    for dir in std::env::split_paths(&path_var) {
138        for candidate in path_lookup_candidates(&dir, program, windows_extensions.as_deref()) {
139            if is_executable_file(&candidate) {
140                return Some(candidate);
141            }
142        }
143    }
144    None
145}
146
147fn path_lookup_candidates(
148    dir: &Path,
149    program: &str,
150    windows_extensions: Option<&[OsString]>,
151) -> Vec<PathBuf> {
152    let mut candidates = vec![dir.join(program)];
153
154    if let Some(windows_extensions) = windows_extensions
155        && Path::new(program).extension().is_none()
156    {
157        for extension in windows_extensions {
158            let mut file_name = OsString::from(program);
159            file_name.push(extension);
160            candidates.push(dir.join(file_name));
161        }
162    }
163
164    candidates
165}
166
167fn windows_pathext_extensions() -> Vec<OsString> {
168    let raw = std::env::var_os("PATHEXT")
169        .unwrap_or_else(|| OsString::from(".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH"));
170    parse_windows_extensions(raw.as_os_str())
171}
172
173fn parse_windows_extensions(raw: &OsStr) -> Vec<OsString> {
174    let mut extensions = Vec::new();
175    let mut seen_lowercase = Vec::new();
176
177    for segment in raw.to_string_lossy().split(';') {
178        let segment = segment.trim();
179        if segment.is_empty() {
180            continue;
181        }
182
183        let normalized = if segment.starts_with('.') {
184            segment.to_string()
185        } else {
186            format!(".{segment}")
187        };
188        let lowercase = normalized.to_ascii_lowercase();
189        if seen_lowercase.iter().any(|existing| existing == &lowercase) {
190            continue;
191        }
192
193        seen_lowercase.push(lowercase);
194        extensions.push(OsString::from(normalized));
195    }
196
197    extensions
198}
199
200fn looks_like_path(program: &str) -> bool {
201    // Treat both separators as paths, even on unix. It is harmless and avoids surprises when a
202    // caller passes a Windows-style path.
203    program.contains('/') || program.contains('\\')
204}
205
206fn is_executable_file(path: &Path) -> bool {
207    let Ok(meta) = std::fs::metadata(path) else {
208        return false;
209    };
210    if !meta.is_file() {
211        return false;
212    }
213    #[cfg(unix)]
214    {
215        use std::os::unix::fs::PermissionsExt;
216        meta.permissions().mode() & 0o111 != 0
217    }
218    #[cfg(not(unix))]
219    {
220        true
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use nils_test_support::{GlobalStateLock, StubBinDir, prepend_path};
228    use std::fs;
229
230    #[test]
231    fn find_in_path_with_explicit_missing_path_returns_none() {
232        let dir = tempfile::TempDir::new().expect("tempdir");
233        let path = dir.path().join("missing");
234
235        let found = find_in_path(path.to_string_lossy().as_ref());
236
237        assert!(found.is_none());
238    }
239
240    #[cfg(unix)]
241    #[test]
242    fn find_in_path_with_non_executable_file_returns_none() {
243        use std::os::unix::fs::PermissionsExt;
244
245        let dir = tempfile::TempDir::new().expect("tempdir");
246        let path = dir.path().join("file");
247        fs::write(&path, "data").expect("write file");
248
249        let mut perms = fs::metadata(&path).expect("metadata").permissions();
250        perms.set_mode(0o644);
251        fs::set_permissions(&path, perms).expect("set permissions");
252
253        let found = find_in_path(path.to_string_lossy().as_ref());
254
255        assert!(found.is_none());
256    }
257
258    #[cfg(unix)]
259    #[test]
260    fn find_in_path_with_executable_file_returns_path() {
261        use std::os::unix::fs::PermissionsExt;
262
263        let dir = tempfile::TempDir::new().expect("tempdir");
264        let path = dir.path().join("exec");
265        fs::write(&path, "data").expect("write file");
266
267        let mut perms = fs::metadata(&path).expect("metadata").permissions();
268        perms.set_mode(0o755);
269        fs::set_permissions(&path, perms).expect("set permissions");
270
271        let found = find_in_path(path.to_string_lossy().as_ref());
272
273        assert_eq!(found, Some(path));
274    }
275
276    #[test]
277    fn find_in_path_resolves_from_path_env() {
278        let lock = GlobalStateLock::new();
279        let stub = StubBinDir::new();
280        stub.write_exe("hello-stub", "#!/bin/sh\necho hi\n");
281
282        let _path_guard = prepend_path(&lock, stub.path());
283
284        let found = find_in_path("hello-stub").expect("found");
285        assert!(found.ends_with("hello-stub"));
286    }
287
288    #[test]
289    fn parse_windows_extensions_normalizes_and_deduplicates_entries() {
290        let parsed = parse_windows_extensions(OsStr::new("EXE; .Cmd ; ; .BAT ;.exe"));
291        assert_eq!(
292            parsed,
293            vec![
294                OsString::from(".EXE"),
295                OsString::from(".Cmd"),
296                OsString::from(".BAT"),
297            ]
298        );
299    }
300
301    #[test]
302    fn path_lookup_candidates_adds_windows_extensions_for_extensionless_program() {
303        let dir = Path::new("/tmp/path-candidates");
304        let windows_extensions = vec![OsString::from(".EXE"), OsString::from(".CMD")];
305
306        let candidates = path_lookup_candidates(dir, "git", Some(windows_extensions.as_slice()));
307
308        assert_eq!(
309            candidates,
310            vec![dir.join("git"), dir.join("git.EXE"), dir.join("git.CMD"),]
311        );
312    }
313
314    #[test]
315    fn path_lookup_candidates_skips_windows_extensions_when_program_already_has_extension() {
316        let dir = Path::new("/tmp/path-candidates");
317        let windows_extensions = vec![OsString::from(".EXE"), OsString::from(".CMD")];
318
319        let candidates =
320            path_lookup_candidates(dir, "git.exe", Some(windows_extensions.as_slice()));
321
322        assert_eq!(candidates, vec![dir.join("git.exe")]);
323    }
324
325    #[cfg(unix)]
326    #[test]
327    fn run_output_returns_output_for_nonzero_status() {
328        let output = run_output("sh", &["-c", "printf 'oops' 1>&2; printf 'out'; exit 2"])
329            .expect("run output");
330
331        assert!(!output.status.success());
332        assert_eq!(output.stdout_lossy(), "out");
333        assert_eq!(output.stderr_lossy(), "oops");
334    }
335
336    #[cfg(unix)]
337    #[test]
338    fn run_checked_returns_nonzero_error_with_captured_output() {
339        let err = run_checked("sh", &["-c", "printf 'e' 1>&2; printf 'o'; exit 7"])
340            .expect_err("expected nonzero error");
341
342        match err {
343            ProcessError::Io(_) => panic!("expected nonzero error"),
344            ProcessError::NonZero(output) => {
345                assert_eq!(output.stdout_lossy(), "o");
346                assert_eq!(output.stderr_lossy(), "e");
347                assert!(!output.status.success());
348            }
349        }
350    }
351
352    #[cfg(unix)]
353    #[test]
354    fn run_stdout_trimmed_trims_trailing_whitespace() {
355        let stdout = run_stdout_trimmed("sh", &["-c", "printf ' hello \\n\\n'"]).expect("stdout");
356
357        assert_eq!(stdout, "hello");
358    }
359
360    #[cfg(unix)]
361    #[test]
362    fn run_status_helpers_keep_stdio_contracts() {
363        let quiet = run_status_quiet("sh", &["-c", "exit 0"]).expect("quiet status");
364        assert!(quiet.success());
365
366        let inherit = run_status_inherit("sh", &["-c", "exit 3"]).expect("inherit status");
367        assert_eq!(inherit.code(), Some(3));
368    }
369}