1use std::{
8 fs,
9 io::{self, BufRead, IsTerminal, Write},
10 path::{Path, PathBuf},
11 process::{self, Command as ProcessCommand},
12 time::{SystemTime, UNIX_EPOCH},
13};
14
15use anyhow::{Context, Result, bail};
16use dotenvy::Error as DotenvError;
17use tempfile::tempdir;
18
19use crate::config::{AppConfig, PlaygroundDefinition};
20
21const DOTENV_FILE_NAME: &str = ".env";
22const DEFAULT_PLAYGROUND_ID: &str = "__default__";
23
24pub fn run_playground(
47 config: &AppConfig,
48 playground_id: &str,
49 selected_agent_id: Option<&str>,
50 save_on_exit: bool,
51) -> Result<i32> {
52 let playground = config
53 .playgrounds
54 .get(playground_id)
55 .with_context(|| format!("unknown playground '{playground_id}'"))?;
56 let playground_config = config.resolve_playground_config(playground)?;
57 let agent_id = selected_agent_id.unwrap_or(&playground_config.default_agent);
58 let agent_command = config
59 .agents
60 .get(agent_id)
61 .with_context(|| format!("unknown agent '{agent_id}'"))?;
62 let load_env = playground_config.load_env;
63
64 let temp_dir = tempdir().context("failed to create temporary playground directory")?;
65 copy_playground_contents(playground, load_env, temp_dir.path())?;
66 let playground_env = load_playground_env(playground, load_env)?;
67
68 run_agent_in_directory(
69 temp_dir.path(),
70 agent_id,
71 agent_command,
72 playground_env,
73 save_on_exit,
74 &config.saved_playgrounds_dir,
75 playground_id,
76 )
77}
78
79pub fn run_default_playground(
84 config: &AppConfig,
85 selected_agent_id: Option<&str>,
86 save_on_exit: bool,
87) -> Result<i32> {
88 let default_agent = config
89 .playground_defaults
90 .default_agent
91 .as_deref()
92 .context("default playground config is missing default_agent")?;
93 let agent_id = selected_agent_id.unwrap_or(default_agent);
94 let agent_command = config
95 .agents
96 .get(agent_id)
97 .with_context(|| format!("unknown agent '{agent_id}'"))?;
98
99 let temp_dir = tempdir().context("failed to create temporary playground directory")?;
100
101 run_agent_in_directory(
102 temp_dir.path(),
103 agent_id,
104 agent_command,
105 Vec::new(),
106 save_on_exit,
107 &config.saved_playgrounds_dir,
108 DEFAULT_PLAYGROUND_ID,
109 )
110}
111
112fn run_agent_in_directory(
113 working_dir: &Path,
114 agent_id: &str,
115 agent_command: &str,
116 playground_env: Vec<(String, String)>,
117 save_on_exit: bool,
118 saved_playgrounds_dir: &Path,
119 playground_id: &str,
120) -> Result<i32> {
121 let status = build_agent_command(agent_command)
122 .envs(playground_env)
123 .current_dir(working_dir)
124 .status()
125 .with_context(|| format!("failed to start agent '{agent_id}'"))?;
126
127 let (exit_code, exited_normally) = exit_code_from_status(status)?;
128
129 let should_save = should_save_playground_snapshot(exited_normally, save_on_exit)
130 || (should_prompt_to_save_playground_snapshot(
131 exited_normally,
132 save_on_exit,
133 is_interactive_terminal(),
134 ) && prompt_to_save_playground_snapshot(io::stdin().lock(), &mut io::stdout().lock())?);
135
136 if should_save {
137 let saved_path =
138 save_playground_snapshot(working_dir, saved_playgrounds_dir, playground_id)?;
139 println!("saved playground snapshot to {}", saved_path.display());
140 }
141
142 Ok(exit_code)
143}
144
145fn should_save_playground_snapshot(exited_normally: bool, save_on_exit: bool) -> bool {
146 exited_normally && save_on_exit
147}
148
149fn should_prompt_to_save_playground_snapshot(
150 exited_normally: bool,
151 save_on_exit: bool,
152 is_interactive: bool,
153) -> bool {
154 exited_normally && !save_on_exit && is_interactive
155}
156
157fn is_interactive_terminal() -> bool {
158 io::stdin().is_terminal() && io::stdout().is_terminal()
159}
160
161fn prompt_to_save_playground_snapshot<R: BufRead, W: Write>(
162 mut input: R,
163 output: &mut W,
164) -> Result<bool> {
165 write!(output, "Keep temporary playground copy? [y/N] ")
166 .context("failed to write save prompt")?;
167 output.flush().context("failed to flush save prompt")?;
168
169 let mut response = String::new();
170 input
171 .read_line(&mut response)
172 .context("failed to read save prompt response")?;
173
174 let normalized = response.trim().to_ascii_lowercase();
175 Ok(matches!(normalized.as_str(), "y" | "yes"))
176}
177
178fn save_playground_snapshot(
179 source_dir: &Path,
180 saved_playgrounds_dir: &Path,
181 playground_id: &str,
182) -> Result<PathBuf> {
183 fs::create_dir_all(saved_playgrounds_dir)
184 .with_context(|| format!("failed to create {}", saved_playgrounds_dir.display()))?;
185
186 let destination = next_saved_playground_dir(saved_playgrounds_dir, playground_id);
187 fs::create_dir_all(&destination)
188 .with_context(|| format!("failed to create {}", destination.display()))?;
189 copy_directory_contents(source_dir, &destination)?;
190
191 Ok(destination)
192}
193
194fn next_saved_playground_dir(saved_playgrounds_dir: &Path, playground_id: &str) -> PathBuf {
195 let timestamp = SystemTime::now()
196 .duration_since(UNIX_EPOCH)
197 .unwrap_or_default()
198 .as_secs();
199 let base_name = format!("{playground_id}-{timestamp}");
200 let mut candidate = saved_playgrounds_dir.join(&base_name);
201 let mut suffix = 1;
202
203 while candidate.exists() {
204 candidate = saved_playgrounds_dir.join(format!("{base_name}-{suffix}"));
205 suffix += 1;
206 }
207
208 candidate
209}
210
211fn copy_playground_contents(
212 playground: &PlaygroundDefinition,
213 load_env: bool,
214 destination: &Path,
215) -> Result<()> {
216 for entry in fs::read_dir(&playground.directory)
217 .with_context(|| format!("failed to read {}", playground.directory.display()))?
218 {
219 let entry = entry.with_context(|| {
220 format!(
221 "failed to inspect an entry under {}",
222 playground.directory.display()
223 )
224 })?;
225 let source_path = entry.path();
226
227 if should_skip_playground_path(playground, load_env, &source_path) {
228 continue;
229 }
230
231 copy_path(&source_path, &destination.join(entry.file_name()))?;
232 }
233
234 Ok(())
235}
236
237fn should_skip_playground_path(
238 playground: &PlaygroundDefinition,
239 load_env: bool,
240 source_path: &Path,
241) -> bool {
242 source_path == playground.config_file
243 || (load_env
244 && source_path
245 .file_name()
246 .is_some_and(|name| name == DOTENV_FILE_NAME))
247}
248
249fn load_playground_env(
250 playground: &PlaygroundDefinition,
251 load_env: bool,
252) -> Result<Vec<(String, String)>> {
253 if !load_env {
254 return Ok(Vec::new());
255 }
256
257 let env_path = playground.directory.join(DOTENV_FILE_NAME);
258 match dotenvy::from_path_iter(&env_path) {
259 Ok(entries) => entries
260 .collect::<std::result::Result<Vec<_>, _>>()
261 .with_context(|| format!("failed to parse {}", env_path.display())),
262 Err(DotenvError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
263 Ok(Vec::new())
264 }
265 Err(error) => Err(error).with_context(|| format!("failed to load {}", env_path.display())),
266 }
267}
268
269fn copy_directory_contents(source: &Path, destination: &Path) -> Result<()> {
270 for entry in
271 fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
272 {
273 let entry = entry
274 .with_context(|| format!("failed to inspect an entry under {}", source.display()))?;
275 copy_path(&entry.path(), &destination.join(entry.file_name()))?;
276 }
277
278 Ok(())
279}
280
281fn copy_path(source: &Path, destination: &Path) -> Result<()> {
282 let metadata = fs::symlink_metadata(source)
283 .with_context(|| format!("failed to inspect {}", source.display()))?;
284
285 if metadata.is_dir() {
286 fs::create_dir_all(destination)
287 .with_context(|| format!("failed to create {}", destination.display()))?;
288
289 for entry in
290 fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
291 {
292 let entry = entry.with_context(|| {
293 format!("failed to inspect an entry under {}", source.display())
294 })?;
295 copy_path(&entry.path(), &destination.join(entry.file_name()))?;
296 }
297
298 return Ok(());
299 }
300
301 if metadata.is_file() {
302 if let Some(parent) = destination.parent() {
303 fs::create_dir_all(parent)
304 .with_context(|| format!("failed to create {}", parent.display()))?;
305 }
306
307 fs::copy(source, destination).with_context(|| {
308 format!(
309 "failed to copy {} to {}",
310 source.display(),
311 destination.display()
312 )
313 })?;
314 return Ok(());
315 }
316
317 bail!(
318 "unsupported file type while copying playground contents: {}",
319 source.display()
320 );
321}
322
323fn build_agent_command(agent_command: &str) -> ProcessCommand {
324 #[cfg(windows)]
325 {
326 let mut command = ProcessCommand::new("cmd");
327 command.arg("/C").arg(agent_command);
328 command
329 }
330
331 #[cfg(not(windows))]
332 {
333 let mut command = ProcessCommand::new("sh");
334 command.arg("-c").arg(agent_command);
335 command
336 }
337}
338
339fn exit_code_from_status(status: process::ExitStatus) -> Result<(i32, bool)> {
340 if let Some(code) = status.code() {
341 return Ok((code, code == 0));
342 }
343
344 #[cfg(unix)]
345 {
346 use std::os::unix::process::ExitStatusExt;
347
348 if let Some(signal) = status.signal() {
349 return Ok((128 + signal, false));
350 }
351 }
352
353 bail!("agent process ended without an exit code")
354}
355
356#[cfg(test)]
357mod tests {
358 use std::{collections::BTreeMap, fs, path::Path};
359
360 use anyhow::Result;
361 use tempfile::tempdir;
362
363 use crate::config::{AppConfig, ConfigPaths, PlaygroundConfig, PlaygroundDefinition};
364
365 use super::{
366 copy_playground_contents, exit_code_from_status, prompt_to_save_playground_snapshot,
367 run_default_playground, run_playground, save_playground_snapshot,
368 should_prompt_to_save_playground_snapshot, should_save_playground_snapshot,
369 };
370
371 #[cfg(unix)]
372 fn command_writing_marker(marker: &str) -> String {
373 format!("printf '{marker}' > agent.txt && test ! -e apg.toml")
374 }
375
376 #[cfg(windows)]
377 fn command_writing_marker(marker: &str) -> String {
378 format!("echo {marker}>agent.txt && if exist apg.toml exit /b 1")
379 }
380
381 #[cfg(unix)]
382 fn failing_command() -> String {
383 "printf 'failed' > agent.txt; exit 7".to_string()
384 }
385
386 #[cfg(windows)]
387 fn failing_command() -> String {
388 "echo failed>agent.txt & exit /b 7".to_string()
389 }
390
391 #[cfg(unix)]
392 fn command_recording_env(var_name: &str) -> String {
393 format!("printf '%s' \"${var_name}\" > env.txt && test ! -e .env && test ! -e apg.toml")
394 }
395
396 #[cfg(windows)]
397 fn command_recording_env(var_name: &str) -> String {
398 format!(
399 "powershell -NoProfile -Command \"[System.IO.File]::WriteAllText('env.txt', $env:{var_name})\" && if exist .env exit /b 1 && if exist apg.toml exit /b 1"
400 )
401 }
402
403 fn single_saved_snapshot(save_root: &Path) -> Result<std::path::PathBuf> {
404 let snapshots = fs::read_dir(save_root)?
405 .collect::<std::result::Result<Vec<_>, _>>()?
406 .into_iter()
407 .map(|entry| entry.path())
408 .collect::<Vec<_>>();
409
410 assert_eq!(snapshots.len(), 1);
411 Ok(snapshots.into_iter().next().expect("single snapshot"))
412 }
413
414 fn make_playground(
415 source_dir: &Path,
416 playground_id: &str,
417 default_agent: Option<&str>,
418 load_env: Option<bool>,
419 ) -> Result<PlaygroundDefinition> {
420 let config_file = source_dir.join("apg.toml");
421 fs::write(&config_file, "description = 'ignored'")?;
422 fs::write(source_dir.join("notes.txt"), "hello")?;
423
424 Ok(PlaygroundDefinition {
425 id: playground_id.to_string(),
426 description: "demo".to_string(),
427 directory: source_dir.to_path_buf(),
428 config_file,
429 playground: PlaygroundConfig {
430 default_agent: default_agent.map(str::to_string),
431 load_env,
432 },
433 })
434 }
435
436 fn make_config(
437 source_dir: &Path,
438 save_root: &Path,
439 playground_id: &str,
440 default_agent: Option<&str>,
441 playground_default_agent: Option<&str>,
442 playground_load_env: Option<bool>,
443 agents: &[(&str, String)],
444 ) -> Result<AppConfig> {
445 let playground = make_playground(
446 source_dir,
447 playground_id,
448 playground_default_agent,
449 playground_load_env,
450 )?;
451 let agents = agents
452 .iter()
453 .map(|(id, command)| ((*id).to_string(), command.clone()))
454 .collect::<BTreeMap<_, _>>();
455 let mut playgrounds = BTreeMap::new();
456 playgrounds.insert(playground_id.to_string(), playground);
457
458 Ok(AppConfig {
459 paths: ConfigPaths::from_root_dir(source_dir.join("config-root")),
460 agents,
461 saved_playgrounds_dir: save_root.to_path_buf(),
462 playground_defaults: PlaygroundConfig {
463 default_agent: default_agent.map(str::to_string),
464 load_env: Some(false),
465 },
466 playgrounds,
467 })
468 }
469
470 #[test]
471 fn copies_playground_contents_except_config_file() -> Result<()> {
472 let source_dir = tempdir()?;
473 let destination_dir = tempdir()?;
474 let nested_dir = source_dir.path().join("nested");
475 let config_file = source_dir.path().join("apg.toml");
476 let note_file = source_dir.path().join("notes.txt");
477 let nested_file = nested_dir.join("task.md");
478
479 fs::create_dir_all(&nested_dir)?;
480 fs::write(&config_file, "description = 'ignored'")?;
481 fs::write(¬e_file, "hello")?;
482 fs::write(&nested_file, "nested")?;
483
484 let playground = PlaygroundDefinition {
485 id: "demo".to_string(),
486 description: "demo".to_string(),
487 directory: source_dir.path().to_path_buf(),
488 config_file: config_file.clone(),
489 playground: PlaygroundConfig::default(),
490 };
491
492 copy_playground_contents(&playground, false, destination_dir.path())?;
493
494 assert!(!destination_dir.path().join("apg.toml").exists());
495 assert_eq!(
496 fs::read_to_string(destination_dir.path().join("notes.txt"))?,
497 "hello"
498 );
499 assert_eq!(
500 fs::read_to_string(destination_dir.path().join("nested").join("task.md"))?,
501 "nested"
502 );
503
504 Ok(())
505 }
506
507 #[test]
508 fn skips_dotenv_file_when_load_env_is_enabled() -> Result<()> {
509 let source_dir = tempdir()?;
510 let destination_dir = tempdir()?;
511 let config_file = source_dir.path().join("apg.toml");
512 let env_file = source_dir.path().join(".env");
513
514 fs::write(&config_file, "description = 'ignored'")?;
515 fs::write(source_dir.path().join("notes.txt"), "hello")?;
516 fs::write(&env_file, "API_TOKEN=secret\n")?;
517
518 let playground = PlaygroundDefinition {
519 id: "demo".to_string(),
520 description: "demo".to_string(),
521 directory: source_dir.path().to_path_buf(),
522 config_file,
523 playground: PlaygroundConfig::default(),
524 };
525
526 copy_playground_contents(&playground, true, destination_dir.path())?;
527
528 assert!(!destination_dir.path().join(".env").exists());
529 assert_eq!(
530 fs::read_to_string(destination_dir.path().join("notes.txt"))?,
531 "hello"
532 );
533
534 Ok(())
535 }
536
537 #[test]
538 fn saves_snapshot_only_for_normal_exit_when_enabled() {
539 assert!(should_save_playground_snapshot(true, true));
540 assert!(!should_save_playground_snapshot(true, false));
541 assert!(!should_save_playground_snapshot(false, true));
542 assert!(!should_save_playground_snapshot(false, false));
543 }
544
545 #[test]
546 fn prompts_only_for_normal_exit_without_explicit_save_flag() {
547 assert!(should_prompt_to_save_playground_snapshot(true, false, true));
548 assert!(!should_prompt_to_save_playground_snapshot(true, true, true));
549 assert!(!should_prompt_to_save_playground_snapshot(
550 false, false, true
551 ));
552 assert!(!should_prompt_to_save_playground_snapshot(
553 true, false, false
554 ));
555 }
556
557 #[test]
558 fn prompt_accepts_yes_and_rejects_default_enter() -> Result<()> {
559 let mut output = Vec::new();
560 let should_save =
561 prompt_to_save_playground_snapshot(std::io::Cursor::new("y\n"), &mut output)?;
562 assert!(should_save);
563 assert_eq!(
564 String::from_utf8(output).expect("prompt output"),
565 "Keep temporary playground copy? [y/N] "
566 );
567
568 let mut output = Vec::new();
569 let should_save =
570 prompt_to_save_playground_snapshot(std::io::Cursor::new("\n"), &mut output)?;
571 assert!(!should_save);
572
573 Ok(())
574 }
575
576 #[test]
577 fn saves_temporary_playground_snapshot() -> Result<()> {
578 let source_dir = tempdir()?;
579 let save_root = tempdir()?;
580 let nested_dir = source_dir.path().join("nested");
581
582 fs::create_dir_all(&nested_dir)?;
583 fs::write(source_dir.path().join("notes.txt"), "hello")?;
584 fs::write(nested_dir.join("task.md"), "nested")?;
585
586 let saved_path = save_playground_snapshot(source_dir.path(), save_root.path(), "demo")?;
587
588 assert!(saved_path.starts_with(save_root.path()));
589 assert_eq!(fs::read_to_string(saved_path.join("notes.txt"))?, "hello");
590 assert_eq!(
591 fs::read_to_string(saved_path.join("nested").join("task.md"))?,
592 "nested"
593 );
594
595 Ok(())
596 }
597
598 #[test]
599 fn only_zero_exit_code_counts_as_normal_exit() -> Result<()> {
600 #[cfg(unix)]
601 {
602 use std::os::unix::process::ExitStatusExt;
603
604 let success = std::process::ExitStatus::from_raw(0);
605 let interrupted = std::process::ExitStatus::from_raw(130 << 8);
606
607 assert_eq!(exit_code_from_status(success)?, (0, true));
608 assert_eq!(exit_code_from_status(interrupted)?, (130, false));
609 }
610
611 #[cfg(windows)]
612 {
613 use std::os::windows::process::ExitStatusExt;
614
615 let success = std::process::ExitStatus::from_raw(0);
616 let interrupted = std::process::ExitStatus::from_raw(130);
617
618 assert_eq!(exit_code_from_status(success)?, (0, true));
619 assert_eq!(exit_code_from_status(interrupted)?, (130, false));
620 }
621
622 Ok(())
623 }
624
625 #[test]
626 fn errors_for_unknown_playground() -> Result<()> {
627 let source_dir = tempdir()?;
628 let save_root = tempdir()?;
629 let config = make_config(
630 source_dir.path(),
631 save_root.path(),
632 "demo",
633 Some("claude"),
634 None,
635 None,
636 &[("claude", command_writing_marker("default"))],
637 )?;
638
639 let error =
640 run_playground(&config, "missing", None, false).expect_err("unknown playground");
641
642 assert!(error.to_string().contains("unknown playground 'missing'"));
643 Ok(())
644 }
645
646 #[test]
647 fn errors_for_unknown_agent() -> Result<()> {
648 let source_dir = tempdir()?;
649 let save_root = tempdir()?;
650 let config = make_config(
651 source_dir.path(),
652 save_root.path(),
653 "demo",
654 Some("claude"),
655 None,
656 None,
657 &[("claude", command_writing_marker("default"))],
658 )?;
659
660 let error =
661 run_playground(&config, "demo", Some("missing"), false).expect_err("unknown agent");
662
663 assert!(error.to_string().contains("unknown agent 'missing'"));
664 Ok(())
665 }
666
667 #[test]
668 fn uses_default_agent_and_saves_snapshot_when_enabled() -> Result<()> {
669 let source_dir = tempdir()?;
670 let save_root = tempdir()?;
671 let config = make_config(
672 source_dir.path(),
673 save_root.path(),
674 "demo",
675 Some("claude"),
676 None,
677 None,
678 &[("claude", command_writing_marker("default"))],
679 )?;
680
681 let exit_code = run_playground(&config, "demo", None, true)?;
682 let snapshot = single_saved_snapshot(save_root.path())?;
683
684 assert_eq!(exit_code, 0);
685 assert_eq!(
686 fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
687 "default"
688 );
689 assert_eq!(fs::read_to_string(snapshot.join("notes.txt"))?, "hello");
690 assert!(!snapshot.join("apg.toml").exists());
691 Ok(())
692 }
693
694 #[test]
695 fn runs_empty_default_playground_with_default_agent() -> Result<()> {
696 let source_dir = tempdir()?;
697 let save_root = tempdir()?;
698 let config = make_config(
699 source_dir.path(),
700 save_root.path(),
701 "demo",
702 Some("claude"),
703 None,
704 None,
705 &[("claude", command_writing_marker("default"))],
706 )?;
707
708 let exit_code = run_default_playground(&config, None, true)?;
709 let snapshot = single_saved_snapshot(save_root.path())?;
710
711 assert_eq!(exit_code, 0);
712 assert_eq!(
713 fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
714 "default"
715 );
716 assert!(!snapshot.join("notes.txt").exists());
717 assert!(
718 snapshot
719 .file_name()
720 .is_some_and(|name| name.to_string_lossy().starts_with("__default__-"))
721 );
722 Ok(())
723 }
724
725 #[test]
726 fn selected_agent_overrides_default_in_empty_default_playground() -> Result<()> {
727 let source_dir = tempdir()?;
728 let save_root = tempdir()?;
729 let config = make_config(
730 source_dir.path(),
731 save_root.path(),
732 "demo",
733 Some("claude"),
734 None,
735 None,
736 &[
737 ("claude", command_writing_marker("default")),
738 ("codex", command_writing_marker("selected")),
739 ],
740 )?;
741
742 let exit_code = run_default_playground(&config, Some("codex"), true)?;
743 let snapshot = single_saved_snapshot(save_root.path())?;
744
745 assert_eq!(exit_code, 0);
746 assert_eq!(
747 fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
748 "selected"
749 );
750 Ok(())
751 }
752
753 #[test]
754 fn uses_playground_default_agent_before_root_default() -> Result<()> {
755 let source_dir = tempdir()?;
756 let save_root = tempdir()?;
757 let config = make_config(
758 source_dir.path(),
759 save_root.path(),
760 "demo",
761 Some("claude"),
762 Some("codex"),
763 None,
764 &[
765 ("claude", command_writing_marker("root-default")),
766 ("codex", command_writing_marker("playground-default")),
767 ],
768 )?;
769
770 let exit_code = run_playground(&config, "demo", None, true)?;
771 let snapshot = single_saved_snapshot(save_root.path())?;
772
773 assert_eq!(exit_code, 0);
774 assert_eq!(
775 fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
776 "playground-default"
777 );
778 Ok(())
779 }
780
781 #[test]
782 fn selected_agent_overrides_default_agent() -> Result<()> {
783 let source_dir = tempdir()?;
784 let save_root = tempdir()?;
785 let config = make_config(
786 source_dir.path(),
787 save_root.path(),
788 "demo",
789 Some("claude"),
790 Some("opencode"),
791 None,
792 &[
793 ("claude", command_writing_marker("default")),
794 ("opencode", command_writing_marker("playground-default")),
795 ("codex", command_writing_marker("selected")),
796 ],
797 )?;
798
799 let exit_code = run_playground(&config, "demo", Some("codex"), true)?;
800 let snapshot = single_saved_snapshot(save_root.path())?;
801
802 assert_eq!(exit_code, 0);
803 assert_eq!(
804 fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
805 "selected"
806 );
807 Ok(())
808 }
809
810 #[test]
811 fn does_not_save_snapshot_when_disabled() -> Result<()> {
812 let source_dir = tempdir()?;
813 let save_root = tempdir()?;
814 let config = make_config(
815 source_dir.path(),
816 save_root.path(),
817 "demo",
818 Some("claude"),
819 None,
820 None,
821 &[("claude", command_writing_marker("default"))],
822 )?;
823
824 let exit_code = run_playground(&config, "demo", None, false)?;
825
826 assert_eq!(exit_code, 0);
827 assert_eq!(fs::read_dir(save_root.path())?.count(), 0);
828 Ok(())
829 }
830
831 #[test]
832 fn does_not_save_snapshot_when_agent_exits_with_error() -> Result<()> {
833 let source_dir = tempdir()?;
834 let save_root = tempdir()?;
835 let config = make_config(
836 source_dir.path(),
837 save_root.path(),
838 "demo",
839 Some("claude"),
840 None,
841 None,
842 &[("claude", failing_command())],
843 )?;
844
845 let exit_code = run_playground(&config, "demo", None, true)?;
846
847 assert_eq!(exit_code, 7);
848 assert_eq!(fs::read_dir(save_root.path())?.count(), 0);
849 Ok(())
850 }
851
852 #[test]
853 fn loads_dotenv_into_agent_environment_without_copying_file() -> Result<()> {
854 let source_dir = tempdir()?;
855 let save_root = tempdir()?;
856 fs::write(
857 source_dir.path().join(".env"),
858 "PLAYGROUND_SECRET=token-123\n",
859 )?;
860 let config = make_config(
861 source_dir.path(),
862 save_root.path(),
863 "demo",
864 Some("claude"),
865 None,
866 Some(true),
867 &[("claude", command_recording_env("PLAYGROUND_SECRET"))],
868 )?;
869
870 let exit_code = run_playground(&config, "demo", None, true)?;
871 let snapshot = single_saved_snapshot(save_root.path())?;
872 assert_eq!(exit_code, 0);
873 assert_eq!(fs::read_to_string(snapshot.join("env.txt"))?, "token-123");
874 assert!(!snapshot.join(".env").exists());
875 Ok(())
876 }
877}