use async_trait::async_trait;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct ProcessOutput {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
pub trait ProcessSpawner: Send + Sync {
async fn run(
&self,
command: &str,
args: &[&str],
working_dir: Option<&Path>,
timeout_secs: Option<u64>,
) -> Result<ProcessOutput, Box<dyn std::error::Error + Send + Sync>>;
}
#[cfg(feature = "native")]
pub struct NativeProcessSpawner;
#[cfg(feature = "native")]
#[async_trait]
impl ProcessSpawner for NativeProcessSpawner {
async fn run(
&self,
command: &str,
args: &[&str],
working_dir: Option<&Path>,
timeout_secs: Option<u64>,
) -> Result<ProcessOutput, Box<dyn std::error::Error + Send + Sync>> {
let mut cmd = tokio::process::Command::new(command);
cmd.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
let child = cmd.spawn()?;
let output = if let Some(secs) = timeout_secs {
let timeout = std::time::Duration::from_secs(secs);
match tokio::time::timeout(timeout, child.wait_with_output()).await {
Ok(result) => result?,
Err(_) => {
return Err(format!("process '{}' timed out after {}s", command, secs).into());
}
}
} else {
child.wait_with_output().await?
};
let exit_code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
Ok(ProcessOutput {
exit_code,
stdout,
stderr,
})
}
}
#[cfg(all(test, feature = "native"))]
mod tests {
use super::*;
#[tokio::test]
async fn test_run_echo() {
let spawner = NativeProcessSpawner;
let output = spawner
.run("echo", &["hello", "world"], None, Some(10))
.await
.unwrap();
assert_eq!(output.exit_code, 0);
assert_eq!(output.stdout.trim(), "hello world");
assert!(output.stderr.is_empty());
}
#[tokio::test]
async fn test_run_with_working_dir() {
let spawner = NativeProcessSpawner;
let output = spawner
.run("pwd", &[], Some(Path::new("/tmp")), Some(10))
.await
.unwrap();
assert_eq!(output.exit_code, 0);
assert!(output.stdout.trim().ends_with("tmp"));
}
#[tokio::test]
async fn test_run_nonexistent_command() {
let spawner = NativeProcessSpawner;
let result = spawner
.run("clawft_nonexistent_command_xyz", &[], None, Some(5))
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_run_captures_stderr() {
let spawner = NativeProcessSpawner;
let output = spawner
.run("sh", &["-c", "echo error >&2"], None, Some(10))
.await
.unwrap();
assert_eq!(output.exit_code, 0);
assert_eq!(output.stderr.trim(), "error");
}
#[tokio::test]
async fn test_run_nonzero_exit_code() {
let spawner = NativeProcessSpawner;
let output = spawner
.run("sh", &["-c", "exit 42"], None, Some(10))
.await
.unwrap();
assert_eq!(output.exit_code, 42);
}
#[tokio::test]
async fn test_run_timeout() {
let spawner = NativeProcessSpawner;
let result = spawner.run("sleep", &["60"], None, Some(1)).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("timed out"),
"expected timeout error, got: {err_msg}"
);
}
}