Skip to main content

ito_core/
process.rs

1//! Process execution boundary for core-side command invocation.
2
3use std::fs;
4use std::io;
5use std::path::PathBuf;
6use std::process::{Command, Stdio};
7use std::thread;
8use std::time::{Duration, Instant};
9
10/// Process invocation request.
11#[derive(Debug, Clone, Default)]
12pub struct ProcessRequest {
13    /// Executable name or absolute path.
14    pub program: String,
15    /// Positional arguments.
16    pub args: Vec<String>,
17    /// Optional working directory.
18    pub current_dir: Option<PathBuf>,
19}
20
21impl ProcessRequest {
22    /// Create a new request for the given program.
23    ///
24    /// The program can be an executable name (resolved via PATH) or an absolute path.
25    /// Use the builder methods to configure arguments and working directory.
26    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    /// Add a single argument to the process invocation.
35    ///
36    /// This is a builder method that returns `self` for chaining.
37    /// Arguments are passed to the process in the order they are added.
38    pub fn arg(mut self, arg: impl Into<String>) -> Self {
39        self.args.push(arg.into());
40        self
41    }
42
43    /// Add multiple arguments to the process invocation.
44    ///
45    /// This is a builder method that returns `self` for chaining.
46    /// Arguments are appended in iteration order after any previously added arguments.
47    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    /// Set the working directory for the process.
59    ///
60    /// If not set, the process inherits the current working directory.
61    /// This is a builder method that returns `self` for chaining.
62    pub fn current_dir(mut self, dir: impl Into<PathBuf>) -> Self {
63        self.current_dir = Some(dir.into());
64        self
65    }
66}
67
68/// Structured process execution output.
69#[derive(Debug, Clone)]
70pub struct ProcessOutput {
71    /// Exit status code, or -1 if unavailable.
72    pub exit_code: i32,
73    /// Whether the process exited successfully.
74    pub success: bool,
75    /// Captured stdout.
76    pub stdout: String,
77    /// Captured stderr.
78    pub stderr: String,
79    /// True if execution was forcibly terminated due to timeout.
80    pub timed_out: bool,
81}
82
83/// Process execution failure modes.
84#[derive(Debug, thiserror::Error)]
85pub enum ProcessExecutionError {
86    /// Spawn failed before a child process was created.
87    #[error("failed to spawn '{program}': {source}")]
88    Spawn {
89        /// Program being executed.
90        program: String,
91        /// Underlying I/O error.
92        source: io::Error,
93    },
94    /// Waiting for process completion failed.
95    #[error("failed waiting for '{program}': {source}")]
96    Wait {
97        /// Program being executed.
98        program: String,
99        /// Underlying I/O error.
100        source: io::Error,
101    },
102    /// Creating a temporary output file failed.
103    #[error("failed to create temp output file '{path}': {source}")]
104    CreateTemp {
105        /// Temp path used for output capture.
106        path: PathBuf,
107        /// Underlying I/O error.
108        source: io::Error,
109    },
110    /// Reading a temporary output file failed.
111    #[error("failed to read temp output file '{path}': {source}")]
112    ReadTemp {
113        /// Temp path used for output capture.
114        path: PathBuf,
115        /// Underlying I/O error.
116        source: io::Error,
117    },
118}
119
120/// Abstraction for process execution.
121pub trait ProcessRunner {
122    /// Execute a process and wait for completion, capturing all output.
123    ///
124    /// This method blocks until the process exits or fails to start.
125    /// Both stdout and stderr are captured and returned in the result.
126    fn run(&self, request: &ProcessRequest) -> Result<ProcessOutput, ProcessExecutionError>;
127
128    /// Execute a process with a timeout, capturing all output.
129    ///
130    /// If the process doesn't complete within the timeout, it will be killed
131    /// and the result will have `timed_out` set to true. Output captured
132    /// before the timeout is still returned.
133    fn run_with_timeout(
134        &self,
135        request: &ProcessRequest,
136        timeout: Duration,
137    ) -> Result<ProcessOutput, ProcessExecutionError>;
138}
139
140/// Default runner backed by `std::process::Command`.
141#[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    /// Executes the given process request and returns its captured output, enforcing the supplied timeout.
163    ///
164    /// On timeout the child process is killed; any output written before termination is returned and `timed_out` is set to `true`.
165    ///
166    /// # Returns
167    ///
168    /// A `ProcessOutput` containing the process exit code (or -1 if unavailable), a `success` flag (false if timed out or exit indicates failure), captured `stdout` and `stderr` as `String`s, and `timed_out` indicating whether the process was terminated due to the timeout.
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use std::time::Duration;
174    /// use ito_core::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
175    ///
176    /// let runner = SystemProcessRunner::default();
177    /// let req = ProcessRequest::new("sh")
178    ///     .arg("-c")
179    ///     .arg("echo hello; sleep 0.01; echo world");
180    /// let out = runner.run_with_timeout(&req, Duration::from_secs(1)).unwrap();
181    /// assert!(out.stdout.contains("hello"));
182    /// assert!(out.stdout.contains("world"));
183    /// assert!(!out.timed_out);
184    /// ```
185    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}