use fastshell::Fastshell;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone)]
pub struct CmdOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
pub trait ShellBackend: Send + Sync {
fn run(
&self,
command: &str,
stdin_input: Option<&str>,
timeout_secs: u64,
idle_timeout_secs: u64,
cwd: &std::path::Path,
) -> CmdOutput;
fn kind(&self) -> &'static str;
fn ui_call(
&self,
_method: &str,
_args: &serde_json::Value,
) -> Option<std::result::Result<serde_json::Value, String>> {
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackendKind {
Native,
Fastshell,
}
impl BackendKind {
pub fn platform_default() -> BackendKind {
if cfg!(any(target_os = "android", target_os = "ios")) {
BackendKind::Fastshell
} else {
BackendKind::Native
}
}
}
pub struct NativeShell;
impl NativeShell {
pub fn new() -> Self {
NativeShell
}
}
impl Default for NativeShell {
fn default() -> Self {
NativeShell::new()
}
}
impl ShellBackend for NativeShell {
fn run(
&self,
command: &str,
stdin_input: Option<&str>,
timeout_secs: u64,
idle_timeout_secs: u64,
cwd: &std::path::Path,
) -> CmdOutput {
use std::io::Write;
use std::process::{Command, Stdio};
let mut cmd = if cfg!(target_os = "windows") {
let mut c = Command::new("cmd");
c.arg("/C").arg(command);
c
} else {
let mut c = Command::new("sh");
c.arg("-c").arg(command);
c
};
cmd.current_dir(cwd)
.stdin(if stdin_input.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
return CmdOutput {
stdout: String::new(),
stderr: format!("failed to spawn shell: {e}"),
exit_code: 127,
}
}
};
if let Some(input) = stdin_input {
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(input.as_bytes());
}
}
let output = if timeout_secs > 0 || idle_timeout_secs > 0 {
wait_with_timeout(child, timeout_secs, idle_timeout_secs)
} else {
child.wait_with_output().map(|o| CmdOutput {
stdout: String::from_utf8_lossy(&o.stdout).to_string(),
stderr: String::from_utf8_lossy(&o.stderr).to_string(),
exit_code: o.status.code().unwrap_or(-1),
})
};
output.unwrap_or_else(|e| CmdOutput {
stdout: String::new(),
stderr: format!("shell execution error: {e}"),
exit_code: -1,
})
}
fn kind(&self) -> &'static str {
"native"
}
}
fn wait_with_timeout(
mut child: std::process::Child,
timeout_secs: u64,
idle_timeout_secs: u64,
) -> std::io::Result<CmdOutput> {
use std::io::Read;
use std::sync::mpsc;
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
enum Chunk {
Stdout(String),
Stderr(String),
}
let (chunk_tx, chunk_rx) = mpsc::channel::<Chunk>();
if let Some(mut p) = stdout_pipe {
let tx = chunk_tx.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
loop {
match p.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let s = String::from_utf8_lossy(&buf[..n]).to_string();
let _ = tx.send(Chunk::Stdout(s));
}
Err(_) => break,
}
}
});
}
if let Some(mut p) = stderr_pipe {
let tx = chunk_tx.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
loop {
match p.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let s = String::from_utf8_lossy(&buf[..n]).to_string();
let _ = tx.send(Chunk::Stderr(s));
}
Err(_) => break,
}
}
});
}
drop(chunk_tx);
let pid = child.id();
let (exit_tx, exit_rx) = mpsc::channel();
std::thread::spawn(move || {
let status = child.wait();
let _ = exit_tx.send(status);
});
let mut stdout = String::new();
let mut stderr = String::new();
let deadline = if timeout_secs > 0 {
Some(std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs))
} else {
None
};
let idle_dur = std::time::Duration::from_secs(idle_timeout_secs);
let mut last_data = std::time::Instant::now();
loop {
let wait_dur = match deadline {
Some(dl) => {
let remaining = dl.saturating_duration_since(std::time::Instant::now());
if idle_timeout_secs > 0 {
let idle_remaining = idle_dur.saturating_sub(last_data.elapsed());
remaining.min(idle_remaining)
} else {
remaining
}
}
None => {
if idle_timeout_secs > 0 {
idle_dur.saturating_sub(last_data.elapsed())
} else {
std::time::Duration::from_secs(u64::MAX)
}
}
};
if wait_dur.is_zero() {
let reason = if deadline.map_or(false, |dl| dl <= std::time::Instant::now()) {
format!("command timed out after {timeout_secs}s")
} else {
format!("command idle timeout after {idle_timeout_secs}s")
};
#[cfg(unix)]
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/F", "/T", "/PID", &pid.to_string()])
.output();
}
return Ok(CmdOutput {
stdout,
stderr: format!("{stderr}\n{reason}"),
exit_code: 124,
});
}
match chunk_rx.recv_timeout(wait_dur) {
Ok(Chunk::Stdout(s)) => {
last_data = std::time::Instant::now();
stdout.push_str(&s);
}
Ok(Chunk::Stderr(s)) => {
last_data = std::time::Instant::now();
stderr.push_str(&s);
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
break;
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Ok(Ok(status)) = exit_rx.try_recv() {
return Ok(CmdOutput {
stdout,
stderr,
exit_code: status.code().unwrap_or(-1),
});
}
continue;
}
}
if let Ok(Ok(status)) = exit_rx.try_recv() {
while let Ok(chunk) = chunk_rx.try_recv() {
match chunk {
Chunk::Stdout(s) => stdout.push_str(&s),
Chunk::Stderr(s) => stderr.push_str(&s),
}
}
return Ok(CmdOutput {
stdout,
stderr,
exit_code: status.code().unwrap_or(-1),
});
}
}
match exit_rx.recv() {
Ok(Ok(status)) => Ok(CmdOutput {
stdout,
stderr,
exit_code: status.code().unwrap_or(-1),
}),
Ok(Err(e)) => Err(e),
Err(_) => Ok(CmdOutput {
stdout,
stderr: format!("{stderr}\nprocess disappeared"),
exit_code: 127,
}),
}
}
pub type SharedFastshell = Arc<Mutex<Fastshell>>;
pub struct FastshellBackend {
fs: SharedFastshell,
base_cwd: std::sync::Mutex<Option<String>>,
}
impl FastshellBackend {
pub fn new(fs: SharedFastshell) -> Self {
FastshellBackend {
fs,
base_cwd: std::sync::Mutex::new(None),
}
}
}
impl ShellBackend for FastshellBackend {
fn run(
&self,
command: &str,
stdin_input: Option<&str>,
timeout_secs: u64,
_idle_timeout_secs: u64,
_cwd: &std::path::Path,
) -> CmdOutput {
let effective = match stdin_input {
Some(s) if !s.is_empty() => {
let escaped = s.replace('\\', "\\\\").replace('\'', "'\\''");
format!("printf '%s' '{escaped}' | {command}")
}
_ => command.to_string(),
};
let lock_timeout_secs = timeout_secs.max(5);
let timeout_ms = timeout_secs.saturating_mul(1000);
let fs_guard = {
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(lock_timeout_secs);
loop {
match self.fs.try_lock() {
Ok(g) => break g,
Err(std::sync::TryLockError::WouldBlock) => {
if std::time::Instant::now() > deadline {
return CmdOutput {
stdout: String::new(),
stderr: format!(
"fastshell: could not acquire lock after {lock_timeout_secs}s (busy)\n"
),
exit_code: 124,
};
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(std::sync::TryLockError::Poisoned(e)) => break e.into_inner(),
}
}
};
let r = {
let base = {
let mut b = self.base_cwd.lock().unwrap_or_else(|e| e.into_inner());
if b.is_none() {
let pwd = fs_guard.execute("pwd").stdout.trim().to_string();
*b = Some(if pwd.is_empty() { "/".to_string() } else { pwd });
}
b.clone().unwrap_or_else(|| "/".to_string())
};
fs_guard.execute_in_with_timeout(&base, &effective, timeout_ms)
};
CmdOutput {
stdout: r.stdout,
stderr: r.stderr,
exit_code: r.exit_code,
}
}
fn kind(&self) -> &'static str {
"fastshell"
}
fn ui_call(
&self,
method: &str,
args: &serde_json::Value,
) -> Option<std::result::Result<serde_json::Value, String>> {
let fs = self.fs.lock().ok()?;
Some(fs.ui_call(method, args))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn platform_default_is_native_on_desktop() {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
assert_eq!(BackendKind::platform_default(), BackendKind::Native);
}
#[test]
fn native_echo() {
let sh = NativeShell::new();
let out = sh.run("echo hello", None, 10, 0, std::path::Path::new("."));
assert_eq!(out.exit_code, 0);
assert!(out.stdout.contains("hello"));
}
#[test]
fn native_heredoc_works() {
let dir = std::env::temp_dir().join(format!("native_hd_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).unwrap();
let sh = NativeShell::new();
let out = sh.run(
"cat > f.txt << 'EOF'\nline1\nline2\nEOF\ncat f.txt",
None,
10,
0,
&dir,
);
assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
assert!(out.stdout.contains("line1"));
assert!(out.stdout.contains("line2"));
assert!(dir.join("f.txt").exists());
}
#[test]
fn native_real_cwd_and_pipes() {
let dir =
std::env::temp_dir().join(format!("native_cwd_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("data.txt"), "apple\nbanana\napple\n").unwrap();
let sh = NativeShell::new();
let out = sh.run("cat data.txt | grep apple | wc -l", None, 10, 0, &dir);
assert_eq!(out.exit_code, 0);
assert_eq!(out.stdout.trim(), "2");
}
#[test]
fn native_stdin_input() {
let sh = NativeShell::new();
let out = sh.run(
"cat",
Some("piped content"),
10,
0,
std::path::Path::new("."),
);
assert_eq!(out.exit_code, 0);
assert!(out.stdout.contains("piped content"));
}
#[test]
fn native_nonzero_exit() {
let sh = NativeShell::new();
let out = sh.run("exit 3", None, 10, 0, std::path::Path::new("."));
assert_eq!(out.exit_code, 3);
}
#[test]
fn native_timeout_kills() {
let sh = NativeShell::new();
let start = std::time::Instant::now();
let out = sh.run("sleep 10", None, 1, 0, std::path::Path::new("."));
let elapsed = start.elapsed();
assert!(
elapsed.as_secs() < 8,
"timeout should have fired; elapsed={elapsed:?}"
);
assert_eq!(
out.exit_code, 124,
"unexpected exit code; stdout={}, stderr={}",
out.stdout, out.stderr
);
}
#[test]
fn native_python_real() {
let sh = NativeShell::new();
let out = sh.run(
"python3 -c \"print(6*7)\"",
None,
10,
0,
std::path::Path::new("."),
);
if out.exit_code == 0 {
assert!(out.stdout.contains("42"));
}
}
#[test]
fn fastshell_backend_resets_cwd_between_calls() {
let dir =
std::env::temp_dir().join(format!("fs_backend_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).unwrap();
let mut fs = Fastshell::new();
let mut cfg = fastshell::Config::default();
cfg.sandbox_path = dir.to_string_lossy().to_string();
cfg.command_timeout_ms = 5_000;
fs.init(cfg).unwrap();
let backend = FastshellBackend::new(Arc::new(Mutex::new(fs)));
let base = backend
.run("pwd", None, 5, 0, &dir)
.stdout
.trim()
.to_string();
assert!(!base.is_empty(), "pwd should return the baseline cwd");
let _ = backend.run("cd ..", None, 5, 0, &dir);
let after = backend
.run("pwd", None, 5, 0, &dir)
.stdout
.trim()
.to_string();
assert_eq!(after, base, "cwd must reset to the baseline between calls");
}
#[test]
fn ui_call_without_plugin_returns_host_error() {
let fs = std::sync::Arc::new(std::sync::Mutex::new(Fastshell::new()));
let backend = FastshellBackend::new(fs);
match backend.ui_call("ui.backends", &serde_json::json!({})) {
Some(Err(e)) => assert!(e.contains("no host plugin"), "err={e}"),
other => panic!("unexpected: {other:?}"),
}
}
}