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 mut command = Command::new(cmd);
58    command
59        .args(args)
60        .stdin(Stdio::piped())
61        .stdout(Stdio::piped())
62        .stderr(Stdio::piped())
63        .env_remove("TERM"); // Prevent tools from emitting escape codes we cannot parse.
64    for (k, v) in &opts.env_extra {
65        command.env(k, v);
66    }
67    if let Some(cwd) = &opts.cwd {
68        command.current_dir(cwd);
69    }
70
71    let mut child = command.spawn().map_err(|io| Error::CommandFailed {
72        cmd: cmd.to_string(),
73        status: -1,
74        stderr: String::new(),
75        io: Some(io),
76    })?;
77
78    // Feed stdin if provided.
79    if let Some(data) = &opts.stdin_data {
80        use std::io::Write;
81        let _ = child.stdin.as_mut().map(|w| w.write_all(data.as_bytes()));
82        if let Some(mut stdin) = child.stdin.take() {
83            let _ = stdin.flush();
84        }
85    } else {
86        child.stdin.take();
87    }
88
89    // Collect stdout/stderr in background threads so we can race against the timeout.
90    let stdout_buf = Arc::new(Mutex::new(Vec::new()));
91    let stderr_buf = Arc::new(Mutex::new(Vec::new()));
92
93    let stdout_arc = stdout_buf.clone();
94    // Take stdout and stderr handles before moving them into threads,
95    // so `child` remains fully owned for `try_wait`/`wait`.
96    let stdout_handle = child.stdout.take();
97    let stderr_handle = child.stderr.take();
98
99    let stdout_thread = std::thread::spawn(move || {
100        if let Some(mut handle) = stdout_handle {
101            let mut out = Vec::new();
102            let _ = handle.read_to_end(&mut out);
103            let _ = stdout_arc.lock().unwrap().write_all(&out);
104        }
105    });
106
107    let stderr_arc = stderr_buf.clone();
108    let stderr_thread = std::thread::spawn(move || {
109        if let Some(mut handle) = stderr_handle {
110            let mut err = Vec::new();
111            let _ = handle.read_to_end(&mut err);
112            let _ = stderr_arc.lock().unwrap().write_all(&err);
113        }
114    });
115
116    // Wait with timeout.
117    let mut waited_status = None;
118    let mut timed_out = false;
119
120    if let Some(timeout) = opts.timeout {
121        let deadline = Instant::now() + timeout;
122        loop {
123            if let Ok(Some(status)) = child.try_wait() {
124                waited_status = Some(status);
125                break;
126            }
127            if Instant::now() >= deadline {
128                timed_out = true;
129                break;
130            }
131            std::thread::sleep(Duration::from_millis(20));
132        }
133    }
134
135    if timed_out {
136        let _ = child.kill();
137        waited_status = Some(
138            child
139                .wait()
140                .unwrap_or_else(|_| std::process::ExitStatus::from_raw(137)),
141        );
142    } else if waited_status.is_none() {
143        // Wait unbounded if no timeout.
144        waited_status = Some(
145            child
146                .wait()
147                .unwrap_or_else(|_| std::process::ExitStatus::from_raw(137)),
148        );
149    }
150
151    let _ = stdout_thread.join();
152    let _ = stderr_thread.join();
153
154    let status = match waited_status {
155        Some(_s) if timed_out => 150,
156        Some(s) if s.success() => 0,
157        Some(s) => s.code().unwrap_or(-1),
158        None => -1,
159    };
160
161    let stdout = String::from_utf8_lossy(&stdout_buf.lock().unwrap()).to_string();
162    let stderr = String::from_utf8_lossy(&stderr_buf.lock().unwrap()).to_string();
163
164    Ok(RunResult {
165        status,
166        stdout,
167        stderr,
168        duration: started.elapsed(),
169    })
170}
171
172/// Convenience helper: run a command that should succeed, otherwise
173/// wrap into [`Error::CommandFailed`].
174pub fn run_expect_success(cmd: &str, args: &[&str], opts: &RunOptions) -> Result<RunResult> {
175    let out = run(cmd, args, opts)?;
176    if out.success() {
177        Ok(out)
178    } else {
179        Err(Error::CommandFailed {
180            cmd: cmd.to_string(),
181            status: out.status,
182            stderr: if out.stderr.trim().is_empty() {
183                String::from("<empty stderr>")
184            } else {
185                out.stderr.trim().to_string()
186            },
187            io: None,
188        })
189    }
190}
191
192/// Locate a binary on `PATH`. Returns the full path or
193/// [`Error::MissingDependency`].
194pub fn which(binary: &str) -> Result<std::path::PathBuf> {
195    let path_env = std::env::var_os("PATH").ok_or_else(|| Error::MissingDependency {
196        binary: binary.to_string(),
197        detail: "PATH environment variable is not set".into(),
198        hint: Some("Set PATH to include the directory containing the tool.".into()),
199        io: None,
200    })?;
201
202    for dir in std::env::split_paths(&path_env) {
203        let candidate = dir.join(binary);
204        if candidate.is_file() {
205            // Check executability.
206            use std::os::unix::fs::PermissionsExt;
207            let meta = std::fs::metadata(&candidate)?;
208            if meta.permissions().mode() & 0o111 != 0 {
209                return Ok(candidate);
210            }
211        }
212    }
213
214    Err(Error::MissingDependency {
215        binary: binary.to_string(),
216        detail: format!("`{binary}` not found on PATH"),
217        hint: Some(format!(
218            "Install via `brew install {binary}` (or run `./scripts/install.sh`)."
219        )),
220        io: None,
221    })
222}
223
224/// Read stdin from an interactive TTY, returning a trimmed line.
225///
226/// Used for the CLI confirmation flow. `prompt` is echoed to stderr
227/// so stdout stays clean for JSON mode.
228pub fn read_line_interactive(prompt: &str) -> Result<String> {
229    eprintln!("{prompt}");
230    let mut line = String::new();
231    if std::io::stdin().lock().read_line(&mut line).is_err() {
232        return Err(Error::Cancelled);
233    }
234    Ok(line.trim().to_string())
235}