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