1use 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#[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#[derive(Debug, Clone, Default)]
36pub struct RunOptions {
37 pub timeout: Option<Duration>,
39 pub env_extra: Vec<(String, String)>,
41 pub cwd: Option<std::path::PathBuf>,
43 pub capture: bool,
45 pub stdin_data: Option<String>,
47}
48
49pub 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") .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 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 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 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 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 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 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
201pub 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
221pub 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 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
253pub 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}