goblin-engine 0.1.0

A high-performance async workflow engine for executing scripts in planned sequences with dependency resolution
Documentation
use crate::error::{GoblinError, Result};
use crate::script::Script;
use async_trait::async_trait;
use std::collections::HashMap;
use std::process::Stdio;
use tokio::process::Command;
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};

/// Result of executing a script
#[derive(Debug, Clone)]
pub struct ExecutionResult {
    pub script_name: String,
    pub stdout: String,
    pub stderr: String,
    pub exit_code: i32,
    pub duration: Duration,
}

impl ExecutionResult {
    /// Check if the execution was successful (exit code 0)
    pub fn is_success(&self) -> bool {
        self.exit_code == 0
    }

    /// Get the output, preferring stdout but falling back to stderr if stdout is empty
    pub fn get_output(&self) -> String {
        if self.stdout.trim().is_empty() && !self.stderr.trim().is_empty() {
            self.stderr.clone()
        } else {
            self.stdout.clone()
        }
    }
}

/// Trait for executing scripts
#[async_trait]
pub trait Executor {
    /// Execute a script with given arguments
    async fn execute_script(&self, script: &Script, args: &[String]) -> Result<ExecutionResult>;
    
    /// Run a test for a script
    async fn run_test(&self, script: &Script) -> Result<bool>;
}

/// Default implementation of the Executor trait
pub struct DefaultExecutor {
    /// Environment variables to pass to executed scripts
    environment: HashMap<String, String>,
}

impl DefaultExecutor {
    /// Create a new default executor
    pub fn new() -> Self {
        Self {
            environment: HashMap::new(),
        }
    }

    /// Create a new executor with custom environment variables
    pub fn with_environment(environment: HashMap<String, String>) -> Self {
        Self { environment }
    }

    /// Add an environment variable
    pub fn add_env(&mut self, key: String, value: String) {
        self.environment.insert(key, value);
    }

    /// Parse shell command into program and arguments
    fn parse_command(command: &str) -> (String, Vec<String>) {
        let parts: Vec<&str> = command.split_whitespace().collect();
        if parts.is_empty() {
            return (String::new(), Vec::new());
        }
        
        let program = parts[0].to_string();
        let args = parts[1..].iter().map(|s| s.to_string()).collect();
        
        (program, args)
    }

    /// Create a tokio Command from a script and arguments
    fn create_command(&self, script: &Script, args: &[String]) -> Command {
        // Parse the base command (without args)
        let (program, cmd_args) = Self::parse_command(&script.command);
        
        let mut command = Command::new(program);
        // Add the parsed command arguments first
        command.args(cmd_args);
        // Then add the script arguments (this preserves JSON structure)
        command.args(args);
        command.current_dir(script.working_directory());
        command.stdout(Stdio::piped());
        command.stderr(Stdio::piped());
        command.stdin(Stdio::null());
        
        // Add environment variables
        for (key, value) in &self.environment {
            command.env(key, value);
        }
        
        command
    }

    /// Execute a command with proper timeout handling and output capture
    async fn execute_command_with_timeout(
        &self,
        mut command: Command,
        script_timeout: Duration,
        script_name: &str,
    ) -> Result<ExecutionResult> {
        let start_time = std::time::Instant::now();
        
        debug!("Executing command for script: {}", script_name);
        
        let child = command
            .spawn()
            .map_err(|e| GoblinError::script_execution_failed(script_name, format!("Failed to spawn process: {}", e)))?;

        let output = timeout(script_timeout, child.wait_with_output()).await
            .map_err(|_| GoblinError::script_timeout(script_name, script_timeout))?
            .map_err(|e| GoblinError::script_execution_failed(script_name, format!("Process error: {}", e)))?;

        let duration = start_time.elapsed();
        let duration = Duration::from_millis(duration.as_millis() as u64);

        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        let exit_code = output.status.code().unwrap_or(-1);

        debug!(
            "Script {} completed in {:?} with exit code: {}",
            script_name, duration, exit_code
        );

        Ok(ExecutionResult {
            script_name: script_name.to_string(),
            stdout,
            stderr,
            exit_code,
            duration,
        })
    }
}

impl Default for DefaultExecutor {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl Executor for DefaultExecutor {
    async fn execute_script(&self, script: &Script, args: &[String]) -> Result<ExecutionResult> {
        info!("Executing script: {} with args: {:?}", script.name, args);
        
        // Validate script first
        script.validate()?;
        
        // Run test if required
        if script.require_test {
            info!("Running required test for script: {}", script.name);
            let test_passed = self.run_test(script).await?;
            if !test_passed {
                return Err(GoblinError::test_failed(&script.name));
            }
            info!("Test passed for script: {}", script.name);
        }

        let command = self.create_command(script, args);
        let result = self
            .execute_command_with_timeout(command, script.timeout, &script.name)
            .await?;

        if !result.is_success() {
            let error_msg = if !result.stderr.is_empty() {
                result.stderr.clone()
            } else {
                format!("Script exited with code: {}", result.exit_code)
            };
            return Err(GoblinError::script_execution_failed(&script.name, error_msg));
        }

        info!(
            "Script {} completed successfully in {:?}",
            script.name, result.duration
        );

        Ok(result)
    }

    async fn run_test(&self, script: &Script) -> Result<bool> {
        let test_command = match script.get_test_command() {
            Some(cmd) => cmd,
            None => {
                warn!("No test command configured for script: {}", script.name);
                return Ok(true); // No test command means test passes
            }
        };

        debug!("Running test for script: {}", script.name);

        let (program, args) = Self::parse_command(test_command);
        let mut command = Command::new(program);
        command.args(args);
        command.current_dir(script.working_directory());
        command.stdout(Stdio::piped());
        command.stderr(Stdio::piped());

        // Add environment variables
        for (key, value) in &self.environment {
            command.env(key, value);
        }

        // Use a reasonable timeout for tests (default to script timeout or 30 seconds)
        let test_timeout = Duration::min(script.timeout, Duration::from_secs(30));
        
        let result = self
            .execute_command_with_timeout(command, test_timeout, &format!("{}_test", script.name))
            .await?;

        // Test passes if exit code is 0 AND stdout contains "true" (case insensitive)
        let test_passed = result.is_success() 
            && (result.stdout.to_lowercase().contains("true") || result.stdout.trim().is_empty());

        if test_passed {
            debug!("Test passed for script: {}", script.name);
        } else {
            debug!(
                "Test failed for script: {} (exit_code: {}, stdout: '{}', stderr: '{}')",
                script.name, result.exit_code, result.stdout, result.stderr
            );
        }

        Ok(test_passed)
    }
}

/// A mock executor for testing purposes
#[cfg(test)]
pub struct MockExecutor {
    pub results: HashMap<String, ExecutionResult>,
    pub test_results: HashMap<String, bool>,
}

#[cfg(test)]
impl MockExecutor {
    pub fn new() -> Self {
        Self {
            results: HashMap::new(),
            test_results: HashMap::new(),
        }
    }

    pub fn add_result(&mut self, script_name: String, result: ExecutionResult) {
        self.results.insert(script_name, result);
    }

    pub fn add_test_result(&mut self, script_name: String, passes: bool) {
        self.test_results.insert(script_name, passes);
    }
}

#[cfg(test)]
#[async_trait]
impl Executor for MockExecutor {
    async fn execute_script(&self, script: &Script, _args: &[String]) -> Result<ExecutionResult> {
        self.results
            .get(&script.name)
            .cloned()
            .ok_or_else(|| GoblinError::script_not_found(&script.name))
    }

    async fn run_test(&self, script: &Script) -> Result<bool> {
        Ok(self.test_results.get(&script.name).copied().unwrap_or(true))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::script::{Script, ScriptConfig};
    use std::path::PathBuf;

    #[tokio::test]
    async fn test_mock_executor() {
        let mut executor = MockExecutor::new();
        
        let result = ExecutionResult {
            script_name: "test_script".to_string(),
            stdout: "Hello, World!".to_string(),
            stderr: String::new(),
            exit_code: 0,
            duration: Duration::from_millis(100),
        };
        
        executor.add_result("test_script".to_string(), result.clone());
        
        let config = ScriptConfig {
            name: "test_script".to_string(),
            command: "echo Hello".to_string(),
            timeout: 30,
            test_command: None,
            require_test: false,
        };
        
        let script = Script::new(config, PathBuf::new());
        let exec_result = executor.execute_script(&script, &[]).await.unwrap();
        
        assert_eq!(exec_result.script_name, "test_script");
        assert_eq!(exec_result.stdout, "Hello, World!");
        assert_eq!(exec_result.exit_code, 0);
        assert!(exec_result.is_success());
    }

    #[test]
    fn test_parse_command() {
        let (program, args) = DefaultExecutor::parse_command("deno run --allow-all main.ts");
        assert_eq!(program, "deno");
        assert_eq!(args, vec!["run", "--allow-all", "main.ts"]);
        
        let (program, args) = DefaultExecutor::parse_command("echo hello");
        assert_eq!(program, "echo");
        assert_eq!(args, vec!["hello"]);
        
        let (program, args) = DefaultExecutor::parse_command("simple_command");
        assert_eq!(program, "simple_command");
        assert!(args.is_empty());
    }

    #[test]
    fn test_execution_result() {
        let result = ExecutionResult {
            script_name: "test".to_string(),
            stdout: "output".to_string(),
            stderr: String::new(),
            exit_code: 0,
            duration: Duration::from_millis(100),
        };
        
        assert!(result.is_success());
        assert_eq!(result.get_output(), "output");
        
        let result_with_error = ExecutionResult {
            script_name: "test".to_string(),
            stdout: String::new(),
            stderr: "error output".to_string(),
            exit_code: 1,
            duration: Duration::from_millis(100),
        };
        
        assert!(!result_with_error.is_success());
        assert_eq!(result_with_error.get_output(), "error output");
    }
}