1use 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#[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#[derive(Debug, Clone)]
39pub struct RunOptions {
40 pub timeout: Option<Duration>,
42 pub env_extra: Vec<(String, String)>,
44 pub cwd: Option<std::path::PathBuf>,
46 pub capture: bool,
49 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 capture: true,
63 stdin_data: None,
64 }
65 }
66}
67
68pub 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"); if opts.capture {
79 command
80 .stdin(Stdio::piped())
81 .stdout(Stdio::piped())
82 .stderr(Stdio::piped());
83 } else {
84 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 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 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 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 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 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 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
218pub 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
238pub 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 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
270pub 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}