Skip to main content

ntfs_mac_core/
runner.rs

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