1use std::collections::BTreeMap;
22use std::fs;
23use std::io::Write;
24use std::path::{Path, PathBuf};
25use std::time::{Duration, SystemTime};
26
27use serde::{Deserialize, Serialize};
28
29use crate::error::{CwError, Result};
30
31pub const SPEC_VERSION: u32 = 1;
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct SpawnSpec {
35 pub version: u32,
36 pub argv: Vec<String>,
37 pub cwd: PathBuf,
38 pub self_unlink: bool,
39 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
44 pub env: BTreeMap<String, String>,
45}
46
47impl SpawnSpec {
48 pub fn new(argv: Vec<String>, cwd: PathBuf) -> Self {
49 Self {
50 version: SPEC_VERSION,
51 argv,
52 cwd,
53 self_unlink: true,
54 env: BTreeMap::new(),
55 }
56 }
57
58 pub fn with_env(mut self, env: BTreeMap<String, String>) -> Self {
60 self.env = env;
61 self
62 }
63}
64
65fn write_last_to_git_dir(spec: &SpawnSpec, git_dir: &Path) -> Result<()> {
76 let mut persistent = spec.clone();
77 persistent.self_unlink = false;
80 let path = git_dir.join("gw-spawn-last.json");
81 let tmp = git_dir.join("gw-spawn-last.json.tmp");
82 let json = serde_json::to_vec(&persistent)?;
83 fs::write(&tmp, json)?;
84 fs::rename(&tmp, &path)?;
85 Ok(())
86}
87
88fn git_dir_for(cwd: &Path) -> Result<PathBuf> {
92 let output = std::process::Command::new("git")
93 .args(["rev-parse", "--git-dir"])
94 .current_dir(cwd)
95 .output()
96 .map_err(|e| CwError::Other(format!("spawn-ai: git rev-parse failed: {}", e)))?;
97 if !output.status.success() {
98 return Err(CwError::Other(format!(
99 "spawn-ai: not a git worktree: {}",
100 cwd.display()
101 )));
102 }
103 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
104 let p = PathBuf::from(&raw);
105 let absolute = if p.is_absolute() { p } else { cwd.join(p) };
106 Ok(absolute)
107}
108
109pub fn materialize(spec: &SpawnSpec) -> Result<(String, PathBuf)> {
112 materialize_in_dir(spec, &std::env::temp_dir())
113}
114
115pub fn materialize_in_dir(spec: &SpawnSpec, dir: &Path) -> Result<(String, PathBuf)> {
117 fs::create_dir_all(dir)?;
118
119 let named = tempfile::Builder::new()
121 .prefix("gw-spawn-")
122 .suffix(".json")
123 .rand_bytes(16)
124 .tempfile_in(dir)?;
125
126 let json = serde_json::to_vec(spec)?;
127 {
128 let mut f = named.as_file();
129 f.write_all(&json)?;
130 f.flush()?;
131 }
132
133 let (_file, path) = named.keep().map_err(|e| e.error)?;
136
137 if let Ok(git_dir) = git_dir_for(&spec.cwd) {
142 let _ = write_last_to_git_dir(spec, &git_dir);
143 }
144
145 let self_exe = if let Some(s) = std::env::var_os("CW_SPAWN_AI_BIN") {
161 quote_path_for_shell(Path::new(&s))
162 } else {
163 let exe = std::env::current_exe().map_err(|e| {
164 CwError::Other(format!(
165 "spawn-ai: cannot resolve current executable path: {}. \
166 Set CW_SPAWN_AI_BIN to override.",
167 e
168 ))
169 })?;
170 quote_path_for_shell(&exe)
171 };
172 let shell_line = format!("{} _spawn-ai {}", self_exe, quote_path_for_shell(&path));
173 Ok((shell_line, path))
174}
175
176pub(crate) fn quote_path_for_shell(path: &Path) -> String {
182 let s = path.to_string_lossy();
183 let safe = s
188 .chars()
189 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':'));
190 if safe {
191 s.into_owned()
192 } else {
193 format!("\"{}\"", s)
194 }
195}
196
197pub fn read_spec(path: &Path) -> Result<SpawnSpec> {
201 let bytes = fs::read(path)
202 .map_err(|e| CwError::Other(format!("spawn-ai: read {} failed: {}", path.display(), e)))?;
203 let spec: SpawnSpec = serde_json::from_slice(&bytes)
204 .map_err(|e| CwError::Other(format!("spawn-ai: parse {} failed: {}", path.display(), e)))?;
205 if spec.version != SPEC_VERSION {
206 return Err(CwError::Other(format!(
207 "spawn-ai: unsupported spawn spec version: {} (expected {})",
208 spec.version, SPEC_VERSION
209 )));
210 }
211 if spec.argv.is_empty() {
212 return Err(CwError::Other("spawn-ai: spawn spec has empty argv".into()));
213 }
214 Ok(spec)
215}
216
217pub fn execute(spec_path: &Path) -> Result<()> {
227 let spec = read_spec(spec_path)?;
228
229 if spec.self_unlink {
230 let _ = fs::remove_file(spec_path);
232 }
233
234 std::env::set_current_dir(&spec.cwd).map_err(|e| {
235 CwError::Other(format!(
236 "spawn-ai: chdir to {} failed: {}",
237 spec.cwd.display(),
238 e
239 ))
240 })?;
241
242 let program = &spec.argv[0];
243 let args = &spec.argv[1..];
244
245 #[cfg(unix)]
246 {
247 use std::os::unix::process::CommandExt;
248 let mut command = std::process::Command::new(program);
249 command.args(args);
250 for (k, v) in &spec.env {
251 command.env(k, v);
255 }
256 let err = command.exec();
257 eprintln!("spawn-ai: exec {} failed: {}", program, err);
259 std::process::exit(127);
260 }
261
262 #[cfg(windows)]
263 {
264 let mut command = std::process::Command::new(program);
267 command.args(args);
268 for (k, v) in &spec.env {
269 command.env(k, v);
270 }
271 let status = match command.status() {
272 Ok(s) => s,
273 Err(e) => {
274 eprintln!("spawn-ai: spawn {} failed: {}", program, e);
275 std::process::exit(127);
276 }
277 };
278 let code = status.code().unwrap_or(1);
279 std::process::exit(code);
280 }
281}
282
283pub fn resolve_last_for_cwd() -> Result<PathBuf> {
287 let cwd = std::env::current_dir()
288 .map_err(|e| CwError::Other(format!("spawn-ai: cannot read current directory: {}", e)))?;
289 let git_dir = git_dir_for(&cwd)?;
290 let last = git_dir.join("gw-spawn-last.json");
291 if !last.exists() {
292 return Err(CwError::Other(format!(
293 "spawn-ai: no recent spawn found for this worktree (looked for {})",
294 last.display()
295 )));
296 }
297 Ok(last)
298}
299
300pub fn sweep_stale() {
304 sweep_stale_in(&std::env::temp_dir(), Duration::from_secs(24 * 3600));
305}
306
307fn sweep_stale_in(dir: &Path, max_age: Duration) {
308 let entries = match fs::read_dir(dir) {
309 Ok(it) => it,
310 Err(_) => return,
311 };
312 let now = SystemTime::now();
313 for entry in entries.flatten() {
314 let name = entry.file_name();
315 let name_str = name.to_string_lossy();
316 if !name_str.starts_with("gw-spawn-") || !name_str.ends_with(".json") {
317 continue;
318 }
319 let metadata = match fs::symlink_metadata(entry.path()) {
322 Ok(m) => m,
323 Err(_) => continue,
324 };
325 if !metadata.is_file() {
326 continue;
327 }
328 let mtime = match metadata.modified() {
329 Ok(t) => t,
330 Err(_) => continue,
331 };
332 if now.duration_since(mtime).unwrap_or_default() > max_age {
333 let _ = fs::remove_file(entry.path());
334 }
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 struct CwdGuard(std::path::PathBuf);
343 impl Drop for CwdGuard {
344 fn drop(&mut self) {
345 let _ = std::env::set_current_dir(&self.0);
346 }
347 }
348 impl CwdGuard {
349 fn enter(target: &std::path::Path) -> Self {
350 let prev = std::env::current_dir().unwrap();
351 std::env::set_current_dir(target).unwrap();
352 Self(prev)
353 }
354 }
355
356 #[test]
357 fn round_trip_preserves_killer_prompts() {
358 let killers = [
359 r#"Fix the bug where user can "escape" quotes"#,
360 r#"$(rm -rf /) — literal, not an expansion"#,
361 "한글 테스트 🚀 ${PATH}",
362 "multi\nline\n<<'EOF'\nnot a heredoc\nEOF\n",
363 r"C:\Users\foo\bar \\path\\with\\backslashes",
364 "`backtick` and 'single' and \"double\"",
365 ];
366 for prompt in killers {
367 let spec = SpawnSpec::new(
368 vec!["claude".into(), "--print".into(), prompt.into()],
369 PathBuf::from("/tmp/wt"),
370 );
371 let json = serde_json::to_string(&spec).unwrap();
372 let back: SpawnSpec = serde_json::from_str(&json).unwrap();
373 assert_eq!(spec, back, "round-trip mismatch for: {:?}", prompt);
374 assert_eq!(back.argv[2], prompt);
375 }
376 }
377
378 #[test]
379 fn large_prompt_round_trips() {
380 let big = "x".repeat(64 * 1024);
381 let spec = SpawnSpec::new(vec!["claude".into(), big.clone()], PathBuf::from("/tmp"));
382 let json = serde_json::to_string(&spec).unwrap();
383 let back: SpawnSpec = serde_json::from_str(&json).unwrap();
384 assert_eq!(back.argv[1], big);
385 }
386
387 #[test]
388 fn materialize_writes_spec_and_returns_shell_line() {
389 let dir = tempfile::tempdir().unwrap();
390 let spec = SpawnSpec::new(
391 vec!["/bin/echo".into(), "hello \"world\"".into()],
392 dir.path().to_path_buf(),
393 );
394 let (shell_line, spec_path) = materialize_in_dir(&spec, dir.path()).unwrap();
395
396 let exe = std::env::current_exe().unwrap();
401 let exe_token = quote_path_for_shell(&exe);
402 assert!(
403 shell_line.starts_with(&format!("{} _spawn-ai ", exe_token)),
404 "expected line to start with {:?} _spawn-ai, got {:?}",
405 exe_token,
406 shell_line
407 );
408 assert!(
412 !shell_line.starts_with("exec "),
413 "shell_line must not use exec: {:?}",
414 shell_line
415 );
416 assert!(spec_path.exists());
417
418 let loaded: SpawnSpec =
419 serde_json::from_str(&std::fs::read_to_string(&spec_path).unwrap()).unwrap();
420 assert_eq!(loaded, spec);
421 }
422
423 #[test]
424 fn materialize_filename_is_shell_safe() {
425 let dir = tempfile::tempdir().unwrap();
426 let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
427 let (line, _path) = materialize_in_dir(&spec, dir.path()).unwrap();
428
429 let exe = std::env::current_exe().unwrap();
434 let exe_token = quote_path_for_shell(&exe);
435 let prefix = format!("{} _spawn-ai ", exe_token);
436 let tail = line
437 .strip_prefix(&prefix)
438 .unwrap_or_else(|| panic!("line does not start with {:?}: {:?}", prefix, line));
439 let quoted = tail.starts_with('"') && tail.ends_with('"');
440 let bare_safe = tail
441 .chars()
442 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':' | '\\'));
443 assert!(quoted || bare_safe, "unsafe tail: {:?}", tail);
444 }
445
446 #[cfg(unix)]
447 #[test]
448 fn materialize_file_is_mode_0600() {
449 use std::os::unix::fs::PermissionsExt;
450 let dir = tempfile::tempdir().unwrap();
451 let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
452 let (_line, path) = materialize_in_dir(&spec, dir.path()).unwrap();
453
454 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
455 assert_eq!(mode, 0o600, "expected 0600, got {:o}", mode);
456 }
457
458 #[test]
459 fn quote_path_for_shell_quotes_windows_backslashes() {
460 use std::path::PathBuf;
461 let win = PathBuf::from(r"C:\Users\me\AppData\Local\Temp\gw-spawn-abcdef0123456789.json");
462 let out = super::quote_path_for_shell(&win);
463 assert!(
465 out.starts_with('"') && out.ends_with('"'),
466 "expected quoted, got {:?}",
467 out
468 );
469 }
470
471 #[test]
472 fn quote_path_for_shell_bare_for_unix_paths() {
473 use std::path::PathBuf;
474 let unix = PathBuf::from("/tmp/gw-spawn-abcdef0123456789.json");
475 let out = super::quote_path_for_shell(&unix);
476 assert!(!out.starts_with('"'), "expected bare, got {:?}", out);
477 }
478
479 #[test]
480 fn read_spec_rejects_wrong_version() {
481 let dir = tempfile::tempdir().unwrap();
482 let path = dir.path().join("bad.json");
483 std::fs::write(
484 &path,
485 r#"{"version":999,"argv":["x"],"cwd":"/","self_unlink":false}"#,
486 )
487 .unwrap();
488 let err = read_spec(&path).unwrap_err();
489 assert!(format!("{err}").contains("unsupported spawn spec version"));
490 }
491
492 #[test]
493 fn read_spec_rejects_empty_argv() {
494 let dir = tempfile::tempdir().unwrap();
495 let path = dir.path().join("empty.json");
496 std::fs::write(
497 &path,
498 r#"{"version":1,"argv":[],"cwd":"/","self_unlink":false}"#,
499 )
500 .unwrap();
501 let err = read_spec(&path).unwrap_err();
502 assert!(format!("{err}").contains("empty argv"));
503 }
504
505 #[test]
506 fn read_spec_round_trip() {
507 let dir = tempfile::tempdir().unwrap();
508 let spec = SpawnSpec::new(
509 vec!["/bin/echo".into(), "hi".into()],
510 dir.path().to_path_buf(),
511 );
512 let path = dir.path().join("ok.json");
513 std::fs::write(&path, serde_json::to_vec(&spec).unwrap()).unwrap();
514 let loaded = read_spec(&path).unwrap();
515 assert_eq!(loaded, spec);
516 }
517
518 #[test]
519 fn materialize_writes_last_copy_into_git_dir() {
520 let dir = tempfile::tempdir().unwrap();
521 let worktree = dir.path();
522 let status = std::process::Command::new("git")
523 .args(["init", "-q"])
524 .current_dir(worktree)
525 .status()
526 .unwrap();
527 assert!(status.success());
528 let git_dir = worktree.join(".git");
529
530 let spec = SpawnSpec::new(
531 vec!["claude".into(), "--print".into(), "hello".into()],
532 worktree.to_path_buf(),
533 );
534
535 let temp = tempfile::tempdir().unwrap();
536 let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
537
538 let last = git_dir.join("gw-spawn-last.json");
539 assert!(
540 last.exists(),
541 "expected persistent copy at {}",
542 last.display()
543 );
544
545 let loaded: SpawnSpec =
546 serde_json::from_str(&std::fs::read_to_string(&last).unwrap()).unwrap();
547 assert!(
548 !loaded.self_unlink,
549 "persistent copy must have self_unlink=false"
550 );
551 assert_eq!(loaded.argv, spec.argv);
552 assert_eq!(loaded.cwd, spec.cwd);
553 }
554
555 #[test]
556 fn read_spec_does_not_observe_unlink_for_persistent_copy() {
557 let dir = tempfile::tempdir().unwrap();
562 let worktree = dir.path();
563 let status = std::process::Command::new("git")
564 .args(["init", "-q"])
565 .current_dir(worktree)
566 .status()
567 .unwrap();
568 assert!(status.success());
569
570 let spec = SpawnSpec::new(vec!["/bin/true".into()], worktree.to_path_buf());
571 let temp = tempfile::tempdir().unwrap();
572 let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
573
574 let last = worktree.join(".git").join("gw-spawn-last.json");
575 let loaded = read_spec(&last).expect("read_spec should succeed");
576 assert!(
577 !loaded.self_unlink,
578 "persistent copy must keep self_unlink=false"
579 );
580 }
581
582 #[test]
583 fn sweep_stale_removes_old_spec_files_only() {
584 use std::time::{Duration, SystemTime};
585 let dir = tempfile::tempdir().unwrap();
586
587 let old = dir.path().join("gw-spawn-old.json");
589 std::fs::write(&old, "{}").unwrap();
590 let past = SystemTime::now() - Duration::from_secs(48 * 3600);
591 filetime::set_file_mtime(&old, filetime::FileTime::from_system_time(past)).unwrap();
592
593 let recent = dir.path().join("gw-spawn-recent.json");
595 std::fs::write(&recent, "{}").unwrap();
596
597 let unrelated = dir.path().join("something-else.json");
599 std::fs::write(&unrelated, "{}").unwrap();
600 filetime::set_file_mtime(&unrelated, filetime::FileTime::from_system_time(past)).unwrap();
601
602 sweep_stale_in(dir.path(), Duration::from_secs(24 * 3600));
603
604 assert!(!old.exists(), "old gw-spawn file should be removed");
605 assert!(recent.exists(), "recent gw-spawn file should remain");
606 assert!(unrelated.exists(), "unrelated file should be untouched");
607 }
608
609 #[test]
610 #[serial_test::serial]
611 fn resolve_last_for_cwd_finds_persisted_spec() {
612 let dir = tempfile::tempdir().unwrap();
613 let worktree = dir.path();
614 let status = std::process::Command::new("git")
615 .args(["init", "-q"])
616 .current_dir(worktree)
617 .status()
618 .unwrap();
619 assert!(status.success());
620
621 let spec = SpawnSpec::new(
622 vec!["/bin/echo".into(), "hi".into()],
623 worktree.to_path_buf(),
624 );
625 let temp = tempfile::tempdir().unwrap();
626 let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
627
628 let _guard = CwdGuard::enter(worktree);
629 let resolved = resolve_last_for_cwd().expect("resolve_last_for_cwd should succeed");
630 assert!(
631 resolved.exists(),
632 "resolved path must exist: {}",
633 resolved.display()
634 );
635 assert!(
636 resolved.ends_with("gw-spawn-last.json"),
637 "unexpected resolved path: {}",
638 resolved.display()
639 );
640 }
641
642 #[test]
643 #[serial_test::serial]
644 fn resolve_last_for_cwd_errors_when_no_spec_persisted() {
645 let dir = tempfile::tempdir().unwrap();
646 let worktree = dir.path();
647 let status = std::process::Command::new("git")
648 .args(["init", "-q"])
649 .current_dir(worktree)
650 .status()
651 .unwrap();
652 assert!(status.success());
653
654 let _guard = CwdGuard::enter(worktree);
655 let result = resolve_last_for_cwd();
656
657 let err = result.unwrap_err();
658 let msg = format!("{err}");
659 assert!(
660 msg.contains("no recent spawn") || msg.contains("gw-spawn-last.json"),
661 "unexpected error: {}",
662 msg
663 );
664 assert!(msg.starts_with("spawn-ai:"), "missing prefix: {}", msg);
665 }
666
667 #[test]
668 #[serial_test::serial]
669 fn resolve_last_for_cwd_errors_outside_a_git_worktree() {
670 let dir = tempfile::tempdir().unwrap();
671
672 let _guard = CwdGuard::enter(dir.path());
673 let result = resolve_last_for_cwd();
674
675 let err = result.unwrap_err();
676 let msg = format!("{err}");
677 assert!(msg.starts_with("spawn-ai:"), "missing prefix: {}", msg);
678 }
679}