use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
const MAX_OUTPUT_BYTES: usize = 256 * 1024;
const POLL_INTERVAL: Duration = Duration::from_millis(20);
static STRYKE_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
pub fn resolve_stryke() -> Option<PathBuf> {
STRYKE_PATH
.get_or_init(|| {
let exe_name = if cfg!(windows) {
"stryke.exe"
} else {
"stryke"
};
if let Some(p) = std::env::var_os("ZWIRE_STRYKE") {
let pb = PathBuf::from(p);
if pb.is_file() {
return Some(pb);
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(sib) = exe.parent().map(|d| d.join(exe_name)) {
if sib.is_file() {
return Some(sib);
}
}
}
if let Some(path) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&path) {
let cand = dir.join(exe_name);
if cand.is_file() {
return Some(cand);
}
}
}
let mut fixed: Vec<PathBuf> = Vec::new();
if let Some(home) = std::env::var_os("HOME") {
fixed.push(PathBuf::from(home).join(".cargo/bin").join(exe_name));
}
fixed.push(PathBuf::from("/opt/homebrew/bin/stryke"));
fixed.push(PathBuf::from("/usr/local/bin/stryke"));
fixed.into_iter().find(|c| c.is_file())
})
.clone()
}
pub struct RunOutcome {
pub stdout: String,
pub stderr: String,
pub code: Option<i32>,
pub timed_out: bool,
}
fn read_capped<R: Read>(mut r: R) -> String {
let mut buf = Vec::with_capacity(8192);
let mut chunk = [0u8; 8192];
loop {
match r.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
let room = MAX_OUTPUT_BYTES.saturating_sub(buf.len());
if room == 0 {
break;
}
buf.extend_from_slice(&chunk[..n.min(room)]);
}
Err(_) => break,
}
}
String::from_utf8_lossy(&buf).into_owned()
}
pub fn run_script(
script_path: &Path,
event_name: &str,
event_json: &str,
timeout: Duration,
) -> Result<RunOutcome, String> {
let stryke = resolve_stryke().ok_or_else(|| "stryke binary not found on PATH".to_string())?;
let mut child = Command::new(&stryke)
.arg(script_path)
.env("ZWIRE_EVENT", event_name)
.env("ZWIRE_HOOK", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("spawn stryke: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(event_json.as_bytes());
}
let out_pipe = child.stdout.take();
let err_pipe = child.stderr.take();
let out_t = std::thread::spawn(move || out_pipe.map(read_capped).unwrap_or_default());
let err_t = std::thread::spawn(move || err_pipe.map(read_capped).unwrap_or_default());
let start = Instant::now();
let mut timed_out = false;
let code;
loop {
match child.try_wait() {
Ok(Some(status)) => {
code = status.code();
break;
}
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
timed_out = true;
code = None;
break;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(e) => return Err(format!("wait stryke: {e}")),
}
}
let stdout = out_t.join().unwrap_or_default();
let stderr = err_t.join().unwrap_or_default();
Ok(RunOutcome {
stdout,
stderr,
code,
timed_out,
})
}
pub fn run_code(code: &str, stdin_data: &str, timeout: Duration) -> Result<RunOutcome, String> {
let stryke = resolve_stryke().ok_or_else(|| "stryke binary not found on PATH".to_string())?;
let mut child = Command::new(&stryke)
.arg("-E")
.arg(code)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("spawn stryke: {e}"))?;
if let Some(mut si) = child.stdin.take() {
let _ = si.write_all(stdin_data.as_bytes());
}
let out_pipe = child.stdout.take();
let err_pipe = child.stderr.take();
let out_t = std::thread::spawn(move || out_pipe.map(read_capped).unwrap_or_default());
let err_t = std::thread::spawn(move || err_pipe.map(read_capped).unwrap_or_default());
let start = Instant::now();
let mut timed_out = false;
let code_out;
loop {
match child.try_wait() {
Ok(Some(status)) => {
code_out = status.code();
break;
}
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
timed_out = true;
code_out = None;
break;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(e) => return Err(format!("wait stryke: {e}")),
}
}
Ok(RunOutcome {
stdout: out_t.join().unwrap_or_default(),
stderr: err_t.join().unwrap_or_default(),
code: code_out,
timed_out,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_capped_truncates_at_limit() {
let big = vec![b'x'; MAX_OUTPUT_BYTES + 4096];
let s = read_capped(&big[..]);
assert_eq!(s.len(), MAX_OUTPUT_BYTES);
}
#[test]
fn read_capped_passes_small_input() {
let s = read_capped(&b"hello"[..]);
assert_eq!(s, "hello");
}
#[test]
fn run_script_errors_without_binary() {
if resolve_stryke().is_none() {
let r = run_script(
Path::new("/nonexistent.st"),
"test",
"{}",
Duration::from_secs(1),
);
assert!(r.is_err());
}
}
#[test]
fn run_code_runs_inline_when_stryke_present() {
if resolve_stryke().is_some() {
let r = run_code("p 6 * 7", "", Duration::from_secs(5)).expect("run");
assert!(!r.timed_out);
assert!(r.stdout.contains("42"), "stdout: {:?}", r.stdout);
}
}
}