Skip to main content

macos_agent/backend/
process.rs

1use std::io;
2use std::process::{Command, Stdio};
3use std::thread;
4use std::time::{Duration, Instant};
5
6use crate::error::CliError;
7
8#[derive(Debug, Clone)]
9pub struct ProcessRequest {
10    pub program: String,
11    pub args: Vec<String>,
12    pub timeout_ms: u64,
13}
14
15impl ProcessRequest {
16    pub fn new(program: impl Into<String>, args: Vec<String>, timeout_ms: u64) -> Self {
17        Self {
18            program: program.into(),
19            args,
20            timeout_ms,
21        }
22    }
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ProcessOutput {
27    pub stdout: String,
28    pub stderr: String,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ProcessFailure {
33    NotFound {
34        program: String,
35    },
36    Timeout {
37        program: String,
38        timeout_ms: u64,
39    },
40    NonZero {
41        program: String,
42        code: i32,
43        stderr: String,
44    },
45    Io {
46        program: String,
47        message: String,
48    },
49}
50
51pub trait ProcessRunner {
52    fn run(&self, request: &ProcessRequest) -> Result<ProcessOutput, ProcessFailure>;
53}
54
55#[derive(Debug, Clone, Copy, Default)]
56pub struct RealProcessRunner;
57
58impl ProcessRunner for RealProcessRunner {
59    fn run(&self, request: &ProcessRequest) -> Result<ProcessOutput, ProcessFailure> {
60        let mut cmd = Command::new(&request.program);
61        cmd.args(&request.args)
62            .stdin(Stdio::null())
63            .stdout(Stdio::piped())
64            .stderr(Stdio::piped());
65
66        let mut child = cmd
67            .spawn()
68            .map_err(|err| map_spawn_error(&request.program, err))?;
69
70        let deadline = Instant::now() + Duration::from_millis(request.timeout_ms.max(1));
71        loop {
72            match child.try_wait() {
73                Ok(Some(_status)) => {
74                    let output = child.wait_with_output().map_err(|err| ProcessFailure::Io {
75                        program: request.program.clone(),
76                        message: err.to_string(),
77                    })?;
78                    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
79                    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
80                    if output.status.success() {
81                        return Ok(ProcessOutput { stdout, stderr });
82                    }
83                    let code = output.status.code().unwrap_or(-1);
84                    return Err(ProcessFailure::NonZero {
85                        program: request.program.clone(),
86                        code,
87                        stderr: sanitize_stderr(&stderr),
88                    });
89                }
90                Ok(None) => {
91                    if Instant::now() >= deadline {
92                        let _ = child.kill();
93                        let _ = child.wait();
94                        return Err(ProcessFailure::Timeout {
95                            program: request.program.clone(),
96                            timeout_ms: request.timeout_ms,
97                        });
98                    }
99                    thread::sleep(Duration::from_millis(10));
100                }
101                Err(err) => {
102                    return Err(ProcessFailure::Io {
103                        program: request.program.clone(),
104                        message: err.to_string(),
105                    });
106                }
107            }
108        }
109    }
110}
111
112fn map_spawn_error(program: &str, err: io::Error) -> ProcessFailure {
113    if err.kind() == io::ErrorKind::NotFound {
114        ProcessFailure::NotFound {
115            program: program.to_string(),
116        }
117    } else {
118        ProcessFailure::Io {
119            program: program.to_string(),
120            message: err.to_string(),
121        }
122    }
123}
124
125fn sanitize_stderr(stderr: &str) -> String {
126    let line = stderr.split_whitespace().collect::<Vec<_>>().join(" ");
127    if line.is_empty() {
128        "no stderr output".to_string()
129    } else {
130        line
131    }
132}
133
134pub fn map_failure(operation: &str, failure: ProcessFailure) -> CliError {
135    match failure {
136        ProcessFailure::NotFound { program } => CliError::runtime(format!(
137            "{operation} failed: missing dependency `{program}` in PATH"
138        ))
139        .with_operation(operation)
140        .with_hint(format!(
141            "Install `{program}` and ensure it is available in PATH."
142        )),
143        ProcessFailure::Timeout {
144            program,
145            timeout_ms,
146        } => CliError::timeout(&format!("{operation} via `{program}`"), timeout_ms)
147            .with_operation(operation),
148        ProcessFailure::NonZero {
149            program,
150            code,
151            stderr,
152        } => CliError::runtime(format!(
153            "{operation} failed via `{program}` (exit {code}): {stderr}"
154        ))
155        .with_operation(operation)
156        .with_hint("Check macOS Accessibility/Automation permissions if this action controls System Events."),
157        ProcessFailure::Io { program, message } => {
158            CliError::runtime(format!("{operation} failed to run `{program}`: {message}"))
159                .with_operation(operation)
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use pretty_assertions::assert_eq;
167
168    use super::{ProcessFailure, ProcessRequest, ProcessRunner, RealProcessRunner, map_failure};
169
170    #[test]
171    fn reports_not_found() {
172        let runner = RealProcessRunner;
173        let req = ProcessRequest::new("__missing_binary__", Vec::new(), 100);
174        let err = runner.run(&req).expect_err("missing bin should fail");
175        assert_eq!(
176            err,
177            ProcessFailure::NotFound {
178                program: "__missing_binary__".to_string(),
179            }
180        );
181    }
182
183    #[test]
184    fn maps_timeout_failure_to_runtime_error() {
185        let err = map_failure(
186            "test-op",
187            ProcessFailure::Timeout {
188                program: "osascript".to_string(),
189                timeout_ms: 10,
190            },
191        );
192
193        assert_eq!(err.exit_code(), 1);
194        assert!(err.to_string().contains("timed out"));
195    }
196
197    #[test]
198    fn non_zero_stderr_is_compacted() {
199        let runner = RealProcessRunner;
200        let req = ProcessRequest::new(
201            "sh",
202            vec![
203                "-c".to_string(),
204                "echo 'bad\\nline' 1>&2; exit 3".to_string(),
205            ],
206            200,
207        );
208        let err = runner.run(&req).expect_err("script should fail");
209        match err {
210            ProcessFailure::NonZero { code, stderr, .. } => {
211                assert_eq!(code, 3);
212                assert_eq!(stderr, "bad line");
213            }
214            other => panic!("unexpected failure: {other:?}"),
215        }
216    }
217}