use crate::error::{Error, Result};
use crate::types::{ExecResult, IoOptions, ScriptOptions, SpawnResult};
use std::io::Write;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Instant;
use tempfile::NamedTempFile;
use tokio::process::{Child, Command};
use tokio::time::timeout;
pub async fn run(script: &str, options: ScriptOptions) -> Result<ExecResult> {
let start_time = Instant::now();
if script.trim().is_empty() {
return Err(Error::command_failed("Script content cannot be empty"));
}
let mut temp_file = NamedTempFile::new()
.map_err(|e| Error::script_write_error(e))?;
temp_file.write_all(script.as_bytes())
.map_err(|e| Error::script_write_error(e))?;
temp_file.flush()
.map_err(|e| Error::script_write_error(e))?;
let script_path = temp_file.path().to_path_buf();
if let Some(ref wd) = options.working_directory {
if !wd.exists() {
return Err(Error::invalid_working_directory(
wd.to_string_lossy(),
std::io::Error::new(std::io::ErrorKind::NotFound, "Directory does not exist")
));
}
if !wd.is_dir() {
return Err(Error::invalid_working_directory(
wd.to_string_lossy(),
std::io::Error::new(std::io::ErrorKind::InvalidInput, "Path is not a directory")
));
}
}
let timeout_duration = options.timeout;
let child = spawn_command(&script_path, &options)
.await?;
let output = if let Some(timeout_duration) = timeout_duration {
match timeout(timeout_duration, child.wait_with_output()).await {
Ok(result) => result.map_err(|e| Error::process_wait_error(e))?,
Err(_) => {
return Ok(ExecResult {
exit_code: -1,
stdout: String::new(),
stderr: format!("Process timed out after {:?}", timeout_duration),
duration: start_time.elapsed(),
timed_out: true,
});
}
}
} else {
child.wait_with_output().await
.map_err(|e| Error::process_wait_error(e))?
};
let duration = start_time.elapsed();
let exit_code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok(ExecResult {
exit_code,
stdout,
stderr,
duration,
timed_out: false,
})
}
pub async fn run_file(path: &PathBuf, options: ScriptOptions) -> Result<ExecResult> {
if !path.exists() {
return Err(Error::script_read_error(
path.to_string_lossy(),
std::io::Error::new(std::io::ErrorKind::NotFound, "Script file does not exist")
));
}
if !path.is_file() {
return Err(Error::invalid_script_path(
path.to_string_lossy(),
"Path is not a file"
));
}
match std::fs::metadata(path) {
Ok(metadata) => {
if metadata.len() == 0 {
return Err(Error::invalid_script_path(
path.to_string_lossy(),
"Script file is empty"
));
}
}
Err(e) => {
return Err(Error::script_read_error(path.to_string_lossy(), e));
}
}
let start_time = Instant::now();
let timeout_duration = options.timeout;
let child = spawn_command(path, &options).await?;
let output = if let Some(timeout_duration) = timeout_duration {
match timeout(timeout_duration, child.wait_with_output()).await {
Ok(result) => result.map_err(|e| Error::process_wait_error(e))?,
Err(_) => {
return Ok(ExecResult {
exit_code: -1,
stdout: String::new(),
stderr: format!("Process timed out after {:?}", timeout_duration),
duration: start_time.elapsed(),
timed_out: true,
});
}
}
} else {
child.wait_with_output().await
.map_err(|e| Error::process_wait_error(e))?
};
let duration = start_time.elapsed();
let exit_code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok(ExecResult {
exit_code,
stdout,
stderr,
duration,
timed_out: false,
})
}
pub async fn spawn(script: &str, options: ScriptOptions) -> Result<SpawnResult> {
if script.trim().is_empty() {
return Err(Error::command_failed("Script content cannot be empty"));
}
let mut temp_file = NamedTempFile::new()
.map_err(|e| Error::script_write_error(e))?;
temp_file.write_all(script.as_bytes())
.map_err(|e| Error::script_write_error(e))?;
temp_file.flush()
.map_err(|e| Error::script_write_error(e))?;
let script_path = temp_file.path().to_path_buf();
let child = spawn_command(&script_path, &options).await?;
Ok(SpawnResult {
child,
_temp_file: Some(temp_file),
})
}
pub async fn spawn_file(path: &PathBuf, options: ScriptOptions) -> Result<Child> {
if !path.exists() {
return Err(Error::script_read_error(
path.to_string_lossy(),
std::io::Error::new(std::io::ErrorKind::NotFound, "Script file does not exist")
));
}
if !path.is_file() {
return Err(Error::invalid_script_path(
path.to_string_lossy(),
"Path is not a file"
));
}
spawn_command(path, &options).await
}
async fn spawn_command(path: &PathBuf, options: &ScriptOptions) -> Result<Child> {
let openscript_path = options
.openscript_path
.as_ref()
.cloned()
.unwrap_or_else(|| PathBuf::from("openscript"));
if openscript_path.is_absolute() && !openscript_path.exists() {
return Err(Error::OpenScriptNotFound);
}
let mut cmd = Command::new(&openscript_path);
cmd.arg(path);
cmd.args(&options.args);
if let Some(ref cwd) = options.working_directory {
if !cwd.exists() {
return Err(Error::invalid_working_directory(
cwd.to_string_lossy(),
std::io::Error::new(std::io::ErrorKind::NotFound, "Directory does not exist")
));
}
cmd.current_dir(cwd);
}
if options.clear_env {
cmd.env_clear();
}
for (key, value) in &options.env_vars {
if key.contains('\0') || value.contains('\0') {
return Err(Error::invalid_environment_variable(
key,
"Environment variable contains null bytes"
));
}
cmd.env(key, value);
}
cmd.stdin(convert_io(options.stdin));
cmd.stdout(convert_io(options.stdout));
cmd.stderr(convert_io(options.stderr));
let child = cmd.spawn().map_err(|e| {
match e.kind() {
std::io::ErrorKind::NotFound => Error::OpenScriptNotFound,
std::io::ErrorKind::PermissionDenied => Error::PermissionDenied,
_ => Error::process_spawn_error(e),
}
})?;
Ok(child)
}
fn convert_io(io_option: IoOptions) -> Stdio {
match io_option {
IoOptions::Inherit => Stdio::inherit(),
IoOptions::Pipe => Stdio::piped(),
IoOptions::Null => Stdio::null(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[tokio::test]
async fn test_run_success() {
let options = ScriptOptions::new().openscript_path("/bin/sh");
let result = run("echo 'test'", options).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("test"));
}
#[tokio::test]
async fn test_run_empty_script() {
let options = ScriptOptions::new().openscript_path("/bin/sh");
let result = run("", options).await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::CommandFailed { .. }));
}
#[tokio::test]
async fn test_run_with_timeout() {
let options = ScriptOptions::new()
.openscript_path("/bin/sh")
.timeout(Duration::from_millis(100));
let result = run("sleep 1", options).await.unwrap();
assert!(result.timed_out);
}
#[tokio::test]
async fn test_spawn_and_wait() -> crate::Result<()> {
let options = ScriptOptions::new().openscript_path("/bin/sh");
let spawn_result = spawn("echo 'spawned'", options).await?;
let output = spawn_result.child.wait_with_output().await?;
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("spawned"));
Ok(())
}
#[tokio::test]
async fn test_invalid_working_directory() {
let options = ScriptOptions::new()
.openscript_path("/bin/sh")
.working_directory("/nonexistent/directory");
let result = run("echo 'test'", options).await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidWorkingDirectory { .. }));
}
#[tokio::test]
async fn test_invalid_script_file() {
let options = ScriptOptions::new().openscript_path("/bin/sh");
let non_existent_path = PathBuf::from("/nonexistent/script.sh");
let result = run_file(&non_existent_path, options).await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::ScriptReadError { .. }));
}
#[tokio::test]
async fn test_error_retryability() {
let timeout_error = Error::timeout(Duration::from_secs(5));
assert!(timeout_error.is_retryable());
let not_found_error = Error::OpenScriptNotFound;
assert!(!not_found_error.is_retryable());
}
#[tokio::test]
async fn test_user_friendly_messages() {
let error = Error::OpenScriptNotFound;
let message = error.user_message();
assert!(message.contains("install OpenScript"));
}
}