1use std::fs;
4use std::io;
5use std::path::PathBuf;
6use std::process::{Command, Stdio};
7use std::thread;
8use std::time::{Duration, Instant};
9
10#[derive(Debug, Clone, Default)]
12pub struct ProcessRequest {
13 pub program: String,
15 pub args: Vec<String>,
17 pub current_dir: Option<PathBuf>,
19}
20
21impl ProcessRequest {
22 pub fn new(program: impl Into<String>) -> Self {
27 Self {
28 program: program.into(),
29 args: Vec::new(),
30 current_dir: None,
31 }
32 }
33
34 pub fn arg(mut self, arg: impl Into<String>) -> Self {
39 self.args.push(arg.into());
40 self
41 }
42
43 pub fn args<I, S>(mut self, args: I) -> Self
48 where
49 I: IntoIterator<Item = S>,
50 S: Into<String>,
51 {
52 for arg in args {
53 self.args.push(arg.into());
54 }
55 self
56 }
57
58 pub fn current_dir(mut self, dir: impl Into<PathBuf>) -> Self {
63 self.current_dir = Some(dir.into());
64 self
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct ProcessOutput {
71 pub exit_code: i32,
73 pub success: bool,
75 pub stdout: String,
77 pub stderr: String,
79 pub timed_out: bool,
81}
82
83#[derive(Debug, thiserror::Error)]
85pub enum ProcessExecutionError {
86 #[error("failed to spawn '{program}': {source}")]
88 Spawn {
89 program: String,
91 source: io::Error,
93 },
94 #[error("failed waiting for '{program}': {source}")]
96 Wait {
97 program: String,
99 source: io::Error,
101 },
102 #[error("failed to create temp output file '{path}': {source}")]
104 CreateTemp {
105 path: PathBuf,
107 source: io::Error,
109 },
110 #[error("failed to read temp output file '{path}': {source}")]
112 ReadTemp {
113 path: PathBuf,
115 source: io::Error,
117 },
118}
119
120pub trait ProcessRunner {
122 fn run(&self, request: &ProcessRequest) -> Result<ProcessOutput, ProcessExecutionError>;
127
128 fn run_with_timeout(
134 &self,
135 request: &ProcessRequest,
136 timeout: Duration,
137 ) -> Result<ProcessOutput, ProcessExecutionError>;
138}
139
140#[derive(Debug, Default)]
142pub struct SystemProcessRunner;
143
144impl ProcessRunner for SystemProcessRunner {
145 fn run(&self, request: &ProcessRequest) -> Result<ProcessOutput, ProcessExecutionError> {
146 let mut command = build_command(request);
147 let output = command
148 .output()
149 .map_err(|source| ProcessExecutionError::Spawn {
150 program: request.program.clone(),
151 source,
152 })?;
153 Ok(ProcessOutput {
154 exit_code: output.status.code().unwrap_or(-1),
155 success: output.status.success(),
156 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
157 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
158 timed_out: false,
159 })
160 }
161
162 fn run_with_timeout(
186 &self,
187 request: &ProcessRequest,
188 timeout: Duration,
189 ) -> Result<ProcessOutput, ProcessExecutionError> {
190 let now_ms = chrono::Utc::now().timestamp_millis();
191 let pid = std::process::id();
192 let stdout_path = temp_output_path("stdout", pid, now_ms);
193 let stderr_path = temp_output_path("stderr", pid, now_ms);
194
195 let stdout_file =
196 fs::File::create(&stdout_path).map_err(|source| ProcessExecutionError::CreateTemp {
197 path: stdout_path.clone(),
198 source,
199 })?;
200 let stderr_file =
201 fs::File::create(&stderr_path).map_err(|source| ProcessExecutionError::CreateTemp {
202 path: stderr_path.clone(),
203 source,
204 })?;
205
206 let mut command = build_command(request);
207 let mut child = command
208 .stdin(Stdio::null())
209 .stdout(Stdio::from(stdout_file))
210 .stderr(Stdio::from(stderr_file))
211 .spawn()
212 .map_err(|source| ProcessExecutionError::Spawn {
213 program: request.program.clone(),
214 source,
215 })?;
216
217 let started = Instant::now();
218 let mut timed_out = false;
219 let mut exit_code = -1;
220 let mut success = false;
221
222 loop {
223 if let Some(status) =
224 child
225 .try_wait()
226 .map_err(|source| ProcessExecutionError::Wait {
227 program: request.program.clone(),
228 source,
229 })?
230 {
231 exit_code = status.code().unwrap_or(-1);
232 success = status.success();
233 break;
234 }
235
236 if started.elapsed() >= timeout {
237 timed_out = true;
238 let _ = child.kill();
239 let _ = child.wait();
240 break;
241 }
242
243 thread::sleep(Duration::from_millis(10));
244 }
245
246 let stdout =
247 fs::read_to_string(&stdout_path).map_err(|source| ProcessExecutionError::ReadTemp {
248 path: stdout_path.clone(),
249 source,
250 })?;
251 let stderr =
252 fs::read_to_string(&stderr_path).map_err(|source| ProcessExecutionError::ReadTemp {
253 path: stderr_path.clone(),
254 source,
255 })?;
256 let _ = fs::remove_file(&stdout_path);
257 let _ = fs::remove_file(&stderr_path);
258
259 Ok(ProcessOutput {
260 exit_code,
261 success: !timed_out && success,
262 stdout,
263 stderr,
264 timed_out,
265 })
266 }
267}
268
269fn build_command(request: &ProcessRequest) -> Command {
270 let mut command = Command::new(&request.program);
271 command.args(&request.args);
272 if let Some(dir) = &request.current_dir {
273 command.current_dir(dir);
274 }
275 command
276}
277
278fn temp_output_path(stream: &str, pid: u32, now_ms: i64) -> PathBuf {
279 let mut path = std::env::temp_dir();
280 path.push(format!("ito-process-{stream}-{pid}-{now_ms}.log"));
281 path
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn captures_stdout_and_stderr() {
290 let runner = SystemProcessRunner;
291 let request = ProcessRequest::new("sh").args(["-lc", "echo out; echo err >&2"]);
292 let output = runner.run(&request).unwrap();
293 assert!(output.success);
294 assert_eq!(output.exit_code, 0);
295 assert!(output.stdout.contains("out"));
296 assert!(output.stderr.contains("err"));
297 assert!(!output.timed_out);
298 }
299
300 #[test]
301 fn captures_non_zero_exit() {
302 let runner = SystemProcessRunner;
303 let request = ProcessRequest::new("sh").args(["-lc", "echo boom >&2; exit 7"]);
304 let output = runner.run(&request).unwrap();
305 assert!(!output.success);
306 assert_eq!(output.exit_code, 7);
307 assert!(output.stderr.contains("boom"));
308 }
309
310 #[test]
311 fn missing_executable_is_spawn_failure() {
312 let runner = SystemProcessRunner;
313 let request = ProcessRequest::new("__ito_missing_executable__");
314 let result = runner.run(&request);
315 match result {
316 Err(ProcessExecutionError::Spawn { .. }) => {}
317 other => panic!("expected spawn error, got {other:?}"),
318 }
319 }
320}