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    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}