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 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"); 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 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 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 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 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 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
172pub 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
192pub 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 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
224pub 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}