1use std::io::{Read, Write};
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, Instant};
16
17use crate::error::{Result, SparError};
18
19pub const DEFAULT_TIMEOUT_SECS: u64 = 1800;
20
21#[derive(Debug, Clone)]
22pub struct ExecOpts {
23 pub cwd: Option<PathBuf>,
24 pub timeout: Duration,
25 pub check: bool,
27 pub env: Vec<(String, String)>,
28 pub stdin: Option<String>,
29}
30
31impl Default for ExecOpts {
32 fn default() -> Self {
33 Self {
34 cwd: None,
35 timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
36 check: true,
37 env: Vec::new(),
38 stdin: None,
39 }
40 }
41}
42
43impl ExecOpts {
44 pub fn new() -> Self {
45 Self::default()
46 }
47
48 pub fn cwd(mut self, path: impl AsRef<Path>) -> Self {
49 self.cwd = Some(path.as_ref().to_path_buf());
50 self
51 }
52
53 pub fn cwd_opt(mut self, path: Option<PathBuf>) -> Self {
54 self.cwd = path;
55 self
56 }
57
58 pub fn timeout_secs(mut self, secs: u64) -> Self {
59 self.timeout = Duration::from_secs(secs);
60 self
61 }
62
63 pub fn check(mut self, value: bool) -> Self {
64 self.check = value;
65 self
66 }
67
68 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
69 self.env.push((key.into(), value.into()));
70 self
71 }
72}
73
74#[derive(Debug, Clone)]
75pub struct Output {
76 pub stdout: String,
77 pub stderr: String,
78 pub code: i32,
79}
80
81impl Output {
82 pub fn ok(&self) -> bool {
83 self.code == 0
84 }
85}
86
87pub fn exec(argv: &[String], opts: &ExecOpts) -> Result<Output> {
92 let program = argv
93 .first()
94 .ok_or_else(|| SparError::new("cannot run an empty command"))?;
95
96 let mut command = Command::new(program);
97 command
98 .args(&argv[1..])
99 .stdout(Stdio::piped())
100 .stderr(Stdio::piped());
101
102 if opts.stdin.is_some() {
103 command.stdin(Stdio::piped());
104 } else {
105 command.stdin(Stdio::null());
107 }
108
109 if let Some(dir) = &opts.cwd {
110 command.current_dir(dir);
111 }
112 for (key, value) in &opts.env {
113 command.env(key, value);
114 }
115
116 let mut child = command
117 .spawn()
118 .map_err(|e| SparError::new(format!("could not run `{}`: {e}", abbreviate(argv))))?;
119
120 if let Some(text) = &opts.stdin {
121 if let Some(mut pipe) = child.stdin.take() {
122 let _ = pipe.write_all(text.as_bytes());
123 }
124 }
127
128 let out_reader = Reader::spawn(child.stdout.take().expect("stdout piped"));
129 let err_reader = Reader::spawn(child.stderr.take().expect("stderr piped"));
130
131 let deadline = Instant::now() + opts.timeout;
132 let mut poll = Duration::from_millis(5);
133 let mut timed_out = false;
134 let status = loop {
135 match child.try_wait()? {
136 Some(status) => break Some(status),
137 None => {
138 if Instant::now() >= deadline {
139 let _ = child.kill();
140 let _ = child.wait();
141 timed_out = true;
142 break None;
143 }
144 std::thread::sleep(poll);
145 poll = (poll * 2).min(Duration::from_millis(100));
148 }
149 }
150 };
151
152 let stdout = out_reader.collect(DRAIN_GRACE);
160 let stderr = err_reader.collect(DRAIN_GRACE);
161
162 if timed_out {
163 return Err(SparError::new(format!(
164 "timed out after {}s: {}\nRaise `timeout` on this agent in spar.toml if the model \
165 legitimately needs longer.",
166 opts.timeout.as_secs(),
167 abbreviate(argv)
168 )));
169 }
170
171 Ok(Output {
172 stdout: String::from_utf8_lossy(&stdout).into_owned(),
173 stderr: String::from_utf8_lossy(&stderr).into_owned(),
174 code: status.and_then(|s| s.code()).unwrap_or(-1),
175 })
176}
177
178const DRAIN_GRACE: Duration = Duration::from_secs(3);
182
183struct Reader {
188 buf: Arc<Mutex<Vec<u8>>>,
189 done: Arc<AtomicBool>,
190}
191
192impl Reader {
193 fn spawn<R: Read + Send + 'static>(mut pipe: R) -> Self {
194 let buf = Arc::new(Mutex::new(Vec::new()));
195 let done = Arc::new(AtomicBool::new(false));
196 let (buf_w, done_w) = (Arc::clone(&buf), Arc::clone(&done));
197 std::thread::spawn(move || {
198 let mut chunk = [0u8; 16 * 1024];
199 loop {
200 match pipe.read(&mut chunk) {
201 Ok(0) | Err(_) => break,
202 Ok(n) => buf_w
203 .lock()
204 .unwrap_or_else(|e| e.into_inner())
205 .extend_from_slice(&chunk[..n]),
206 }
207 }
208 done_w.store(true, Ordering::Release);
209 });
210 Self { buf, done }
211 }
212
213 fn len(&self) -> usize {
214 self.buf.lock().unwrap_or_else(|e| e.into_inner()).len()
215 }
216
217 fn collect(&self, grace: Duration) -> Vec<u8> {
225 let mut last_len = self.len();
226 let mut quiet_since = Instant::now();
227 let mut poll = Duration::from_micros(100);
228 while !self.done.load(Ordering::Acquire) {
229 let now_len = self.len();
230 if now_len != last_len {
231 last_len = now_len;
232 quiet_since = Instant::now();
233 } else if quiet_since.elapsed() >= grace {
234 break;
235 }
236 std::thread::sleep(poll);
237 poll = (poll * 2).min(Duration::from_millis(10));
238 }
239 self.buf.lock().unwrap_or_else(|e| e.into_inner()).clone()
240 }
241}
242
243pub fn run(argv: &[String], opts: &ExecOpts) -> Result<String> {
246 let out = exec(argv, opts)?;
247 if opts.check && !out.ok() {
248 return Err(SparError::new(failure_message(argv, &out)));
249 }
250 Ok(out.stdout)
251}
252
253pub fn run_str(argv: &[&str], opts: &ExecOpts) -> Result<String> {
255 let owned: Vec<String> = argv.iter().map(|s| (*s).to_string()).collect();
256 run(&owned, opts)
257}
258
259pub fn abbreviate(argv: &[String]) -> String {
261 argv.iter()
262 .map(|arg| {
263 let one_line = arg.split_whitespace().collect::<Vec<_>>().join(" ");
264 if one_line.chars().count() <= 60 {
265 one_line
266 } else {
267 let head: String = one_line.chars().take(57).collect();
268 format!("{head}...")
269 }
270 })
271 .collect::<Vec<_>>()
272 .join(" ")
273}
274
275pub fn failure_message(argv: &[String], out: &Output) -> String {
280 let mut parts = vec![format!(
281 "command failed ({}): {}",
282 out.code,
283 abbreviate(argv)
284 )];
285 for (label, stream) in [("stderr", &out.stderr), ("stdout", &out.stdout)] {
286 let text = stream.trim();
287 if !text.is_empty() {
288 parts.push(format!("--- {label} ---\n{}", tail(text, 1500)));
289 }
290 }
291 if parts.len() == 1 {
292 parts.push("(no output on either stream)".to_string());
293 }
294 parts.join("\n")
295}
296
297fn tail(text: &str, max: usize) -> &str {
299 let count = text.chars().count();
300 if count <= max {
301 return text;
302 }
303 let start = text
304 .char_indices()
305 .nth(count - max)
306 .map(|(i, _)| i)
307 .unwrap_or(0);
308 &text[start..]
309}
310
311pub fn which(program: &str) -> Option<PathBuf> {
313 if program.contains(std::path::MAIN_SEPARATOR) {
314 let path = PathBuf::from(program);
315 return is_executable(&path).then_some(path);
316 }
317 let paths = std::env::var_os("PATH")?;
318 std::env::split_paths(&paths).find_map(|dir| {
319 let candidate = dir.join(program);
320 is_executable(&candidate).then_some(candidate)
321 })
322}
323
324pub fn is_executable(path: &Path) -> bool {
325 #[cfg(unix)]
326 {
327 use std::os::unix::fs::PermissionsExt;
328 match std::fs::metadata(path) {
329 Ok(meta) => meta.is_file() && meta.permissions().mode() & 0o111 != 0,
330 Err(_) => false,
331 }
332 }
333 #[cfg(not(unix))]
334 {
335 path.is_file()
336 }
337}
338
339pub fn expand_tilde(path: &str) -> PathBuf {
342 if path == "~" {
343 if let Some(home) = home_dir() {
344 return home;
345 }
346 }
347 if let Some(rest) = path.strip_prefix("~/") {
348 if let Some(home) = home_dir() {
349 return home.join(rest);
350 }
351 }
352 PathBuf::from(path)
353}
354
355pub fn home_dir() -> Option<PathBuf> {
356 std::env::var_os("HOME").map(PathBuf::from)
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 fn proc(out: &str, err: &str, code: i32) -> Output {
364 Output {
365 stdout: out.into(),
366 stderr: err.into(),
367 code,
368 }
369 }
370
371 fn argv(parts: &[&str]) -> Vec<String> {
372 parts.iter().map(|s| s.to_string()).collect()
373 }
374
375 #[test]
376 fn stdout_used_when_stderr_is_empty() {
377 let msg = failure_message(&argv(&["claude"]), &proc("You've hit your limit.", "", 1));
378 assert!(msg.contains("hit your limit"), "{msg}");
379 }
380
381 #[test]
382 fn stderr_shown_when_present() {
383 let msg = failure_message(&argv(&["gh"]), &proc("noise", "real reason", 1));
384 assert!(msg.contains("real reason"), "{msg}");
385 }
386
387 #[test]
388 fn both_streams_are_shown_not_just_one() {
389 let msg = failure_message(&argv(&["gh"]), &proc("on stdout", "on stderr", 1));
390 assert!(
391 msg.contains("on stdout") && msg.contains("on stderr"),
392 "{msg}"
393 );
394 }
395
396 #[test]
397 fn says_something_when_both_are_empty() {
398 assert!(failure_message(&argv(&["x"]), &proc("", "", 2)).contains("no output"));
399 }
400
401 #[test]
402 fn long_arguments_are_abbreviated() {
403 let long = "word ".repeat(500);
404 let out = abbreviate(&argv(&["claude", "-p", &long]));
405 assert!(out.len() < 200, "{}", out.len());
406 }
407
408 #[test]
409 fn newlines_in_arguments_do_not_break_the_line() {
410 assert!(!abbreviate(&argv(&["claude", "a\nb\nc"])).contains('\n'));
411 }
412
413 #[test]
414 fn short_arguments_survive_intact() {
415 assert_eq!(
416 "gh pr merge 17",
417 abbreviate(&argv(&["gh", "pr", "merge", "17"]))
418 );
419 }
420
421 #[test]
422 fn abbreviation_never_splits_a_character() {
423 let wide = "\u{1f600}".repeat(200);
426 let out = abbreviate(&argv(&[&wide]));
427 assert!(out.ends_with("..."));
428 }
429
430 #[test]
431 fn exit_code_is_reported() {
432 let out = exec(
433 &argv(&["sh", "-c", "exit 3"]),
434 &ExecOpts::new().check(false),
435 )
436 .unwrap();
437 assert_eq!(3, out.code);
438 }
439
440 #[test]
441 fn check_false_returns_stdout_on_failure() {
442 let text = run(
443 &argv(&["sh", "-c", "echo partial; exit 1"]),
444 &ExecOpts::new().check(false),
445 )
446 .unwrap();
447 assert_eq!("partial\n", text);
448 }
449
450 #[test]
451 fn check_true_fails_loudly() {
452 let err = run(
453 &argv(&["sh", "-c", "echo why >&2; exit 1"]),
454 &ExecOpts::new(),
455 )
456 .unwrap_err();
457 assert!(err.to_string().contains("why"), "{err}");
458 }
459
460 #[test]
461 fn large_output_does_not_deadlock() {
462 let text = run(
464 &argv(&["sh", "-c", "yes hello | head -c 400000"]),
465 &ExecOpts::new().timeout_secs(60),
466 )
467 .unwrap();
468 assert_eq!(400_000, text.len());
469 }
470
471 #[test]
475 fn a_surviving_grandchild_holding_the_pipe_cannot_hang_the_timeout() {
476 let start = Instant::now();
477 let err = run(
478 &argv(&["sh", "-c", "sleep 120 & echo parent-output; sleep 60"]),
479 &ExecOpts::new().timeout_secs(1),
480 )
481 .unwrap_err();
482 let elapsed = start.elapsed();
483
484 assert!(err.to_string().contains("timed out"), "{err}");
485 assert!(
486 elapsed < Duration::from_secs(20),
487 "the timeout did not bound the call: {elapsed:?}"
488 );
489 }
490
491 #[test]
492 fn a_surviving_grandchild_does_not_hang_a_normal_exit_either() {
493 let start = Instant::now();
494 let out = run(
495 &argv(&["sh", "-c", "sleep 120 & echo done"]),
496 &ExecOpts::new().timeout_secs(60),
497 )
498 .unwrap();
499 assert!(out.contains("done"), "{out:?}");
500 assert!(
501 start.elapsed() < Duration::from_secs(20),
502 "waited on a grandchild that will never exit"
503 );
504 }
505
506 #[test]
507 fn timeout_kills_and_explains() {
508 let err = run(
509 &argv(&["sh", "-c", "sleep 30"]),
510 &ExecOpts::new().timeout_secs(1),
511 )
512 .unwrap_err();
513 assert!(err.to_string().contains("timed out"), "{err}");
514 }
515
516 #[test]
517 fn missing_binary_names_the_command() {
518 let err = exec(
519 &argv(&["spar-definitely-not-a-real-binary"]),
520 &ExecOpts::new(),
521 )
522 .unwrap_err();
523 assert!(
524 err.to_string()
525 .contains("spar-definitely-not-a-real-binary"),
526 "{err}"
527 );
528 }
529
530 #[test]
531 fn tilde_expands_against_home() {
532 std::env::set_var("HOME", "/home/someone");
533 assert_eq!(PathBuf::from("/home/someone/bin"), expand_tilde("~/bin"));
534 assert_eq!(PathBuf::from("/absolute"), expand_tilde("/absolute"));
535 assert_eq!(PathBuf::from("~notauser/x"), expand_tilde("~notauser/x"));
536 }
537}