goblin_engine/
executor.rs

1use crate::error::{GoblinError, Result};
2use crate::script::Script;
3use async_trait::async_trait;
4use std::collections::HashMap;
5use std::process::Stdio;
6use tokio::process::Command;
7use tokio::time::{timeout, Duration};
8use tracing::{debug, info, warn};
9
10/// Result of executing a script
11#[derive(Debug, Clone)]
12pub struct ExecutionResult {
13    pub script_name: String,
14    pub stdout: String,
15    pub stderr: String,
16    pub exit_code: i32,
17    pub duration: Duration,
18}
19
20impl ExecutionResult {
21    /// Check if the execution was successful (exit code 0)
22    pub fn is_success(&self) -> bool {
23        self.exit_code == 0
24    }
25
26    /// Get the output, preferring stdout but falling back to stderr if stdout is empty
27    pub fn get_output(&self) -> String {
28        if self.stdout.trim().is_empty() && !self.stderr.trim().is_empty() {
29            self.stderr.clone()
30        } else {
31            self.stdout.clone()
32        }
33    }
34}
35
36/// Trait for executing scripts
37#[async_trait]
38pub trait Executor {
39    /// Execute a script with given arguments
40    async fn execute_script(&self, script: &Script, args: &[String]) -> Result<ExecutionResult>;
41    
42    /// Run a test for a script
43    async fn run_test(&self, script: &Script) -> Result<bool>;
44}
45
46/// Default implementation of the Executor trait
47pub struct DefaultExecutor {
48    /// Environment variables to pass to executed scripts
49    environment: HashMap<String, String>,
50}
51
52impl DefaultExecutor {
53    /// Create a new default executor
54    pub fn new() -> Self {
55        Self {
56            environment: HashMap::new(),
57        }
58    }
59
60    /// Create a new executor with custom environment variables
61    pub fn with_environment(environment: HashMap<String, String>) -> Self {
62        Self { environment }
63    }
64
65    /// Add an environment variable
66    pub fn add_env(&mut self, key: String, value: String) {
67        self.environment.insert(key, value);
68    }
69
70    /// Parse shell command into program and arguments
71    fn parse_command(command: &str) -> (String, Vec<String>) {
72        let parts: Vec<&str> = command.split_whitespace().collect();
73        if parts.is_empty() {
74            return (String::new(), Vec::new());
75        }
76        
77        let program = parts[0].to_string();
78        let args = parts[1..].iter().map(|s| s.to_string()).collect();
79        
80        (program, args)
81    }
82
83    /// Create a tokio Command from a script and arguments
84    fn create_command(&self, script: &Script, args: &[String]) -> Command {
85        // Parse the base command (without args)
86        let (program, cmd_args) = Self::parse_command(&script.command);
87        
88        let mut command = Command::new(program);
89        // Add the parsed command arguments first
90        command.args(cmd_args);
91        // Then add the script arguments (this preserves JSON structure)
92        command.args(args);
93        command.current_dir(script.working_directory());
94        command.stdout(Stdio::piped());
95        command.stderr(Stdio::piped());
96        command.stdin(Stdio::null());
97        
98        // Add environment variables
99        for (key, value) in &self.environment {
100            command.env(key, value);
101        }
102        
103        command
104    }
105
106    /// Execute a command with proper timeout handling and output capture
107    async fn execute_command_with_timeout(
108        &self,
109        mut command: Command,
110        script_timeout: Duration,
111        script_name: &str,
112    ) -> Result<ExecutionResult> {
113        let start_time = std::time::Instant::now();
114        
115        debug!("Executing command for script: {}", script_name);
116        
117        let child = command
118            .spawn()
119            .map_err(|e| GoblinError::script_execution_failed(script_name, format!("Failed to spawn process: {}", e)))?;
120
121        let output = timeout(script_timeout, child.wait_with_output()).await
122            .map_err(|_| GoblinError::script_timeout(script_name, script_timeout))?
123            .map_err(|e| GoblinError::script_execution_failed(script_name, format!("Process error: {}", e)))?;
124
125        let duration = start_time.elapsed();
126        let duration = Duration::from_millis(duration.as_millis() as u64);
127
128        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
129        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
130        let exit_code = output.status.code().unwrap_or(-1);
131
132        debug!(
133            "Script {} completed in {:?} with exit code: {}",
134            script_name, duration, exit_code
135        );
136
137        Ok(ExecutionResult {
138            script_name: script_name.to_string(),
139            stdout,
140            stderr,
141            exit_code,
142            duration,
143        })
144    }
145}
146
147impl Default for DefaultExecutor {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153#[async_trait]
154impl Executor for DefaultExecutor {
155    async fn execute_script(&self, script: &Script, args: &[String]) -> Result<ExecutionResult> {
156        info!("Executing script: {} with args: {:?}", script.name, args);
157        
158        // Validate script first
159        script.validate()?;
160        
161        // Run test if required
162        if script.require_test {
163            info!("Running required test for script: {}", script.name);
164            let test_passed = self.run_test(script).await?;
165            if !test_passed {
166                return Err(GoblinError::test_failed(&script.name));
167            }
168            info!("Test passed for script: {}", script.name);
169        }
170
171        let command = self.create_command(script, args);
172        let result = self
173            .execute_command_with_timeout(command, script.timeout, &script.name)
174            .await?;
175
176        if !result.is_success() {
177            let error_msg = if !result.stderr.is_empty() {
178                result.stderr.clone()
179            } else {
180                format!("Script exited with code: {}", result.exit_code)
181            };
182            return Err(GoblinError::script_execution_failed(&script.name, error_msg));
183        }
184
185        info!(
186            "Script {} completed successfully in {:?}",
187            script.name, result.duration
188        );
189
190        Ok(result)
191    }
192
193    async fn run_test(&self, script: &Script) -> Result<bool> {
194        let test_command = match script.get_test_command() {
195            Some(cmd) => cmd,
196            None => {
197                warn!("No test command configured for script: {}", script.name);
198                return Ok(true); // No test command means test passes
199            }
200        };
201
202        debug!("Running test for script: {}", script.name);
203
204        let (program, args) = Self::parse_command(test_command);
205        let mut command = Command::new(program);
206        command.args(args);
207        command.current_dir(script.working_directory());
208        command.stdout(Stdio::piped());
209        command.stderr(Stdio::piped());
210
211        // Add environment variables
212        for (key, value) in &self.environment {
213            command.env(key, value);
214        }
215
216        // Use a reasonable timeout for tests (default to script timeout or 30 seconds)
217        let test_timeout = Duration::min(script.timeout, Duration::from_secs(30));
218        
219        let result = self
220            .execute_command_with_timeout(command, test_timeout, &format!("{}_test", script.name))
221            .await?;
222
223        // Test passes if exit code is 0 AND stdout contains "true" (case insensitive)
224        let test_passed = result.is_success() 
225            && (result.stdout.to_lowercase().contains("true") || result.stdout.trim().is_empty());
226
227        if test_passed {
228            debug!("Test passed for script: {}", script.name);
229        } else {
230            debug!(
231                "Test failed for script: {} (exit_code: {}, stdout: '{}', stderr: '{}')",
232                script.name, result.exit_code, result.stdout, result.stderr
233            );
234        }
235
236        Ok(test_passed)
237    }
238}
239
240/// A mock executor for testing purposes
241#[cfg(test)]
242pub struct MockExecutor {
243    pub results: HashMap<String, ExecutionResult>,
244    pub test_results: HashMap<String, bool>,
245}
246
247#[cfg(test)]
248impl MockExecutor {
249    pub fn new() -> Self {
250        Self {
251            results: HashMap::new(),
252            test_results: HashMap::new(),
253        }
254    }
255
256    pub fn add_result(&mut self, script_name: String, result: ExecutionResult) {
257        self.results.insert(script_name, result);
258    }
259
260    pub fn add_test_result(&mut self, script_name: String, passes: bool) {
261        self.test_results.insert(script_name, passes);
262    }
263}
264
265#[cfg(test)]
266#[async_trait]
267impl Executor for MockExecutor {
268    async fn execute_script(&self, script: &Script, _args: &[String]) -> Result<ExecutionResult> {
269        self.results
270            .get(&script.name)
271            .cloned()
272            .ok_or_else(|| GoblinError::script_not_found(&script.name))
273    }
274
275    async fn run_test(&self, script: &Script) -> Result<bool> {
276        Ok(self.test_results.get(&script.name).copied().unwrap_or(true))
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::script::{Script, ScriptConfig};
284    use std::path::PathBuf;
285
286    #[tokio::test]
287    async fn test_mock_executor() {
288        let mut executor = MockExecutor::new();
289        
290        let result = ExecutionResult {
291            script_name: "test_script".to_string(),
292            stdout: "Hello, World!".to_string(),
293            stderr: String::new(),
294            exit_code: 0,
295            duration: Duration::from_millis(100),
296        };
297        
298        executor.add_result("test_script".to_string(), result.clone());
299        
300        let config = ScriptConfig {
301            name: "test_script".to_string(),
302            command: "echo Hello".to_string(),
303            timeout: 30,
304            test_command: None,
305            require_test: false,
306        };
307        
308        let script = Script::new(config, PathBuf::new());
309        let exec_result = executor.execute_script(&script, &[]).await.unwrap();
310        
311        assert_eq!(exec_result.script_name, "test_script");
312        assert_eq!(exec_result.stdout, "Hello, World!");
313        assert_eq!(exec_result.exit_code, 0);
314        assert!(exec_result.is_success());
315    }
316
317    #[test]
318    fn test_parse_command() {
319        let (program, args) = DefaultExecutor::parse_command("deno run --allow-all main.ts");
320        assert_eq!(program, "deno");
321        assert_eq!(args, vec!["run", "--allow-all", "main.ts"]);
322        
323        let (program, args) = DefaultExecutor::parse_command("echo hello");
324        assert_eq!(program, "echo");
325        assert_eq!(args, vec!["hello"]);
326        
327        let (program, args) = DefaultExecutor::parse_command("simple_command");
328        assert_eq!(program, "simple_command");
329        assert!(args.is_empty());
330    }
331
332    #[test]
333    fn test_execution_result() {
334        let result = ExecutionResult {
335            script_name: "test".to_string(),
336            stdout: "output".to_string(),
337            stderr: String::new(),
338            exit_code: 0,
339            duration: Duration::from_millis(100),
340        };
341        
342        assert!(result.is_success());
343        assert_eq!(result.get_output(), "output");
344        
345        let result_with_error = ExecutionResult {
346            script_name: "test".to_string(),
347            stdout: String::new(),
348            stderr: "error output".to_string(),
349            exit_code: 1,
350            duration: Duration::from_millis(100),
351        };
352        
353        assert!(!result_with_error.is_success());
354        assert_eq!(result_with_error.get_output(), "error output");
355    }
356}