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(
163 &self,
164 request: &ProcessRequest,
165 timeout: Duration,
166 ) -> Result<ProcessOutput, ProcessExecutionError> {
167 let now_ms = chrono::Utc::now().timestamp_millis();
168 let pid = std::process::id();
169 let stdout_path = temp_output_path("stdout", pid, now_ms);
170 let stderr_path = temp_output_path("stderr", pid, now_ms);
171
172 let stdout_file =
173 fs::File::create(&stdout_path).map_err(|source| ProcessExecutionError::CreateTemp {
174 path: stdout_path.clone(),
175 source,
176 })?;
177 let stderr_file =
178 fs::File::create(&stderr_path).map_err(|source| ProcessExecutionError::CreateTemp {
179 path: stderr_path.clone(),
180 source,
181 })?;
182
183 let mut command = build_command(request);
184 let mut child = command
185 .stdin(Stdio::null())
186 .stdout(Stdio::from(stdout_file))
187 .stderr(Stdio::from(stderr_file))
188 .spawn()
189 .map_err(|source| ProcessExecutionError::Spawn {
190 program: request.program.clone(),
191 source,
192 })?;
193
194 let started = Instant::now();
195 let mut timed_out = false;
196 let mut exit_code = -1;
197 let mut success = false;
198
199 loop {
200 if let Some(status) =
201 child
202 .try_wait()
203 .map_err(|source| ProcessExecutionError::Wait {
204 program: request.program.clone(),
205 source,
206 })?
207 {
208 exit_code = status.code().unwrap_or(-1);
209 success = status.success();
210 break;
211 }
212
213 if started.elapsed() >= timeout {
214 timed_out = true;
215 let _ = child.kill();
216 let _ = child.wait();
217 break;
218 }
219
220 thread::sleep(Duration::from_millis(50));
221 }
222
223 let stdout =
224 fs::read_to_string(&stdout_path).map_err(|source| ProcessExecutionError::ReadTemp {
225 path: stdout_path.clone(),
226 source,
227 })?;
228 let stderr =
229 fs::read_to_string(&stderr_path).map_err(|source| ProcessExecutionError::ReadTemp {
230 path: stderr_path.clone(),
231 source,
232 })?;
233 let _ = fs::remove_file(&stdout_path);
234 let _ = fs::remove_file(&stderr_path);
235
236 Ok(ProcessOutput {
237 exit_code,
238 success: !timed_out && success,
239 stdout,
240 stderr,
241 timed_out,
242 })
243 }
244}
245
246fn build_command(request: &ProcessRequest) -> Command {
247 let mut command = Command::new(&request.program);
248 command.args(&request.args);
249 if let Some(dir) = &request.current_dir {
250 command.current_dir(dir);
251 }
252 command
253}
254
255fn temp_output_path(stream: &str, pid: u32, now_ms: i64) -> PathBuf {
256 let mut path = std::env::temp_dir();
257 path.push(format!("ito-process-{stream}-{pid}-{now_ms}.log"));
258 path
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn captures_stdout_and_stderr() {
267 let runner = SystemProcessRunner;
268 let request = ProcessRequest::new("sh").args(["-lc", "echo out; echo err >&2"]);
269 let output = runner.run(&request).unwrap();
270 assert!(output.success);
271 assert_eq!(output.exit_code, 0);
272 assert!(output.stdout.contains("out"));
273 assert!(output.stderr.contains("err"));
274 assert!(!output.timed_out);
275 }
276
277 #[test]
278 fn captures_non_zero_exit() {
279 let runner = SystemProcessRunner;
280 let request = ProcessRequest::new("sh").args(["-lc", "echo boom >&2; exit 7"]);
281 let output = runner.run(&request).unwrap();
282 assert!(!output.success);
283 assert_eq!(output.exit_code, 7);
284 assert!(output.stderr.contains("boom"));
285 }
286
287 #[test]
288 fn missing_executable_is_spawn_failure() {
289 let runner = SystemProcessRunner;
290 let request = ProcessRequest::new("__ito_missing_executable__");
291 let result = runner.run(&request);
292 match result {
293 Err(ProcessExecutionError::Spawn { .. }) => {}
294 other => panic!("expected spawn error, got {other:?}"),
295 }
296 }
297}