use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use crate::error::{CwError, Result};
pub const SPEC_VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct SpawnSpec {
pub version: u32,
pub argv: Vec<String>,
pub cwd: PathBuf,
pub self_unlink: bool,
}
impl SpawnSpec {
pub fn new(argv: Vec<String>, cwd: PathBuf) -> Self {
Self {
version: SPEC_VERSION,
argv,
cwd,
self_unlink: true,
}
}
}
pub fn materialize(spec: &SpawnSpec) -> Result<(String, PathBuf)> {
materialize_in_dir(spec, &std::env::temp_dir())
}
pub fn materialize_in_dir(spec: &SpawnSpec, dir: &Path) -> Result<(String, PathBuf)> {
fs::create_dir_all(dir)?;
let named = tempfile::Builder::new()
.prefix("gw-spawn-")
.suffix(".json")
.rand_bytes(16)
.tempfile_in(dir)?;
let json = serde_json::to_vec(spec)?;
{
let mut f = named.as_file();
f.write_all(&json)?;
f.flush()?;
}
let (_file, path) = named.keep().map_err(|e| e.error)?;
let shell_line = format!("gw _spawn-ai {}", quote_path_for_shell(&path));
Ok((shell_line, path))
}
fn quote_path_for_shell(path: &Path) -> String {
let s = path.to_string_lossy();
let safe = s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':'));
if safe {
s.into_owned()
} else {
format!("\"{}\"", s)
}
}
pub fn read_spec(path: &Path) -> Result<SpawnSpec> {
let bytes = fs::read(path)
.map_err(|e| CwError::Other(format!("spawn-ai: read {} failed: {}", path.display(), e)))?;
let spec: SpawnSpec = serde_json::from_slice(&bytes)
.map_err(|e| CwError::Other(format!("spawn-ai: parse {} failed: {}", path.display(), e)))?;
if spec.version != SPEC_VERSION {
return Err(CwError::Other(format!(
"spawn-ai: unsupported spawn spec version: {} (expected {})",
spec.version, SPEC_VERSION
)));
}
if spec.argv.is_empty() {
return Err(CwError::Other("spawn-ai: spawn spec has empty argv".into()));
}
Ok(spec)
}
pub fn execute(spec_path: &Path) -> Result<()> {
let spec = read_spec(spec_path)?;
if spec.self_unlink {
let _ = fs::remove_file(spec_path);
}
std::env::set_current_dir(&spec.cwd).map_err(|e| {
CwError::Other(format!(
"spawn-ai: chdir to {} failed: {}",
spec.cwd.display(),
e
))
})?;
let program = &spec.argv[0];
let args = &spec.argv[1..];
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = std::process::Command::new(program).args(args).exec();
eprintln!("spawn-ai: exec {} failed: {}", program, err);
std::process::exit(127);
}
#[cfg(windows)]
{
let status = match std::process::Command::new(program).args(args).status() {
Ok(s) => s,
Err(e) => {
eprintln!("spawn-ai: spawn {} failed: {}", program, e);
std::process::exit(127);
}
};
let code = status.code().unwrap_or(1);
std::process::exit(code);
}
}
pub fn sweep_stale() {
sweep_stale_in(&std::env::temp_dir(), Duration::from_secs(24 * 3600));
}
fn sweep_stale_in(dir: &Path, max_age: Duration) {
let entries = match fs::read_dir(dir) {
Ok(it) => it,
Err(_) => return,
};
let now = SystemTime::now();
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("gw-spawn-") || !name_str.ends_with(".json") {
continue;
}
let metadata = match fs::symlink_metadata(entry.path()) {
Ok(m) => m,
Err(_) => continue,
};
if !metadata.is_file() {
continue;
}
let mtime = match metadata.modified() {
Ok(t) => t,
Err(_) => continue,
};
if now.duration_since(mtime).unwrap_or_default() > max_age {
let _ = fs::remove_file(entry.path());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_preserves_killer_prompts() {
let killers = [
r#"Fix the bug where user can "escape" quotes"#,
r#"$(rm -rf /) — literal, not an expansion"#,
"한글 테스트 🚀 ${PATH}",
"multi\nline\n<<'EOF'\nnot a heredoc\nEOF\n",
r"C:\Users\foo\bar \\path\\with\\backslashes",
"`backtick` and 'single' and \"double\"",
];
for prompt in killers {
let spec = SpawnSpec::new(
vec!["claude".into(), "--print".into(), prompt.into()],
PathBuf::from("/tmp/wt"),
);
let json = serde_json::to_string(&spec).unwrap();
let back: SpawnSpec = serde_json::from_str(&json).unwrap();
assert_eq!(spec, back, "round-trip mismatch for: {:?}", prompt);
assert_eq!(back.argv[2], prompt);
}
}
#[test]
fn large_prompt_round_trips() {
let big = "x".repeat(64 * 1024);
let spec = SpawnSpec::new(vec!["claude".into(), big.clone()], PathBuf::from("/tmp"));
let json = serde_json::to_string(&spec).unwrap();
let back: SpawnSpec = serde_json::from_str(&json).unwrap();
assert_eq!(back.argv[1], big);
}
#[test]
fn materialize_writes_spec_and_returns_shell_line() {
let dir = tempfile::tempdir().unwrap();
let spec = SpawnSpec::new(
vec!["/bin/echo".into(), "hello \"world\"".into()],
dir.path().to_path_buf(),
);
let (shell_line, spec_path) = materialize_in_dir(&spec, dir.path()).unwrap();
assert!(shell_line.starts_with("gw _spawn-ai "));
assert!(
!shell_line.starts_with("exec "),
"shell_line must not use exec: {:?}",
shell_line
);
assert!(spec_path.exists());
let loaded: SpawnSpec =
serde_json::from_str(&std::fs::read_to_string(&spec_path).unwrap()).unwrap();
assert_eq!(loaded, spec);
}
#[test]
fn materialize_filename_is_shell_safe() {
let dir = tempfile::tempdir().unwrap();
let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
let (line, _path) = materialize_in_dir(&spec, dir.path()).unwrap();
let tail = line.strip_prefix("gw _spawn-ai ").unwrap();
let quoted = tail.starts_with('"') && tail.ends_with('"');
let bare_safe = tail
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':' | '\\'));
assert!(quoted || bare_safe, "unsafe tail: {:?}", tail);
}
#[cfg(unix)]
#[test]
fn materialize_file_is_mode_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
let (_line, path) = materialize_in_dir(&spec, dir.path()).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {:o}", mode);
}
#[test]
fn quote_path_for_shell_quotes_windows_backslashes() {
use std::path::PathBuf;
let win = PathBuf::from(r"C:\Users\me\AppData\Local\Temp\gw-spawn-abcdef0123456789.json");
let out = super::quote_path_for_shell(&win);
assert!(
out.starts_with('"') && out.ends_with('"'),
"expected quoted, got {:?}",
out
);
}
#[test]
fn quote_path_for_shell_bare_for_unix_paths() {
use std::path::PathBuf;
let unix = PathBuf::from("/tmp/gw-spawn-abcdef0123456789.json");
let out = super::quote_path_for_shell(&unix);
assert!(!out.starts_with('"'), "expected bare, got {:?}", out);
}
#[test]
fn read_spec_rejects_wrong_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(
&path,
r#"{"version":999,"argv":["x"],"cwd":"/","self_unlink":false}"#,
)
.unwrap();
let err = read_spec(&path).unwrap_err();
assert!(format!("{err}").contains("unsupported spawn spec version"));
}
#[test]
fn read_spec_rejects_empty_argv() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.json");
std::fs::write(
&path,
r#"{"version":1,"argv":[],"cwd":"/","self_unlink":false}"#,
)
.unwrap();
let err = read_spec(&path).unwrap_err();
assert!(format!("{err}").contains("empty argv"));
}
#[test]
fn read_spec_round_trip() {
let dir = tempfile::tempdir().unwrap();
let spec = SpawnSpec::new(
vec!["/bin/echo".into(), "hi".into()],
dir.path().to_path_buf(),
);
let path = dir.path().join("ok.json");
std::fs::write(&path, serde_json::to_vec(&spec).unwrap()).unwrap();
let loaded = read_spec(&path).unwrap();
assert_eq!(loaded, spec);
}
#[test]
fn sweep_stale_removes_old_spec_files_only() {
use std::time::{Duration, SystemTime};
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("gw-spawn-old.json");
std::fs::write(&old, "{}").unwrap();
let past = SystemTime::now() - Duration::from_secs(48 * 3600);
filetime::set_file_mtime(&old, filetime::FileTime::from_system_time(past)).unwrap();
let recent = dir.path().join("gw-spawn-recent.json");
std::fs::write(&recent, "{}").unwrap();
let unrelated = dir.path().join("something-else.json");
std::fs::write(&unrelated, "{}").unwrap();
filetime::set_file_mtime(&unrelated, filetime::FileTime::from_system_time(past)).unwrap();
sweep_stale_in(dir.path(), Duration::from_secs(24 * 3600));
assert!(!old.exists(), "old gw-spawn file should be removed");
assert!(recent.exists(), "recent gw-spawn file should remain");
assert!(unrelated.exists(), "unrelated file should be untouched");
}
}