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, Default)]
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,
48 pub stdin_data: Option<String>,
50}
51
52pub 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"); 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 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 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 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 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 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
175pub 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
195pub 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 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
227pub 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}