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