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 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}