rumdl_lib/code_block_tools/
executor.rs1use super::config::ToolDefinition;
7use std::collections::HashMap;
8use std::io::{Read, Write};
9use std::process::{Command, Stdio};
10use std::sync::{Arc, Mutex};
11use std::thread;
12use std::time::{Duration, Instant};
13
14#[derive(Debug, Clone)]
16pub struct ToolOutput {
17 pub stdout: String,
19 pub stderr: String,
21 pub exit_code: i32,
23 pub success: bool,
25}
26
27#[derive(Debug, Clone)]
29pub enum ExecutorError {
30 ToolNotFound { tool: String },
32 ExecutionFailed { tool: String, message: String },
34 Timeout { tool: String, timeout_ms: u64 },
36 IoError { message: String },
38}
39
40impl std::fmt::Display for ExecutorError {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 Self::ToolNotFound { tool } => {
44 write!(f, "Tool '{tool}' not found in PATH")
45 }
46 Self::ExecutionFailed { tool, message } => {
47 write!(f, "Tool '{tool}' failed: {message}")
48 }
49 Self::Timeout { tool, timeout_ms } => {
50 write!(f, "Tool '{tool}' timed out after {timeout_ms}ms")
51 }
52 Self::IoError { message } => {
53 write!(f, "I/O error: {message}")
54 }
55 }
56 }
57}
58
59impl std::error::Error for ExecutorError {}
60
61pub struct ToolExecutor {
65 tool_cache: Arc<Mutex<HashMap<String, bool>>>,
67 default_timeout_ms: u64,
69}
70
71impl ToolExecutor {
72 pub fn new(default_timeout_ms: u64) -> Self {
74 Self {
75 tool_cache: Arc::new(Mutex::new(HashMap::new())),
76 default_timeout_ms,
77 }
78 }
79
80 pub fn is_tool_available(&self, tool_name: &str) -> bool {
82 {
84 let cache = self.tool_cache.lock().unwrap();
85 if let Some(&available) = cache.get(tool_name) {
86 return available;
87 }
88 }
89
90 let available = self.check_tool_exists(tool_name);
92
93 {
95 let mut cache = self.tool_cache.lock().unwrap();
96 cache.insert(tool_name.to_string(), available);
97 }
98
99 available
100 }
101
102 fn check_tool_exists(&self, tool_name: &str) -> bool {
104 #[cfg(unix)]
105 {
106 Command::new("which")
107 .arg(tool_name)
108 .stdout(Stdio::null())
109 .stderr(Stdio::null())
110 .status()
111 .is_ok_and(|s| s.success())
112 }
113
114 #[cfg(windows)]
115 {
116 Command::new("where")
117 .arg(tool_name)
118 .stdout(Stdio::null())
119 .stderr(Stdio::null())
120 .status()
121 .is_ok_and(|s| s.success())
122 }
123
124 #[cfg(not(any(unix, windows)))]
125 {
126 let _ = tool_name;
128 false
129 }
130 }
131
132 pub fn execute(
143 &self,
144 tool_def: &ToolDefinition,
145 input: &str,
146 is_format_mode: bool,
147 timeout_ms: Option<u64>,
148 ) -> Result<ToolOutput, ExecutorError> {
149 if tool_def.command.is_empty() {
150 return Err(ExecutorError::ExecutionFailed {
151 tool: "unknown".to_string(),
152 message: "Empty command".to_string(),
153 });
154 }
155
156 let tool_name = &tool_def.command[0];
157
158 if !self.is_tool_available(tool_name) {
160 return Err(ExecutorError::ToolNotFound {
161 tool: tool_name.clone(),
162 });
163 }
164
165 let mut cmd = Command::new(tool_name);
167
168 if tool_def.command.len() > 1 {
170 cmd.args(&tool_def.command[1..]);
171 }
172
173 let extra_args = if is_format_mode {
175 &tool_def.format_args
176 } else {
177 &tool_def.lint_args
178 };
179 if !extra_args.is_empty() {
180 cmd.args(extra_args);
181 }
182
183 if tool_def.stdin {
185 cmd.stdin(Stdio::piped());
186 }
187 cmd.stdout(Stdio::piped());
188 cmd.stderr(Stdio::piped());
189
190 let mut child = cmd.spawn().map_err(|e| ExecutorError::IoError {
192 message: format!("Failed to spawn '{tool_name}': {e}"),
193 })?;
194
195 let mut stdout_handle = child
196 .stdout
197 .take()
198 .map(|stdout| thread::spawn(move || read_pipe_to_string(stdout)));
199 let mut stderr_handle = child
200 .stderr
201 .take()
202 .map(|stderr| thread::spawn(move || read_pipe_to_string(stderr)));
203
204 if tool_def.stdin
206 && let Some(mut stdin) = child.stdin.take()
207 {
208 stdin.write_all(input.as_bytes()).map_err(|e| ExecutorError::IoError {
209 message: format!("Failed to write to stdin: {e}"),
210 })?;
211 }
212
213 let timeout = Duration::from_millis(timeout_ms.unwrap_or(self.default_timeout_ms));
215 let status = if timeout.is_zero() {
216 child.wait().map_err(|e| ExecutorError::IoError {
217 message: format!("Failed to wait for '{tool_name}': {e}"),
218 })?
219 } else {
220 let start = Instant::now();
221 loop {
222 if let Some(status) = child.try_wait().map_err(|e| ExecutorError::IoError {
223 message: format!("Failed to poll '{tool_name}': {e}"),
224 })? {
225 break status;
226 }
227 if start.elapsed() >= timeout {
228 let _ = child.kill();
229 let _ = child.wait();
230 let _ = join_reader(stdout_handle.take());
231 let _ = join_reader(stderr_handle.take());
232 return Err(ExecutorError::Timeout {
233 tool: tool_name.clone(),
234 timeout_ms: timeout.as_millis() as u64,
235 });
236 }
237 thread::sleep(Duration::from_millis(10));
238 }
239 };
240
241 let stdout = join_reader(stdout_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
242 let stderr = join_reader(stderr_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
243 let exit_code = status.code().unwrap_or(-1);
244
245 Ok(ToolOutput {
246 stdout,
247 stderr,
248 exit_code,
249 success: status.success(),
250 })
251 }
252
253 pub fn format(
255 &self,
256 tool_def: &ToolDefinition,
257 input: &str,
258 timeout_ms: Option<u64>,
259 ) -> Result<String, ExecutorError> {
260 let output = self.execute(tool_def, input, true, timeout_ms)?;
261
262 if output.success && tool_def.stdout {
263 Ok(output.stdout)
264 } else if !output.success {
265 let exit_code = output.exit_code;
266 let stderr = &output.stderr;
267 Err(ExecutorError::ExecutionFailed {
268 tool: tool_def.command.first().cloned().unwrap_or_default(),
269 message: format!("Exit code {exit_code}: {stderr}"),
270 })
271 } else {
272 Err(ExecutorError::ExecutionFailed {
274 tool: tool_def.command.first().cloned().unwrap_or_default(),
275 message: "Formatter doesn't output to stdout".to_string(),
276 })
277 }
278 }
279
280 pub fn lint(
282 &self,
283 tool_def: &ToolDefinition,
284 input: &str,
285 timeout_ms: Option<u64>,
286 ) -> Result<ToolOutput, ExecutorError> {
287 self.execute(tool_def, input, false, timeout_ms)
288 }
289}
290
291fn read_pipe_to_string<R: Read>(mut pipe: R) -> std::io::Result<String> {
292 let mut buf = Vec::new();
293 pipe.read_to_end(&mut buf)?;
294 Ok(String::from_utf8_lossy(&buf).to_string())
295}
296
297fn join_reader(handle: Option<thread::JoinHandle<std::io::Result<String>>>) -> Result<String, String> {
298 match handle {
299 Some(handle) => match handle.join() {
300 Ok(res) => res.map_err(|e| format!("Failed to read output: {e}")),
301 Err(_) => Err("Output reader thread panicked".to_string()),
302 },
303 None => Ok(String::new()),
304 }
305}
306
307impl Default for ToolExecutor {
308 fn default() -> Self {
309 Self::new(30_000) }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn test_executor_creation() {
319 let executor = ToolExecutor::new(10_000);
320 assert_eq!(executor.default_timeout_ms, 10_000);
322 }
323
324 #[test]
325 fn test_tool_not_found() {
326 let executor = ToolExecutor::default();
327 let tool_def = ToolDefinition {
328 command: vec!["nonexistent-tool-xyz123".to_string()],
329 stdin: true,
330 stdout: true,
331 lint_args: vec![],
332 format_args: vec![],
333 };
334
335 let result = executor.execute(&tool_def, "test", false, None);
336 assert!(matches!(result, Err(ExecutorError::ToolNotFound { .. })));
337 }
338
339 #[test]
340 fn test_empty_command() {
341 let executor = ToolExecutor::default();
342 let tool_def = ToolDefinition {
343 command: vec![],
344 stdin: true,
345 stdout: true,
346 lint_args: vec![],
347 format_args: vec![],
348 };
349
350 let result = executor.execute(&tool_def, "test", false, None);
351 assert!(matches!(result, Err(ExecutorError::ExecutionFailed { .. })));
352 }
353
354 #[test]
358 #[ignore = "requires 'cat' to be available"]
359 fn test_execute_cat() {
360 let executor = ToolExecutor::default();
361 let tool_def = ToolDefinition {
362 command: vec!["cat".to_string()],
363 stdin: true,
364 stdout: true,
365 lint_args: vec![],
366 format_args: vec![],
367 };
368
369 let result = executor.execute(&tool_def, "hello world", false, None);
370 let output = result.expect("cat should succeed");
371 assert!(output.success);
372 assert_eq!(output.stdout.trim(), "hello world");
373 }
374
375 #[test]
376 #[cfg(unix)]
377 #[ignore = "requires 'sleep' to be available"]
378 fn test_timeout() {
379 let executor = ToolExecutor::new(5);
380 let tool_def = ToolDefinition {
381 command: vec!["sleep".to_string(), "1".to_string()],
382 stdin: false,
383 stdout: true,
384 lint_args: vec![],
385 format_args: vec![],
386 };
387
388 let result = executor.execute(&tool_def, "", false, Some(5));
389 assert!(matches!(result, Err(ExecutorError::Timeout { .. })));
390 }
391}