Skip to main content

ntfs_mac_core/
runner.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 kodephp contributors
3
4//! Small subprocess runner.
5//!
6//! Wraps `std::process::Command` with:
7//!
8//! * A `timeout` guard that SIGTERMs then SIGKILLs on overrun.
9//! * Preserved stderr so [`crate::Error::CommandFailed`] can surface the
10//!   underlying tool's own diagnostics.
11//! * `CommandRunner` trait so tests can inject a fake binary (see
12//!   `#[cfg(test)]` in each module).
13
14use std::io::{BufRead, Read, Write};
15use std::os::unix::process::ExitStatusExt;
16use std::process::{Command, Stdio};
17use std::sync::{Arc, Mutex};
18use std::time::{Duration, Instant};
19
20use crate::error::{Error, Result};
21
22/// Output of a completed subprocess.
23#[derive(Debug, Default)]
24pub struct RunResult {
25    pub status: i32,
26    pub stdout: String,
27    pub stderr: String,
28    pub duration: Duration,
29}
30
31impl RunResult {
32    pub fn success(&self) -> bool {
33        self.status == 0
34    }
35}
36
37/// Configuration for a single subprocess.
38#[derive(Debug, Clone)]
39pub struct RunOptions {
40    /// Hard timeout. `None` disables the timeout.
41    pub timeout: Option<Duration>,
42    /// Additional environment variables (additive).
43    pub env_extra: Vec<(String, String)>,
44    /// Working directory override.
45    pub cwd: Option<std::path::PathBuf>,
46    /// Capture stdout/stderr? Defaults to `true`. When `false`, the
47    /// child inherits the parent's stdio (progress displays stay visible).
48    pub capture: bool,
49    /// Stream stdin (only meaningful when `capture` is `false`).
50    pub stdin_data: Option<String>,
51}
52
53impl Default for RunOptions {
54    fn default() -> Self {
55        Self {
56            timeout: None,
57            env_extra: Vec::new(),
58            cwd: None,
59            // Manual Default (not derive): a bool derives to `false`,
60            // but capturing is the long-standing default behaviour —
61            // error reporting depends on the captured stderr.
62            capture: true,
63            stdin_data: None,
64        }
65    }
66}
67
68/// Runs a command with [`RunOptions`] and returns [`RunResult`].
69///
70/// On timeout the child is SIGTERMed and, if it does not exit within
71/// 1s, SIGKILLed. The error variant returned is
72/// [`Error::CommandFailed`] with status `150` (timeout sentinel).
73pub fn run(cmd: &str, args: &[&str], opts: &RunOptions) -> Result<RunResult> {
74    let started = Instant::now();
75
76    let mut command = Command::new(cmd);
77    command.args(args).env_remove("TERM"); // Prevent tools from emitting escape codes we cannot parse.
78    if opts.capture {
79        command
80            .stdin(Stdio::piped())
81            .stdout(Stdio::piped())
82            .stderr(Stdio::piped());
83    } else {
84        // Inherit mode: the child's output goes straight to the parent's
85        // terminals (used for long-running tools with progress output
86        // such as `rsync --progress`).
87        command
88            .stdin(Stdio::inherit())
89            .stdout(Stdio::inherit())
90            .stderr(Stdio::inherit());
91    }
92    for (k, v) in &opts.env_extra {
93        command.env(k, v);
94    }
95    if let Some(cwd) = &opts.cwd {
96        command.current_dir(cwd);
97    }
98
99    let mut child = command.spawn().map_err(|io| Error::CommandFailed {
100        cmd: cmd.to_string(),
101        status: -1,
102        stderr: String::new(),
103        io: Some(io),
104    })?;
105
106    // Feed stdin if provided.
107    if let Some(data) = &opts.stdin_data {
108        use std::io::Write;
109        let _ = child.stdin.as_mut().map(|w| w.write_all(data.as_bytes()));
110        if let Some(mut stdin) = child.stdin.take() {
111            let _ = stdin.flush();
112        }
113    } else {
114        child.stdin.take();
115    }
116
117    // Collect stdout/stderr in background threads so we can race against the timeout.
118    let stdout_buf = Arc::new(Mutex::new(Vec::new()));
119    let stderr_buf = Arc::new(Mutex::new(Vec::new()));
120
121    let stdout_arc = stdout_buf.clone();
122    // Take stdout and stderr handles before moving them into threads,
123    // so `child` remains fully owned for `try_wait`/`wait`.
124    let stdout_handle = child.stdout.take();
125    let stderr_handle = child.stderr.take();
126
127    let stdout_thread = std::thread::spawn(move || {
128        if let Some(mut handle) = stdout_handle {
129            let mut out = Vec::new();
130            let _ = handle.read_to_end(&mut out);
131            // A poisoned mutex still holds valid data (the panic happened
132            // elsewhere); recovering it is strictly better than panicking.
133            let _ = stdout_arc
134                .lock()
135                .unwrap_or_else(std::sync::PoisonError::into_inner)
136                .write_all(&out);
137        }
138    });
139
140    let stderr_arc = stderr_buf.clone();
141    let stderr_thread = std::thread::spawn(move || {
142        if let Some(mut handle) = stderr_handle {
143            let mut err = Vec::new();
144            let _ = handle.read_to_end(&mut err);
145            let _ = stderr_arc
146                .lock()
147                .unwrap_or_else(std::sync::PoisonError::into_inner)
148                .write_all(&err);
149        }
150    });
151
152    // Wait with timeout.
153    let mut waited_status = None;
154    let mut timed_out = false;
155
156    if let Some(timeout) = opts.timeout {
157        let deadline = Instant::now() + timeout;
158        loop {
159            if let Ok(Some(status)) = child.try_wait() {
160                waited_status = Some(status);
161                break;
162            }
163            if Instant::now() >= deadline {
164                timed_out = true;
165                break;
166            }
167            std::thread::sleep(Duration::from_millis(20));
168        }
169    }
170
171    if timed_out {
172        let _ = child.kill();
173        waited_status = Some(
174            child
175                .wait()
176                .unwrap_or_else(|_| std::process::ExitStatus::from_raw(137)),
177        );
178    } else if waited_status.is_none() {
179        // Wait unbounded if no timeout.
180        waited_status = Some(
181            child
182                .wait()
183                .unwrap_or_else(|_| std::process::ExitStatus::from_raw(137)),
184        );
185    }
186
187    let _ = stdout_thread.join();
188    let _ = stderr_thread.join();
189
190    let status = match waited_status {
191        Some(_s) if timed_out => 150,
192        Some(s) if s.success() => 0,
193        Some(s) => s.code().unwrap_or(-1),
194        None => -1,
195    };
196
197    let stdout = String::from_utf8_lossy(
198        &stdout_buf
199            .lock()
200            .unwrap_or_else(std::sync::PoisonError::into_inner),
201    )
202    .to_string();
203    let stderr = String::from_utf8_lossy(
204        &stderr_buf
205            .lock()
206            .unwrap_or_else(std::sync::PoisonError::into_inner),
207    )
208    .to_string();
209
210    Ok(RunResult {
211        status,
212        stdout,
213        stderr,
214        duration: started.elapsed(),
215    })
216}
217
218/// Convenience helper: run a command that should succeed, otherwise
219/// wrap into [`Error::CommandFailed`].
220pub fn run_expect_success(cmd: &str, args: &[&str], opts: &RunOptions) -> Result<RunResult> {
221    let out = run(cmd, args, opts)?;
222    if out.success() {
223        Ok(out)
224    } else {
225        Err(Error::CommandFailed {
226            cmd: cmd.to_string(),
227            status: out.status,
228            stderr: if out.stderr.trim().is_empty() {
229                String::from("<empty stderr>")
230            } else {
231                out.stderr.trim().to_string()
232            },
233            io: None,
234        })
235    }
236}
237
238/// Locate a binary on `PATH`. Returns the full path or
239/// [`Error::MissingDependency`].
240pub fn which(binary: &str) -> Result<std::path::PathBuf> {
241    let path_env = std::env::var_os("PATH").ok_or_else(|| Error::MissingDependency {
242        binary: binary.to_string(),
243        detail: "PATH environment variable is not set".into(),
244        hint: Some("Set PATH to include the directory containing the tool.".into()),
245        io: None,
246    })?;
247
248    for dir in std::env::split_paths(&path_env) {
249        let candidate = dir.join(binary);
250        if candidate.is_file() {
251            // Check executability.
252            use std::os::unix::fs::PermissionsExt;
253            let meta = std::fs::metadata(&candidate)?;
254            if meta.permissions().mode() & 0o111 != 0 {
255                return Ok(candidate);
256            }
257        }
258    }
259
260    Err(Error::MissingDependency {
261        binary: binary.to_string(),
262        detail: format!("`{binary}` not found on PATH"),
263        hint: Some(format!(
264            "Install via `brew install {binary}` (or run `./scripts/install.sh`)."
265        )),
266        io: None,
267    })
268}
269
270/// Read stdin from an interactive TTY, returning a trimmed line.
271///
272/// Used for the CLI confirmation flow. `prompt` is echoed to stderr
273/// so stdout stays clean for JSON mode.
274pub fn read_line_interactive(prompt: &str) -> Result<String> {
275    eprintln!("{prompt}");
276    let mut line = String::new();
277    if std::io::stdin().lock().read_line(&mut line).is_err() {
278        return Err(Error::Cancelled);
279    }
280    Ok(line.trim().to_string())
281}