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};
#[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 {
pub fn is_success(&self) -> bool {
self.exit_code == 0
}
pub fn get_output(&self) -> String {
if self.stdout.trim().is_empty() && !self.stderr.trim().is_empty() {
self.stderr.clone()
} else {
self.stdout.clone()
}
}
}
#[async_trait]
pub trait Executor {
async fn execute_script(&self, script: &Script, args: &[String]) -> Result<ExecutionResult>;
async fn run_test(&self, script: &Script) -> Result<bool>;
}
pub struct DefaultExecutor {
environment: HashMap<String, String>,
}
impl DefaultExecutor {
pub fn new() -> Self {
Self {
environment: HashMap::new(),
}
}
pub fn with_environment(environment: HashMap<String, String>) -> Self {
Self { environment }
}
pub fn add_env(&mut self, key: String, value: String) {
self.environment.insert(key, value);
}
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)
}
fn create_command(&self, script: &Script, args: &[String]) -> Command {
let (program, cmd_args) = Self::parse_command(&script.command);
let mut command = Command::new(program);
command.args(cmd_args);
command.args(args);
command.current_dir(script.working_directory());
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
command.stdin(Stdio::null());
for (key, value) in &self.environment {
command.env(key, value);
}
command
}
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);
script.validate()?;
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); }
};
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());
for (key, value) in &self.environment {
command.env(key, value);
}
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?;
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)
}
}
#[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");
}
}