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