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}