use crate::error::ComposioError;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::process::Command;
#[derive(Debug, Clone)]
pub struct BashResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub execution_time_ms: u128,
}
pub struct BashExecutor {
sandbox_dir: PathBuf,
timeout_secs: u64,
env_vars: Vec<(String, String)>,
}
impl BashExecutor {
pub fn new() -> Self {
Self {
sandbox_dir: std::env::temp_dir().join("composio_sandbox"),
timeout_secs: 30,
env_vars: Vec::new(),
}
}
pub fn with_sandbox(sandbox_dir: PathBuf) -> Self {
Self {
sandbox_dir,
timeout_secs: 30,
env_vars: Vec::new(),
}
}
pub fn timeout(mut self, timeout_secs: u64) -> Self {
self.timeout_secs = timeout_secs;
self
}
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env_vars.push((key.into(), value.into()));
self
}
pub async fn execute(&self, command: &str) -> Result<BashResult, ComposioError> {
if !self.sandbox_dir.exists() {
tokio::fs::create_dir_all(&self.sandbox_dir)
.await
.map_err(|e| ComposioError::ExecutionError(format!("Failed to create sandbox: {}", e)))?;
}
let start_time = std::time::Instant::now();
let mut cmd = Command::new("bash");
cmd.arg("-c")
.arg(command)
.current_dir(&self.sandbox_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (key, value) in &self.env_vars {
cmd.env(key, value);
}
let output = tokio::time::timeout(
std::time::Duration::from_secs(self.timeout_secs),
cmd.output(),
)
.await
.map_err(|_| {
ComposioError::ExecutionError(format!(
"Command timed out after {} seconds",
self.timeout_secs
))
})?
.map_err(|e| ComposioError::ExecutionError(format!("Failed to execute command: {}", e)))?;
let execution_time_ms = start_time.elapsed().as_millis();
Ok(BashResult {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code().unwrap_or(-1),
execution_time_ms,
})
}
pub async fn execute_batch(&self, commands: Vec<&str>) -> Result<Vec<BashResult>, ComposioError> {
let mut results = Vec::new();
for command in commands {
let result = self.execute(command).await?;
results.push(result);
}
Ok(results)
}
pub fn sandbox_dir(&self) -> &PathBuf {
&self.sandbox_dir
}
}
impl Default for BashExecutor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_bash_executor_echo() {
let executor = BashExecutor::new();
let result = executor.execute("echo 'Hello, World!'").await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("Hello, World!"));
assert!(result.stderr.is_empty());
}
#[tokio::test]
async fn test_bash_executor_with_error() {
let executor = BashExecutor::new();
let result = executor.execute("ls /nonexistent_directory").await.unwrap();
assert_ne!(result.exit_code, 0);
assert!(!result.stderr.is_empty());
}
#[tokio::test]
async fn test_bash_executor_with_env() {
let executor = BashExecutor::new().env("TEST_VAR", "test_value");
let result = executor.execute("echo $TEST_VAR").await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("test_value"));
}
#[tokio::test]
async fn test_bash_executor_batch() {
let executor = BashExecutor::new();
let results = executor
.execute_batch(vec!["echo 'Step 1'", "echo 'Step 2'", "echo 'Step 3'"])
.await
.unwrap();
assert_eq!(results.len(), 3);
assert!(results[0].stdout.contains("Step 1"));
assert!(results[1].stdout.contains("Step 2"));
assert!(results[2].stdout.contains("Step 3"));
}
#[tokio::test]
async fn test_bash_executor_timeout() {
let executor = BashExecutor::new().timeout(1);
let result = executor.execute("sleep 5").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("timed out"));
}
#[test]
fn test_bash_result_clone() {
let result = BashResult {
stdout: "output".to_string(),
stderr: "error".to_string(),
exit_code: 0,
execution_time_ms: 100,
};
let cloned = result.clone();
assert_eq!(cloned.stdout, "output");
assert_eq!(cloned.exit_code, 0);
}
}