1use fastshell::Fastshell;
20use std::sync::{Arc, Mutex};
21
22#[derive(Debug, Clone)]
24pub struct CmdOutput {
25 pub stdout: String,
26 pub stderr: String,
27 pub exit_code: i32,
28}
29
30pub trait ShellBackend: Send + Sync {
35 fn run(
36 &self,
37 command: &str,
38 stdin_input: Option<&str>,
39 timeout_secs: u64,
40 idle_timeout_secs: u64,
41 cwd: &std::path::Path,
42 ) -> CmdOutput;
43
44 fn kind(&self) -> &'static str;
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum BackendKind {
51 Native,
53 Fastshell,
55}
56
57impl BackendKind {
58 pub fn platform_default() -> BackendKind {
61 if cfg!(any(target_os = "android", target_os = "ios")) {
62 BackendKind::Fastshell
63 } else {
64 BackendKind::Native
65 }
66 }
67}
68
69pub struct NativeShell;
73
74impl NativeShell {
75 pub fn new() -> Self {
76 NativeShell
77 }
78}
79
80impl Default for NativeShell {
81 fn default() -> Self {
82 NativeShell::new()
83 }
84}
85
86impl ShellBackend for NativeShell {
87 fn run(
88 &self,
89 command: &str,
90 stdin_input: Option<&str>,
91 timeout_secs: u64,
92 idle_timeout_secs: u64,
93 cwd: &std::path::Path,
94 ) -> CmdOutput {
95 use std::io::Write;
96 use std::process::{Command, Stdio};
97
98 let mut cmd = if cfg!(target_os = "windows") {
100 let mut c = Command::new("cmd");
101 c.arg("/C").arg(command);
102 c
103 } else {
104 let mut c = Command::new("sh");
105 c.arg("-c").arg(command);
106 c
107 };
108 cmd.current_dir(cwd)
109 .stdin(if stdin_input.is_some() {
110 Stdio::piped()
111 } else {
112 Stdio::null()
113 })
114 .stdout(Stdio::piped())
115 .stderr(Stdio::piped());
116
117 let mut child = match cmd.spawn() {
118 Ok(c) => c,
119 Err(e) => {
120 return CmdOutput {
121 stdout: String::new(),
122 stderr: format!("failed to spawn shell: {e}"),
123 exit_code: 127,
124 }
125 }
126 };
127
128 if let Some(input) = stdin_input {
130 if let Some(mut stdin) = child.stdin.take() {
131 let _ = stdin.write_all(input.as_bytes());
132 }
134 }
135
136 let output = if timeout_secs > 0 || idle_timeout_secs > 0 {
138 wait_with_timeout(child, timeout_secs, idle_timeout_secs)
139 } else {
140 child.wait_with_output().map(|o| CmdOutput {
141 stdout: String::from_utf8_lossy(&o.stdout).to_string(),
142 stderr: String::from_utf8_lossy(&o.stderr).to_string(),
143 exit_code: o.status.code().unwrap_or(-1),
144 })
145 };
146
147 output.unwrap_or_else(|e| CmdOutput {
148 stdout: String::new(),
149 stderr: format!("shell execution error: {e}"),
150 exit_code: -1,
151 })
152 }
153
154 fn kind(&self) -> &'static str {
155 "native"
156 }
157}
158
159fn wait_with_timeout(
163 mut child: std::process::Child,
164 timeout_secs: u64,
165 idle_timeout_secs: u64,
166) -> std::io::Result<CmdOutput> {
167 use std::io::Read;
168 use std::sync::mpsc;
169
170 let stdout_pipe = child.stdout.take();
171 let stderr_pipe = child.stderr.take();
172
173 enum Chunk {
174 Stdout(String),
175 Stderr(String),
176 }
177
178 let (chunk_tx, chunk_rx) = mpsc::channel::<Chunk>();
179
180 if let Some(mut p) = stdout_pipe {
183 let tx = chunk_tx.clone();
184 std::thread::spawn(move || {
185 let mut buf = [0u8; 4096];
186 loop {
187 match p.read(&mut buf) {
188 Ok(0) => break,
189 Ok(n) => {
190 let s = String::from_utf8_lossy(&buf[..n]).to_string();
191 let _ = tx.send(Chunk::Stdout(s));
192 }
193 Err(_) => break,
194 }
195 }
196 });
197 }
198 if let Some(mut p) = stderr_pipe {
199 let tx = chunk_tx.clone();
200 std::thread::spawn(move || {
201 let mut buf = [0u8; 4096];
202 loop {
203 match p.read(&mut buf) {
204 Ok(0) => break,
205 Ok(n) => {
206 let s = String::from_utf8_lossy(&buf[..n]).to_string();
207 let _ = tx.send(Chunk::Stderr(s));
208 }
209 Err(_) => break,
210 }
211 }
212 });
213 }
214 drop(chunk_tx);
215
216 let pid = child.id();
217 let (exit_tx, exit_rx) = mpsc::channel();
218 std::thread::spawn(move || {
219 let status = child.wait();
220 let _ = exit_tx.send(status);
221 });
222
223 let mut stdout = String::new();
224 let mut stderr = String::new();
225 let deadline = if timeout_secs > 0 {
226 Some(std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs))
227 } else {
228 None
229 };
230 let idle_dur = std::time::Duration::from_secs(idle_timeout_secs);
231 let mut last_data = std::time::Instant::now();
232
233 loop {
234 let wait_dur = match deadline {
236 Some(dl) => {
237 let remaining = dl.saturating_duration_since(std::time::Instant::now());
238 if idle_timeout_secs > 0 {
240 let idle_remaining = idle_dur.saturating_sub(last_data.elapsed());
241 remaining.min(idle_remaining)
242 } else {
243 remaining
244 }
245 }
246 None => {
247 if idle_timeout_secs > 0 {
248 idle_dur.saturating_sub(last_data.elapsed())
249 } else {
250 std::time::Duration::from_secs(u64::MAX)
252 }
253 }
254 };
255
256 if wait_dur.is_zero() {
257 let reason = if deadline.map_or(false, |dl| dl <= std::time::Instant::now()) {
259 format!("command timed out after {timeout_secs}s")
260 } else {
261 format!("command idle timeout after {idle_timeout_secs}s")
262 };
263 #[cfg(unix)]
264 unsafe {
265 libc::kill(pid as i32, libc::SIGKILL);
266 }
267 #[cfg(windows)]
268 {
269 let _ = std::process::Command::new("taskkill")
270 .args(["/F", "/T", "/PID", &pid.to_string()])
271 .output();
272 }
273 return Ok(CmdOutput {
274 stdout,
275 stderr: format!("{stderr}\n{reason}"),
276 exit_code: 124,
277 });
278 }
279
280 match chunk_rx.recv_timeout(wait_dur) {
282 Ok(Chunk::Stdout(s)) => {
283 last_data = std::time::Instant::now();
284 stdout.push_str(&s);
285 }
286 Ok(Chunk::Stderr(s)) => {
287 last_data = std::time::Instant::now();
288 stderr.push_str(&s);
289 }
290 Err(mpsc::RecvTimeoutError::Disconnected) => {
291 break;
293 }
294 Err(mpsc::RecvTimeoutError::Timeout) => {
295 if let Ok(Ok(status)) = exit_rx.try_recv() {
297 return Ok(CmdOutput {
298 stdout,
299 stderr,
300 exit_code: status.code().unwrap_or(-1),
301 });
302 }
303 continue;
306 }
307 }
308
309 if let Ok(Ok(status)) = exit_rx.try_recv() {
311 while let Ok(chunk) = chunk_rx.try_recv() {
313 match chunk {
314 Chunk::Stdout(s) => stdout.push_str(&s),
315 Chunk::Stderr(s) => stderr.push_str(&s),
316 }
317 }
318 return Ok(CmdOutput {
319 stdout,
320 stderr,
321 exit_code: status.code().unwrap_or(-1),
322 });
323 }
324 }
325
326 match exit_rx.recv() {
328 Ok(Ok(status)) => Ok(CmdOutput {
329 stdout,
330 stderr,
331 exit_code: status.code().unwrap_or(-1),
332 }),
333 Ok(Err(e)) => Err(e),
334 Err(_) => Ok(CmdOutput {
335 stdout,
336 stderr: format!("{stderr}\nprocess disappeared"),
337 exit_code: 127,
338 }),
339 }
340}
341
342pub type SharedFastshell = Arc<Mutex<Fastshell>>;
346
347pub struct FastshellBackend {
349 fs: SharedFastshell,
350}
351
352impl FastshellBackend {
353 pub fn new(fs: SharedFastshell) -> Self {
354 FastshellBackend { fs }
355 }
356}
357
358impl ShellBackend for FastshellBackend {
359 fn run(
360 &self,
361 command: &str,
362 stdin_input: Option<&str>,
363 timeout_secs: u64,
364 _idle_timeout_secs: u64,
365 _cwd: &std::path::Path,
366 ) -> CmdOutput {
367 let effective = match stdin_input {
371 Some(s) if !s.is_empty() => {
372 let escaped = s.replace('\\', "\\\\").replace('\'', "'\\''");
373 format!("printf '%s' '{escaped}' | {command}")
374 }
375 _ => command.to_string(),
376 };
377
378 let lock_timeout_secs = timeout_secs.max(5);
382 let timeout_ms = timeout_secs.saturating_mul(1000);
383 let fs_guard = {
384 let deadline = std::time::Instant::now()
385 + std::time::Duration::from_secs(lock_timeout_secs);
386 loop {
387 match self.fs.try_lock() {
388 Ok(g) => break g,
389 Err(std::sync::TryLockError::WouldBlock) => {
390 if std::time::Instant::now() > deadline {
391 return CmdOutput {
392 stdout: String::new(),
393 stderr: format!(
394 "fastshell: could not acquire lock after {lock_timeout_secs}s (busy)\n"
395 ),
396 exit_code: 124,
397 };
398 }
399 std::thread::sleep(std::time::Duration::from_millis(50));
400 }
401 Err(std::sync::TryLockError::Poisoned(e)) => break e.into_inner(),
402 }
403 }
404 };
405
406 let r = if timeout_ms > 0 {
407 fs_guard.execute_with_timeout(&effective, timeout_ms)
408 } else {
409 fs_guard.execute(&effective)
410 };
411
412 CmdOutput {
413 stdout: r.stdout,
414 stderr: r.stderr,
415 exit_code: r.exit_code,
416 }
417 }
418
419 fn kind(&self) -> &'static str {
420 "fastshell"
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn platform_default_is_native_on_desktop() {
430 #[cfg(not(any(target_os = "android", target_os = "ios")))]
432 assert_eq!(BackendKind::platform_default(), BackendKind::Native);
433 }
434
435 #[test]
436 fn native_echo() {
437 let sh = NativeShell::new();
438 let out = sh.run("echo hello", None, 10, 0, std::path::Path::new("."));
439 assert_eq!(out.exit_code, 0);
440 assert!(out.stdout.contains("hello"));
441 }
442
443 #[test]
444 fn native_heredoc_works() {
445 let dir = std::env::temp_dir().join(format!("native_hd_{}", uuid::Uuid::new_v4().simple()));
447 std::fs::create_dir_all(&dir).unwrap();
448 let sh = NativeShell::new();
449 let out = sh.run(
450 "cat > f.txt << 'EOF'\nline1\nline2\nEOF\ncat f.txt",
451 None,
452 10,
453 0,
454 &dir,
455 );
456 assert_eq!(out.exit_code, 0, "stderr={}", out.stderr);
457 assert!(out.stdout.contains("line1"));
458 assert!(out.stdout.contains("line2"));
459 assert!(dir.join("f.txt").exists());
461 }
462
463 #[test]
464 fn native_real_cwd_and_pipes() {
465 let dir = std::env::temp_dir().join(format!("native_cwd_{}", uuid::Uuid::new_v4().simple()));
466 std::fs::create_dir_all(&dir).unwrap();
467 std::fs::write(dir.join("data.txt"), "apple\nbanana\napple\n").unwrap();
468 let sh = NativeShell::new();
469 let out = sh.run("cat data.txt | grep apple | wc -l", None, 10, 0, &dir);
470 assert_eq!(out.exit_code, 0);
471 assert_eq!(out.stdout.trim(), "2");
472 }
473
474 #[test]
475 fn native_stdin_input() {
476 let sh = NativeShell::new();
477 let out = sh.run("cat", Some("piped content"), 10, 0, std::path::Path::new("."));
478 assert_eq!(out.exit_code, 0);
479 assert!(out.stdout.contains("piped content"));
480 }
481
482 #[test]
483 fn native_nonzero_exit() {
484 let sh = NativeShell::new();
485 let out = sh.run("exit 3", None, 10, 0, std::path::Path::new("."));
486 assert_eq!(out.exit_code, 3);
487 }
488
489 #[test]
490 fn native_timeout_kills() {
491 let sh = NativeShell::new();
492 let start = std::time::Instant::now();
493 let out = sh.run("sleep 10", None, 1, 0, std::path::Path::new("."));
497 let elapsed = start.elapsed();
498 assert!(elapsed.as_secs() < 8, "timeout should have fired; elapsed={elapsed:?}");
499 assert_eq!(out.exit_code, 124, "unexpected exit code; stdout={}, stderr={}", out.stdout, out.stderr);
501 }
502
503 #[test]
504 fn native_python_real() {
505 let sh = NativeShell::new();
507 let out = sh.run("python3 -c \"print(6*7)\"", None, 10, 0, std::path::Path::new("."));
508 if out.exit_code == 0 {
509 assert!(out.stdout.contains("42"));
510 }
511 }
512}