1use std::{
8 collections::HashSet,
9 fs,
10 io::{self, BufRead, IsTerminal, Write},
11 path::{Path, PathBuf},
12 process::{self, Command as ProcessCommand},
13 time::{SystemTime, UNIX_EPOCH},
14};
15
16use anyhow::{Context, Result, bail};
17use dotenvy::Error as DotenvError;
18use tempfile::tempdir;
19
20use crate::config::{AppConfig, CreateMode, PlaygroundDefinition};
21use crate::utils::symlink::{apply_directory_mounts, copy_symlink};
22
23pub use crate::utils::symlink::DirectoryMount;
24
25const DOTENV_FILE_NAME: &str = ".env";
26const DEFAULT_PLAYGROUND_ID: &str = "__default__";
27
28struct RunContext<'a> {
29 playground_env: Vec<(String, String)>,
30 save_on_exit: bool,
31 saved_playgrounds_dir: &'a Path,
32 playground_id: &'a str,
33 mounts: &'a [DirectoryMount],
34}
35
36pub fn run_playground(
59 config: &AppConfig,
60 playground_id: &str,
61 selected_agent_id: Option<&str>,
62 save_on_exit: bool,
63 mounts: &[DirectoryMount],
64) -> Result<i32> {
65 let playground = config
66 .playgrounds
67 .get(playground_id)
68 .with_context(|| format!("unknown playground '{playground_id}'"))?;
69 let playground_config = config.resolve_playground_config(playground)?;
70 let agent_id = selected_agent_id.unwrap_or(&playground_config.default_agent);
71 let agent_command = config
72 .agents
73 .get(agent_id)
74 .with_context(|| format!("unknown agent '{agent_id}'"))?;
75 let load_env = playground_config.load_env;
76 let create_mode = playground_config.create_mode;
77
78 let temp_dir = tempdir().context("failed to create temporary playground directory")?;
79 materialize_playground_contents(playground, load_env, create_mode, temp_dir.path())?;
80 apply_directory_mounts(temp_dir.path(), mounts)?;
81 let playground_env = load_playground_env(playground, load_env)?;
82
83 run_agent_in_directory(
84 temp_dir.path(),
85 agent_id,
86 agent_command,
87 RunContext {
88 playground_env,
89 save_on_exit,
90 saved_playgrounds_dir: &config.saved_playgrounds_dir,
91 playground_id,
92 mounts,
93 },
94 )
95}
96
97pub fn run_default_playground(
102 config: &AppConfig,
103 selected_agent_id: Option<&str>,
104 save_on_exit: bool,
105 mounts: &[DirectoryMount],
106) -> Result<i32> {
107 let default_agent = config
108 .playground_defaults
109 .default_agent
110 .as_deref()
111 .context("default playground config is missing default_agent")?;
112 let agent_id = selected_agent_id.unwrap_or(default_agent);
113 let agent_command = config
114 .agents
115 .get(agent_id)
116 .with_context(|| format!("unknown agent '{agent_id}'"))?;
117
118 let temp_dir = tempdir().context("failed to create temporary playground directory")?;
119 apply_directory_mounts(temp_dir.path(), mounts)?;
120
121 run_agent_in_directory(
122 temp_dir.path(),
123 agent_id,
124 agent_command,
125 RunContext {
126 playground_env: Vec::new(),
127 save_on_exit,
128 saved_playgrounds_dir: &config.saved_playgrounds_dir,
129 playground_id: DEFAULT_PLAYGROUND_ID,
130 mounts,
131 },
132 )
133}
134
135fn run_agent_in_directory(
136 working_dir: &Path,
137 agent_id: &str,
138 agent_command: &str,
139 run_context: RunContext<'_>,
140) -> Result<i32> {
141 let status = build_agent_command(agent_command)
142 .envs(run_context.playground_env)
143 .current_dir(working_dir)
144 .status()
145 .with_context(|| format!("failed to start agent '{agent_id}'"))?;
146
147 let (exit_code, exited_normally) = exit_code_from_status(status)?;
148
149 let should_save = should_save_playground_snapshot(exited_normally, run_context.save_on_exit)
150 || (should_prompt_to_save_playground_snapshot(
151 exited_normally,
152 run_context.save_on_exit,
153 is_interactive_terminal(),
154 ) && prompt_to_save_playground_snapshot(io::stdin().lock(), &mut io::stdout().lock())?);
155
156 if should_save {
157 let saved_path = save_playground_snapshot(
158 working_dir,
159 run_context.saved_playgrounds_dir,
160 run_context.playground_id,
161 mounted_paths(working_dir, run_context.mounts),
162 )?;
163 println!("saved playground snapshot to {}", saved_path.display());
164 }
165
166 Ok(exit_code)
167}
168
169fn mounted_paths(working_dir: &Path, mounts: &[DirectoryMount]) -> HashSet<PathBuf> {
170 mounts
171 .iter()
172 .map(|mount| working_dir.join(&mount.destination))
173 .collect()
174}
175
176fn should_save_playground_snapshot(exited_normally: bool, save_on_exit: bool) -> bool {
177 exited_normally && save_on_exit
178}
179
180fn should_prompt_to_save_playground_snapshot(
181 exited_normally: bool,
182 save_on_exit: bool,
183 is_interactive: bool,
184) -> bool {
185 exited_normally && !save_on_exit && is_interactive
186}
187
188fn is_interactive_terminal() -> bool {
189 io::stdin().is_terminal() && io::stdout().is_terminal()
190}
191
192fn prompt_to_save_playground_snapshot<R: BufRead, W: Write>(
193 mut input: R,
194 output: &mut W,
195) -> Result<bool> {
196 write!(output, "Keep temporary playground copy? [y/N] ")
197 .context("failed to write save prompt")?;
198 output.flush().context("failed to flush save prompt")?;
199
200 let mut response = String::new();
201 input
202 .read_line(&mut response)
203 .context("failed to read save prompt response")?;
204
205 let normalized = response.trim().to_ascii_lowercase();
206 Ok(matches!(normalized.as_str(), "y" | "yes"))
207}
208
209fn save_playground_snapshot(
210 source_dir: &Path,
211 saved_playgrounds_dir: &Path,
212 playground_id: &str,
213 preserved_symlink_paths: HashSet<PathBuf>,
214) -> Result<PathBuf> {
215 fs::create_dir_all(saved_playgrounds_dir)
216 .with_context(|| format!("failed to create {}", saved_playgrounds_dir.display()))?;
217
218 let destination = next_saved_playground_dir(saved_playgrounds_dir, playground_id);
219 fs::create_dir_all(&destination)
220 .with_context(|| format!("failed to create {}", destination.display()))?;
221 copy_directory_contents(source_dir, &destination, &preserved_symlink_paths)?;
222
223 Ok(destination)
224}
225
226fn next_saved_playground_dir(saved_playgrounds_dir: &Path, playground_id: &str) -> PathBuf {
227 let timestamp = SystemTime::now()
228 .duration_since(UNIX_EPOCH)
229 .unwrap_or_default()
230 .as_secs();
231 let base_name = format!("{playground_id}-{timestamp}");
232 let mut candidate = saved_playgrounds_dir.join(&base_name);
233 let mut suffix = 1;
234
235 while candidate.exists() {
236 candidate = saved_playgrounds_dir.join(format!("{base_name}-{suffix}"));
237 suffix += 1;
238 }
239
240 candidate
241}
242
243fn materialize_playground_contents(
244 playground: &PlaygroundDefinition,
245 load_env: bool,
246 create_mode: CreateMode,
247 destination: &Path,
248) -> Result<()> {
249 for entry in fs::read_dir(&playground.directory)
250 .with_context(|| format!("failed to read {}", playground.directory.display()))?
251 {
252 let entry = entry.with_context(|| {
253 format!(
254 "failed to inspect an entry under {}",
255 playground.directory.display()
256 )
257 })?;
258 let source_path = entry.path();
259
260 if should_skip_playground_path(playground, load_env, &source_path) {
261 continue;
262 }
263
264 materialize_path(
265 &source_path,
266 &destination.join(entry.file_name()),
267 create_mode,
268 )?;
269 }
270
271 Ok(())
272}
273
274fn materialize_path(source: &Path, destination: &Path, create_mode: CreateMode) -> Result<()> {
275 match create_mode {
276 CreateMode::Copy => copy_path(source, destination),
277 CreateMode::Symlink => symlink_path(source, destination),
278 CreateMode::Hardlink => hardlink_path(source, destination),
279 }
280}
281
282fn should_skip_playground_path(
283 playground: &PlaygroundDefinition,
284 load_env: bool,
285 source_path: &Path,
286) -> bool {
287 source_path == playground.config_file
288 || (load_env
289 && source_path
290 .file_name()
291 .is_some_and(|name| name == DOTENV_FILE_NAME))
292}
293
294fn load_playground_env(
295 playground: &PlaygroundDefinition,
296 load_env: bool,
297) -> Result<Vec<(String, String)>> {
298 if !load_env {
299 return Ok(Vec::new());
300 }
301
302 let env_path = playground.directory.join(DOTENV_FILE_NAME);
303 match dotenvy::from_path_iter(&env_path) {
304 Ok(entries) => entries
305 .collect::<std::result::Result<Vec<_>, _>>()
306 .with_context(|| format!("failed to parse {}", env_path.display())),
307 Err(DotenvError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
308 Ok(Vec::new())
309 }
310 Err(error) => Err(error).with_context(|| format!("failed to load {}", env_path.display())),
311 }
312}
313
314fn copy_directory_contents(
315 source: &Path,
316 destination: &Path,
317 preserved_symlink_paths: &HashSet<PathBuf>,
318) -> Result<()> {
319 let mut active_directories = HashSet::new();
320 copy_directory_contents_following_symlinks(
321 source,
322 destination,
323 preserved_symlink_paths,
324 &mut active_directories,
325 )
326}
327
328fn copy_directory_contents_following_symlinks(
329 source: &Path,
330 destination: &Path,
331 preserved_symlink_paths: &HashSet<PathBuf>,
332 active_directories: &mut HashSet<PathBuf>,
333) -> Result<()> {
334 let canonical_source = fs::canonicalize(source)
335 .with_context(|| format!("failed to resolve {}", source.display()))?;
336 if !active_directories.insert(canonical_source.clone()) {
337 bail!(
338 "refusing to save playground snapshot because symlink traversal would revisit {}",
339 canonical_source.display()
340 );
341 }
342
343 let result = copy_directory_entries_following_symlinks(
344 source,
345 destination,
346 preserved_symlink_paths,
347 active_directories,
348 );
349 active_directories.remove(&canonical_source);
350 result
351}
352
353fn copy_directory_entries_following_symlinks(
354 source: &Path,
355 destination: &Path,
356 preserved_symlink_paths: &HashSet<PathBuf>,
357 active_directories: &mut HashSet<PathBuf>,
358) -> Result<()> {
359 for entry in
360 fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
361 {
362 let entry = entry
363 .with_context(|| format!("failed to inspect an entry under {}", source.display()))?;
364 copy_path_following_symlinks(
365 &entry.path(),
366 &destination.join(entry.file_name()),
367 preserved_symlink_paths,
368 active_directories,
369 )?;
370 }
371
372 Ok(())
373}
374
375fn copy_path(source: &Path, destination: &Path) -> Result<()> {
376 copy_path_with_symlink_behavior(source, destination, false)
377}
378
379fn copy_path_with_symlink_behavior(
380 source: &Path,
381 destination: &Path,
382 follow_symlinks: bool,
383) -> Result<()> {
384 let metadata = inspect_path(source, follow_symlinks)?;
385
386 if metadata.file_type().is_symlink() {
387 copy_symlink(source, destination)?;
388 return Ok(());
389 }
390
391 if metadata.is_dir() {
392 fs::create_dir_all(destination)
393 .with_context(|| format!("failed to create {}", destination.display()))?;
394
395 for entry in
396 fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
397 {
398 let entry = entry.with_context(|| {
399 format!("failed to inspect an entry under {}", source.display())
400 })?;
401 copy_path_with_symlink_behavior(
402 &entry.path(),
403 &destination.join(entry.file_name()),
404 follow_symlinks,
405 )?;
406 }
407
408 return Ok(());
409 }
410
411 if metadata.is_file() {
412 create_parent_dir(destination)?;
413
414 fs::copy(source, destination).with_context(|| {
415 format!(
416 "failed to copy {} to {}",
417 source.display(),
418 destination.display()
419 )
420 })?;
421 return Ok(());
422 }
423
424 bail!(
425 "unsupported file type while copying playground contents: {}",
426 source.display()
427 );
428}
429
430fn copy_path_following_symlinks(
431 source: &Path,
432 destination: &Path,
433 preserved_symlink_paths: &HashSet<PathBuf>,
434 active_directories: &mut HashSet<PathBuf>,
435) -> Result<()> {
436 if preserved_symlink_paths.contains(source)
437 && fs::symlink_metadata(source)
438 .with_context(|| format!("failed to inspect {}", source.display()))?
439 .file_type()
440 .is_symlink()
441 {
442 copy_symlink(source, destination)?;
443 return Ok(());
444 }
445
446 let metadata = inspect_path(source, true)?;
447
448 if metadata.is_dir() {
449 fs::create_dir_all(destination)
450 .with_context(|| format!("failed to create {}", destination.display()))?;
451 return copy_directory_contents_following_symlinks(
452 source,
453 destination,
454 preserved_symlink_paths,
455 active_directories,
456 );
457 }
458
459 if metadata.is_file() {
460 create_parent_dir(destination)?;
461 fs::copy(source, destination).with_context(|| {
462 format!(
463 "failed to copy {} to {}",
464 source.display(),
465 destination.display()
466 )
467 })?;
468 return Ok(());
469 }
470
471 bail!(
472 "unsupported file type while copying playground contents: {}",
473 source.display()
474 );
475}
476
477fn hardlink_path(source: &Path, destination: &Path) -> Result<()> {
478 let metadata = fs::symlink_metadata(source)
479 .with_context(|| format!("failed to inspect {}", source.display()))?;
480
481 if metadata.is_dir() {
482 fs::create_dir_all(destination)
483 .with_context(|| format!("failed to create {}", destination.display()))?;
484
485 for entry in
486 fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
487 {
488 let entry = entry.with_context(|| {
489 format!("failed to inspect an entry under {}", source.display())
490 })?;
491 hardlink_path(&entry.path(), &destination.join(entry.file_name()))?;
492 }
493
494 return Ok(());
495 }
496
497 if metadata.is_file() {
498 create_parent_dir(destination)?;
499 hard_link_or_copy(source, destination)?;
500 return Ok(());
501 }
502
503 bail!(
504 "unsupported file type while hard-linking playground contents: {}",
505 source.display()
506 );
507}
508
509fn hard_link_or_copy(source: &Path, destination: &Path) -> Result<()> {
510 match fs::hard_link(source, destination) {
511 Ok(()) => Ok(()),
512 Err(error) if error.kind() == io::ErrorKind::CrossesDevices => {
513 fs::copy(source, destination).with_context(|| {
514 format!(
515 "failed to copy {} to {} after cross-device hard-link failure",
516 source.display(),
517 destination.display()
518 )
519 })?;
520 Ok(())
521 }
522 Err(error) => Err(error).with_context(|| {
523 format!(
524 "failed to hard link {} to {}",
525 source.display(),
526 destination.display()
527 )
528 }),
529 }
530}
531
532fn symlink_path(source: &Path, destination: &Path) -> Result<()> {
533 let metadata = inspect_path(source, true)?;
534
535 create_parent_dir(destination)?;
536 create_symlink(source, destination, metadata.is_dir()).with_context(|| {
537 format!(
538 "failed to symlink {} to {}",
539 source.display(),
540 destination.display()
541 )
542 })?;
543
544 Ok(())
545}
546
547fn inspect_path(path: &Path, follow_symlinks: bool) -> Result<fs::Metadata> {
548 let metadata = if follow_symlinks {
549 fs::metadata(path)
550 } else {
551 fs::symlink_metadata(path)
552 };
553
554 metadata.with_context(|| format!("failed to inspect {}", path.display()))
555}
556
557fn create_parent_dir(path: &Path) -> Result<()> {
558 if let Some(parent) = path.parent() {
559 fs::create_dir_all(parent)
560 .with_context(|| format!("failed to create {}", parent.display()))?;
561 }
562
563 Ok(())
564}
565
566#[cfg(unix)]
567fn create_symlink(source: &Path, destination: &Path, _is_dir: bool) -> io::Result<()> {
568 std::os::unix::fs::symlink(source, destination)
569}
570
571#[cfg(windows)]
572fn create_symlink(source: &Path, destination: &Path, is_dir: bool) -> io::Result<()> {
573 if is_dir {
574 std::os::windows::fs::symlink_dir(source, destination)
575 } else {
576 std::os::windows::fs::symlink_file(source, destination)
577 }
578}
579
580fn build_agent_command(agent_command: &str) -> ProcessCommand {
581 #[cfg(windows)]
582 {
583 let mut command = ProcessCommand::new("cmd");
584 command.arg("/C").arg(agent_command);
585 command
586 }
587
588 #[cfg(not(windows))]
589 {
590 let mut command = ProcessCommand::new("sh");
591 command.arg("-c").arg(agent_command);
592 command
593 }
594}
595
596fn exit_code_from_status(status: process::ExitStatus) -> Result<(i32, bool)> {
597 if let Some(code) = status.code() {
598 return Ok((code, code == 0));
599 }
600
601 #[cfg(unix)]
602 {
603 use std::os::unix::process::ExitStatusExt;
604
605 if let Some(signal) = status.signal() {
606 return Ok((128 + signal, false));
607 }
608 }
609
610 bail!("agent process ended without an exit code")
611}
612
613#[cfg(test)]
614mod tests {
615 use std::{
616 collections::{BTreeMap, HashSet},
617 fs,
618 path::{Path, PathBuf},
619 };
620
621 use anyhow::Result;
622 use tempfile::tempdir;
623
624 use crate::config::{
625 AppConfig, ConfigPaths, CreateMode, PlaygroundConfig, PlaygroundDefinition,
626 };
627 use crate::utils::symlink::{copy_symlink, parse_directory_mount};
628
629 use super::{
630 DirectoryMount, exit_code_from_status, materialize_playground_contents,
631 prompt_to_save_playground_snapshot, run_default_playground, run_playground,
632 save_playground_snapshot, should_prompt_to_save_playground_snapshot,
633 should_save_playground_snapshot,
634 };
635
636 #[cfg(unix)]
637 fn command_writing_marker(marker: &str) -> String {
638 format!("printf '{marker}' > agent.txt && test ! -e apg.toml")
639 }
640
641 #[cfg(windows)]
642 fn command_writing_marker(marker: &str) -> String {
643 format!("echo {marker}>agent.txt && if exist apg.toml exit /b 1")
644 }
645
646 #[cfg(unix)]
647 fn failing_command() -> String {
648 "printf 'failed' > agent.txt; exit 7".to_string()
649 }
650
651 #[cfg(windows)]
652 fn failing_command() -> String {
653 "echo failed>agent.txt & exit /b 7".to_string()
654 }
655
656 #[cfg(unix)]
657 fn command_recording_env(var_name: &str) -> String {
658 format!("printf '%s' \"${var_name}\" > env.txt && test ! -e .env && test ! -e apg.toml")
659 }
660
661 #[cfg(windows)]
662 fn command_recording_env(var_name: &str) -> String {
663 format!(
664 "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"
665 )
666 }
667
668 #[cfg(unix)]
669 fn command_recording_mount(path: &str) -> String {
670 format!("cat '{path}' > mounted.txt")
671 }
672
673 #[cfg(windows)]
674 fn command_recording_mount(path: &str) -> String {
675 format!(
676 "powershell -NoProfile -Command \"Get-Content -Raw '{path}' | Set-Content -NoNewline mounted.txt\""
677 )
678 }
679
680 fn single_saved_snapshot(save_root: &Path) -> Result<std::path::PathBuf> {
681 let snapshots = fs::read_dir(save_root)?
682 .collect::<std::result::Result<Vec<_>, _>>()?
683 .into_iter()
684 .map(|entry| entry.path())
685 .collect::<Vec<_>>();
686
687 assert_eq!(snapshots.len(), 1);
688 Ok(snapshots.into_iter().next().expect("single snapshot"))
689 }
690
691 fn make_playground(
692 source_dir: &Path,
693 playground_id: &str,
694 default_agent: Option<&str>,
695 load_env: Option<bool>,
696 ) -> Result<PlaygroundDefinition> {
697 let config_file = source_dir.join("apg.toml");
698 fs::write(&config_file, "description = 'ignored'")?;
699 fs::write(source_dir.join("notes.txt"), "hello")?;
700
701 Ok(PlaygroundDefinition {
702 id: playground_id.to_string(),
703 description: "demo".to_string(),
704 directory: source_dir.to_path_buf(),
705 config_file,
706 playground: PlaygroundConfig {
707 default_agent: default_agent.map(str::to_string),
708 load_env,
709 create_mode: None,
710 },
711 })
712 }
713
714 fn make_config(
715 source_dir: &Path,
716 save_root: &Path,
717 playground_id: &str,
718 default_agent: Option<&str>,
719 playground_default_agent: Option<&str>,
720 playground_load_env: Option<bool>,
721 agents: &[(&str, String)],
722 ) -> Result<AppConfig> {
723 let playground = make_playground(
724 source_dir,
725 playground_id,
726 playground_default_agent,
727 playground_load_env,
728 )?;
729 let agents = agents
730 .iter()
731 .map(|(id, command)| ((*id).to_string(), command.clone()))
732 .collect::<BTreeMap<_, _>>();
733 let mut playgrounds = BTreeMap::new();
734 playgrounds.insert(playground_id.to_string(), playground);
735
736 Ok(AppConfig {
737 paths: ConfigPaths::from_root_dir(source_dir.join("config-root")),
738 agents,
739 saved_playgrounds_dir: save_root.to_path_buf(),
740 playground_defaults: PlaygroundConfig {
741 default_agent: default_agent.map(str::to_string),
742 load_env: Some(false),
743 create_mode: None,
744 },
745 playgrounds,
746 })
747 }
748
749 #[test]
750 fn copies_playground_contents_except_config_file() -> Result<()> {
751 let source_dir = tempdir()?;
752 let destination_dir = tempdir()?;
753 let nested_dir = source_dir.path().join("nested");
754 let config_file = source_dir.path().join("apg.toml");
755 let note_file = source_dir.path().join("notes.txt");
756 let nested_file = nested_dir.join("task.md");
757
758 fs::create_dir_all(&nested_dir)?;
759 fs::write(&config_file, "description = 'ignored'")?;
760 fs::write(¬e_file, "hello")?;
761 fs::write(&nested_file, "nested")?;
762
763 let playground = PlaygroundDefinition {
764 id: "demo".to_string(),
765 description: "demo".to_string(),
766 directory: source_dir.path().to_path_buf(),
767 config_file: config_file.clone(),
768 playground: PlaygroundConfig::default(),
769 };
770
771 materialize_playground_contents(
772 &playground,
773 false,
774 CreateMode::Copy,
775 destination_dir.path(),
776 )?;
777
778 assert!(!destination_dir.path().join("apg.toml").exists());
779 assert_eq!(
780 fs::read_to_string(destination_dir.path().join("notes.txt"))?,
781 "hello"
782 );
783 assert_eq!(
784 fs::read_to_string(destination_dir.path().join("nested").join("task.md"))?,
785 "nested"
786 );
787
788 Ok(())
789 }
790
791 #[test]
792 fn skips_dotenv_file_when_load_env_is_enabled() -> Result<()> {
793 let source_dir = tempdir()?;
794 let destination_dir = tempdir()?;
795 let config_file = source_dir.path().join("apg.toml");
796 let env_file = source_dir.path().join(".env");
797
798 fs::write(&config_file, "description = 'ignored'")?;
799 fs::write(source_dir.path().join("notes.txt"), "hello")?;
800 fs::write(&env_file, "API_TOKEN=secret\n")?;
801
802 let playground = PlaygroundDefinition {
803 id: "demo".to_string(),
804 description: "demo".to_string(),
805 directory: source_dir.path().to_path_buf(),
806 config_file,
807 playground: PlaygroundConfig::default(),
808 };
809
810 materialize_playground_contents(
811 &playground,
812 true,
813 CreateMode::Copy,
814 destination_dir.path(),
815 )?;
816
817 assert!(!destination_dir.path().join(".env").exists());
818 assert_eq!(
819 fs::read_to_string(destination_dir.path().join("notes.txt"))?,
820 "hello"
821 );
822
823 Ok(())
824 }
825
826 #[test]
827 fn symlinks_playground_contents_when_requested() -> Result<()> {
828 let source_dir = tempdir()?;
829 let destination_dir = tempdir()?;
830 let config_file = source_dir.path().join("apg.toml");
831 let note_file = source_dir.path().join("notes.txt");
832 let nested_dir = source_dir.path().join("nested");
833
834 fs::write(&config_file, "description = 'ignored'")?;
835 fs::write(¬e_file, "hello")?;
836 fs::create_dir_all(&nested_dir)?;
837 fs::write(nested_dir.join("task.md"), "nested")?;
838
839 let playground = PlaygroundDefinition {
840 id: "demo".to_string(),
841 description: "demo".to_string(),
842 directory: source_dir.path().to_path_buf(),
843 config_file,
844 playground: PlaygroundConfig::default(),
845 };
846
847 materialize_playground_contents(
848 &playground,
849 false,
850 CreateMode::Symlink,
851 destination_dir.path(),
852 )?;
853
854 assert!(
855 fs::symlink_metadata(destination_dir.path().join("notes.txt"))?
856 .file_type()
857 .is_symlink()
858 );
859 assert!(
860 fs::symlink_metadata(destination_dir.path().join("nested"))?
861 .file_type()
862 .is_symlink()
863 );
864 assert_eq!(
865 fs::read_to_string(destination_dir.path().join("nested").join("task.md"))?,
866 "nested"
867 );
868 assert!(!destination_dir.path().join("apg.toml").exists());
869
870 Ok(())
871 }
872
873 #[test]
874 fn hardlinks_playground_files_when_requested() -> Result<()> {
875 let source_dir = tempdir()?;
876 let destination_dir = tempdir()?;
877 let config_file = source_dir.path().join("apg.toml");
878 let note_file = source_dir.path().join("notes.txt");
879 let nested_dir = source_dir.path().join("nested");
880 let nested_file = nested_dir.join("task.md");
881
882 fs::write(&config_file, "description = 'ignored'")?;
883 fs::write(¬e_file, "hello")?;
884 fs::create_dir_all(&nested_dir)?;
885 fs::write(&nested_file, "nested")?;
886
887 let playground = PlaygroundDefinition {
888 id: "demo".to_string(),
889 description: "demo".to_string(),
890 directory: source_dir.path().to_path_buf(),
891 config_file,
892 playground: PlaygroundConfig::default(),
893 };
894
895 materialize_playground_contents(
896 &playground,
897 false,
898 CreateMode::Hardlink,
899 destination_dir.path(),
900 )?;
901
902 let linked_note = destination_dir.path().join("notes.txt");
903 let linked_nested = destination_dir.path().join("nested").join("task.md");
904 assert!(linked_note.is_file());
905 assert!(linked_nested.is_file());
906 assert!(!fs::symlink_metadata(&linked_note)?.file_type().is_symlink());
907 assert!(
908 !fs::symlink_metadata(destination_dir.path().join("nested"))?
909 .file_type()
910 .is_symlink()
911 );
912 assert_eq!(fs::read_to_string(linked_nested)?, "nested");
913
914 Ok(())
915 }
916
917 #[test]
918 fn saves_snapshot_only_for_normal_exit_when_enabled() {
919 assert!(should_save_playground_snapshot(true, true));
920 assert!(!should_save_playground_snapshot(true, false));
921 assert!(!should_save_playground_snapshot(false, true));
922 assert!(!should_save_playground_snapshot(false, false));
923 }
924
925 #[test]
926 fn prompts_only_for_normal_exit_without_explicit_save_flag() {
927 assert!(should_prompt_to_save_playground_snapshot(true, false, true));
928 assert!(!should_prompt_to_save_playground_snapshot(true, true, true));
929 assert!(!should_prompt_to_save_playground_snapshot(
930 false, false, true
931 ));
932 assert!(!should_prompt_to_save_playground_snapshot(
933 true, false, false
934 ));
935 }
936
937 #[test]
938 fn prompt_accepts_yes_and_rejects_default_enter() -> Result<()> {
939 let mut output = Vec::new();
940 let should_save =
941 prompt_to_save_playground_snapshot(std::io::Cursor::new("y\n"), &mut output)?;
942 assert!(should_save);
943 assert_eq!(
944 String::from_utf8(output).expect("prompt output"),
945 "Keep temporary playground copy? [y/N] "
946 );
947
948 let mut output = Vec::new();
949 let should_save =
950 prompt_to_save_playground_snapshot(std::io::Cursor::new("\n"), &mut output)?;
951 assert!(!should_save);
952
953 Ok(())
954 }
955
956 #[test]
957 fn saves_temporary_playground_snapshot() -> Result<()> {
958 let source_dir = tempdir()?;
959 let save_root = tempdir()?;
960 let nested_dir = source_dir.path().join("nested");
961
962 fs::create_dir_all(&nested_dir)?;
963 fs::write(source_dir.path().join("notes.txt"), "hello")?;
964 fs::write(nested_dir.join("task.md"), "nested")?;
965
966 let saved_path =
967 save_playground_snapshot(source_dir.path(), save_root.path(), "demo", HashSet::new())?;
968
969 assert!(saved_path.starts_with(save_root.path()));
970 assert_eq!(fs::read_to_string(saved_path.join("notes.txt"))?, "hello");
971 assert_eq!(
972 fs::read_to_string(saved_path.join("nested").join("task.md"))?,
973 "nested"
974 );
975
976 Ok(())
977 }
978
979 #[cfg(unix)]
980 #[test]
981 fn refuses_to_save_snapshot_when_symlink_cycle_is_detected() -> Result<()> {
982 let source_dir = tempdir()?;
983 let save_root = tempdir()?;
984 let loop_dir = source_dir.path().join("loop");
985
986 std::os::unix::fs::symlink(source_dir.path(), &loop_dir)?;
987
988 let error =
989 save_playground_snapshot(source_dir.path(), save_root.path(), "demo", HashSet::new())
990 .expect_err("symlink cycle should fail");
991
992 assert!(
993 error
994 .to_string()
995 .contains("refusing to save playground snapshot")
996 );
997
998 Ok(())
999 }
1000
1001 #[test]
1002 fn only_zero_exit_code_counts_as_normal_exit() -> Result<()> {
1003 #[cfg(unix)]
1004 {
1005 use std::os::unix::process::ExitStatusExt;
1006
1007 let success = std::process::ExitStatus::from_raw(0);
1008 let interrupted = std::process::ExitStatus::from_raw(130 << 8);
1009
1010 assert_eq!(exit_code_from_status(success)?, (0, true));
1011 assert_eq!(exit_code_from_status(interrupted)?, (130, false));
1012 }
1013
1014 #[cfg(windows)]
1015 {
1016 use std::os::windows::process::ExitStatusExt;
1017
1018 let success = std::process::ExitStatus::from_raw(0);
1019 let interrupted = std::process::ExitStatus::from_raw(130);
1020
1021 assert_eq!(exit_code_from_status(success)?, (0, true));
1022 assert_eq!(exit_code_from_status(interrupted)?, (130, false));
1023 }
1024
1025 Ok(())
1026 }
1027
1028 #[test]
1029 fn errors_for_unknown_playground() -> Result<()> {
1030 let source_dir = tempdir()?;
1031 let save_root = tempdir()?;
1032 let config = make_config(
1033 source_dir.path(),
1034 save_root.path(),
1035 "demo",
1036 Some("claude"),
1037 None,
1038 None,
1039 &[("claude", command_writing_marker("default"))],
1040 )?;
1041
1042 let error =
1043 run_playground(&config, "missing", None, false, &[]).expect_err("unknown playground");
1044
1045 assert!(error.to_string().contains("unknown playground 'missing'"));
1046 Ok(())
1047 }
1048
1049 #[test]
1050 fn errors_for_unknown_agent() -> Result<()> {
1051 let source_dir = tempdir()?;
1052 let save_root = tempdir()?;
1053 let config = make_config(
1054 source_dir.path(),
1055 save_root.path(),
1056 "demo",
1057 Some("claude"),
1058 None,
1059 None,
1060 &[("claude", command_writing_marker("default"))],
1061 )?;
1062
1063 let error = run_playground(&config, "demo", Some("missing"), false, &[])
1064 .expect_err("unknown agent");
1065
1066 assert!(error.to_string().contains("unknown agent 'missing'"));
1067 Ok(())
1068 }
1069
1070 #[test]
1071 fn uses_default_agent_and_saves_snapshot_when_enabled() -> Result<()> {
1072 let source_dir = tempdir()?;
1073 let save_root = tempdir()?;
1074 let config = make_config(
1075 source_dir.path(),
1076 save_root.path(),
1077 "demo",
1078 Some("claude"),
1079 None,
1080 None,
1081 &[("claude", command_writing_marker("default"))],
1082 )?;
1083
1084 let exit_code = run_playground(&config, "demo", None, true, &[])?;
1085 let snapshot = single_saved_snapshot(save_root.path())?;
1086
1087 assert_eq!(exit_code, 0);
1088 assert_eq!(
1089 fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
1090 "default"
1091 );
1092 assert_eq!(fs::read_to_string(snapshot.join("notes.txt"))?, "hello");
1093 assert!(!snapshot.join("apg.toml").exists());
1094 Ok(())
1095 }
1096
1097 #[test]
1098 fn runs_empty_default_playground_with_default_agent() -> Result<()> {
1099 let source_dir = tempdir()?;
1100 let save_root = tempdir()?;
1101 let config = make_config(
1102 source_dir.path(),
1103 save_root.path(),
1104 "demo",
1105 Some("claude"),
1106 None,
1107 None,
1108 &[("claude", command_writing_marker("default"))],
1109 )?;
1110
1111 let exit_code = run_default_playground(&config, None, true, &[])?;
1112 let snapshot = single_saved_snapshot(save_root.path())?;
1113
1114 assert_eq!(exit_code, 0);
1115 assert_eq!(
1116 fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
1117 "default"
1118 );
1119 assert!(!snapshot.join("notes.txt").exists());
1120 assert!(
1121 snapshot
1122 .file_name()
1123 .is_some_and(|name| name.to_string_lossy().starts_with("__default__-"))
1124 );
1125 Ok(())
1126 }
1127
1128 #[test]
1129 fn selected_agent_overrides_default_in_empty_default_playground() -> Result<()> {
1130 let source_dir = tempdir()?;
1131 let save_root = tempdir()?;
1132 let config = make_config(
1133 source_dir.path(),
1134 save_root.path(),
1135 "demo",
1136 Some("claude"),
1137 None,
1138 None,
1139 &[
1140 ("claude", command_writing_marker("default")),
1141 ("codex", command_writing_marker("selected")),
1142 ],
1143 )?;
1144
1145 let exit_code = run_default_playground(&config, Some("codex"), true, &[])?;
1146 let snapshot = single_saved_snapshot(save_root.path())?;
1147
1148 assert_eq!(exit_code, 0);
1149 assert_eq!(
1150 fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
1151 "selected"
1152 );
1153 Ok(())
1154 }
1155
1156 #[test]
1157 fn uses_playground_default_agent_before_root_default() -> Result<()> {
1158 let source_dir = tempdir()?;
1159 let save_root = tempdir()?;
1160 let config = make_config(
1161 source_dir.path(),
1162 save_root.path(),
1163 "demo",
1164 Some("claude"),
1165 Some("codex"),
1166 None,
1167 &[
1168 ("claude", command_writing_marker("root-default")),
1169 ("codex", command_writing_marker("playground-default")),
1170 ],
1171 )?;
1172
1173 let exit_code = run_playground(&config, "demo", None, true, &[])?;
1174 let snapshot = single_saved_snapshot(save_root.path())?;
1175
1176 assert_eq!(exit_code, 0);
1177 assert_eq!(
1178 fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
1179 "playground-default"
1180 );
1181 Ok(())
1182 }
1183
1184 #[test]
1185 fn selected_agent_overrides_default_agent() -> Result<()> {
1186 let source_dir = tempdir()?;
1187 let save_root = tempdir()?;
1188 let config = make_config(
1189 source_dir.path(),
1190 save_root.path(),
1191 "demo",
1192 Some("claude"),
1193 Some("opencode"),
1194 None,
1195 &[
1196 ("claude", command_writing_marker("default")),
1197 ("opencode", command_writing_marker("playground-default")),
1198 ("codex", command_writing_marker("selected")),
1199 ],
1200 )?;
1201
1202 let exit_code = run_playground(&config, "demo", Some("codex"), true, &[])?;
1203 let snapshot = single_saved_snapshot(save_root.path())?;
1204
1205 assert_eq!(exit_code, 0);
1206 assert_eq!(
1207 fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
1208 "selected"
1209 );
1210 Ok(())
1211 }
1212
1213 #[test]
1214 fn does_not_save_snapshot_when_disabled() -> Result<()> {
1215 let source_dir = tempdir()?;
1216 let save_root = tempdir()?;
1217 let config = make_config(
1218 source_dir.path(),
1219 save_root.path(),
1220 "demo",
1221 Some("claude"),
1222 None,
1223 None,
1224 &[("claude", command_writing_marker("default"))],
1225 )?;
1226
1227 let exit_code = run_playground(&config, "demo", None, false, &[])?;
1228
1229 assert_eq!(exit_code, 0);
1230 assert_eq!(fs::read_dir(save_root.path())?.count(), 0);
1231 Ok(())
1232 }
1233
1234 #[test]
1235 fn does_not_save_snapshot_when_agent_exits_with_error() -> Result<()> {
1236 let source_dir = tempdir()?;
1237 let save_root = tempdir()?;
1238 let config = make_config(
1239 source_dir.path(),
1240 save_root.path(),
1241 "demo",
1242 Some("claude"),
1243 None,
1244 None,
1245 &[("claude", failing_command())],
1246 )?;
1247
1248 let exit_code = run_playground(&config, "demo", None, true, &[])?;
1249
1250 assert_eq!(exit_code, 7);
1251 assert_eq!(fs::read_dir(save_root.path())?.count(), 0);
1252 Ok(())
1253 }
1254
1255 #[test]
1256 fn loads_dotenv_into_agent_environment_without_copying_file() -> Result<()> {
1257 let source_dir = tempdir()?;
1258 let save_root = tempdir()?;
1259 fs::write(
1260 source_dir.path().join(".env"),
1261 "PLAYGROUND_SECRET=token-123\n",
1262 )?;
1263 let config = make_config(
1264 source_dir.path(),
1265 save_root.path(),
1266 "demo",
1267 Some("claude"),
1268 None,
1269 Some(true),
1270 &[("claude", command_recording_env("PLAYGROUND_SECRET"))],
1271 )?;
1272
1273 let exit_code = run_playground(&config, "demo", None, true, &[])?;
1274 let snapshot = single_saved_snapshot(save_root.path())?;
1275 assert_eq!(exit_code, 0);
1276 assert_eq!(fs::read_to_string(snapshot.join("env.txt"))?, "token-123");
1277 assert!(!snapshot.join(".env").exists());
1278 Ok(())
1279 }
1280
1281 #[test]
1282 fn parses_directory_mount_with_default_destination_from_source_name() -> Result<()> {
1283 let temp = tempdir()?;
1284 let source = temp.path().join("outside");
1285 fs::create_dir_all(&source)?;
1286
1287 let mount = parse_directory_mount(
1288 source
1289 .to_str()
1290 .expect("temporary directory path should be valid UTF-8"),
1291 )?;
1292
1293 assert_eq!(
1294 mount,
1295 DirectoryMount {
1296 source: fs::canonicalize(&source)?,
1297 destination: PathBuf::from("outside"),
1298 }
1299 );
1300 Ok(())
1301 }
1302
1303 #[test]
1304 fn parses_directory_mount_with_explicit_relative_destination() -> Result<()> {
1305 let temp = tempdir()?;
1306 let source = temp.path().join("outside");
1307 fs::create_dir_all(&source)?;
1308
1309 let mount = parse_directory_mount(&format!("{}:tools/shared", source.display()))?;
1310
1311 assert_eq!(
1312 mount,
1313 DirectoryMount {
1314 source: fs::canonicalize(&source)?,
1315 destination: PathBuf::from("tools/shared"),
1316 }
1317 );
1318 Ok(())
1319 }
1320
1321 #[test]
1322 fn rejects_absolute_directory_mount_destination() -> Result<()> {
1323 let temp = tempdir()?;
1324 let source = temp.path().join("outside");
1325 fs::create_dir_all(&source)?;
1326
1327 let error = parse_directory_mount(&format!("{}:/absolute", source.display()))
1328 .expect_err("absolute destination should be rejected");
1329
1330 assert!(error.to_string().contains("must be a relative path"));
1331 Ok(())
1332 }
1333
1334 #[cfg(unix)]
1335 #[test]
1336 fn copy_symlink_preserves_relative_link_target() -> Result<()> {
1337 let source_dir = tempdir()?;
1338 let destination_dir = tempdir()?;
1339 let nested_dir = source_dir.path().join("nested");
1340 let target_dir = source_dir.path().join("target");
1341 let source_link = nested_dir.join("shared");
1342 let destination_link = destination_dir.path().join("nested").join("shared");
1343
1344 fs::create_dir_all(&nested_dir)?;
1345 fs::create_dir_all(&target_dir)?;
1346 std::os::unix::fs::symlink("../target", &source_link)?;
1347
1348 copy_symlink(&source_link, &destination_link)?;
1349
1350 assert_eq!(
1351 fs::read_link(&destination_link)?,
1352 PathBuf::from("../target")
1353 );
1354 Ok(())
1355 }
1356
1357 #[test]
1358 fn mounts_external_directory_into_playground_and_preserves_symlink_in_snapshot() -> Result<()> {
1359 let source_dir = tempdir()?;
1360 let save_root = tempdir()?;
1361 let external_dir = tempdir()?;
1362 fs::write(external_dir.path().join("shared.txt"), "from-outside")?;
1363
1364 let config = make_config(
1365 source_dir.path(),
1366 save_root.path(),
1367 "demo",
1368 Some("claude"),
1369 None,
1370 None,
1371 &[("claude", command_recording_mount("tools/shared/shared.txt"))],
1372 )?;
1373 let mounts = vec![DirectoryMount {
1374 source: fs::canonicalize(external_dir.path())?,
1375 destination: PathBuf::from("tools/shared"),
1376 }];
1377
1378 let exit_code = run_playground(&config, "demo", None, true, &mounts)?;
1379 let snapshot = single_saved_snapshot(save_root.path())?;
1380
1381 assert_eq!(exit_code, 0);
1382 assert_eq!(
1383 fs::read_to_string(snapshot.join("mounted.txt"))?,
1384 "from-outside"
1385 );
1386 let mounted_path = snapshot.join("tools").join("shared");
1387 let metadata = fs::symlink_metadata(&mounted_path)?;
1388 assert!(metadata.file_type().is_symlink());
1389 assert_eq!(
1390 fs::read_link(&mounted_path)?,
1391 fs::canonicalize(external_dir.path())?
1392 );
1393
1394 Ok(())
1395 }
1396
1397 #[test]
1398 fn mounts_external_directory_into_empty_default_playground() -> Result<()> {
1399 let source_dir = tempdir()?;
1400 let save_root = tempdir()?;
1401 let external_dir = tempdir()?;
1402 fs::write(external_dir.path().join("shared.txt"), "from-outside")?;
1403
1404 let config = make_config(
1405 source_dir.path(),
1406 save_root.path(),
1407 "demo",
1408 Some("claude"),
1409 None,
1410 None,
1411 &[("claude", command_recording_mount("shared/shared.txt"))],
1412 )?;
1413 let mounts = vec![DirectoryMount {
1414 source: fs::canonicalize(external_dir.path())?,
1415 destination: PathBuf::from("shared"),
1416 }];
1417
1418 let exit_code = run_default_playground(&config, None, true, &mounts)?;
1419 let snapshot = single_saved_snapshot(save_root.path())?;
1420
1421 assert_eq!(exit_code, 0);
1422 assert_eq!(
1423 fs::read_to_string(snapshot.join("mounted.txt"))?,
1424 "from-outside"
1425 );
1426 assert!(
1427 fs::symlink_metadata(snapshot.join("shared"))?
1428 .file_type()
1429 .is_symlink()
1430 );
1431
1432 Ok(())
1433 }
1434
1435 #[test]
1436 fn playground_create_mode_overrides_root_default_during_run() -> Result<()> {
1437 let source_dir = tempdir()?;
1438 let save_root = tempdir()?;
1439 let mut config = make_config(
1440 source_dir.path(),
1441 save_root.path(),
1442 "demo",
1443 Some("claude"),
1444 None,
1445 None,
1446 &[(
1447 "claude",
1448 command_writing_marker("playground-create-mode-override"),
1449 )],
1450 )?;
1451 config.playground_defaults.create_mode = Some(CreateMode::Copy);
1452 config
1453 .playgrounds
1454 .get_mut("demo")
1455 .expect("demo playground")
1456 .playground
1457 .create_mode = Some(CreateMode::Symlink);
1458
1459 let exit_code = run_playground(&config, "demo", None, true, &[])?;
1460 let snapshot = single_saved_snapshot(save_root.path())?;
1461
1462 assert_eq!(exit_code, 0);
1463 assert_eq!(
1464 fs::read_to_string(snapshot.join("agent.txt"))?.trim(),
1465 "playground-create-mode-override"
1466 );
1467 assert_eq!(fs::read_to_string(snapshot.join("notes.txt"))?, "hello");
1468 assert!(
1469 !fs::symlink_metadata(snapshot.join("notes.txt"))?
1470 .file_type()
1471 .is_symlink()
1472 );
1473
1474 Ok(())
1475 }
1476}