Skip to main content

agent_playground/
runner.rs

1//! Runtime execution for launching an agent inside a temporary playground.
2//!
3//! The runner materializes playground files into a throwaway directory, executes
4//! the selected agent command in that directory, and optionally persists the
5//! final state as a snapshot.
6
7use 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
36/// Runs a configured playground with the selected agent command.
37///
38/// The execution flow is:
39///
40/// 1. Resolve the playground and agent command from [`AppConfig`].
41/// 2. Materialize playground contents into a temporary directory.
42/// 3. Optionally load `.env` key-value pairs into the child process.
43/// 4. Run the agent command in the temporary directory.
44/// 5. Optionally save a snapshot of that directory on normal exit.
45///
46/// When `selected_agent_id` is `None`, the module falls back to the
47/// playground default agent and then to the root default agent.
48///
49/// # Returns
50///
51/// Returns the exit code that should be used by the caller process.
52///
53/// # Errors
54///
55/// Returns an error if configuration references are invalid, filesystem
56/// operations fail, environment parsing fails, or the agent process cannot be
57/// started or yields an unrepresentable status.
58pub 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
97/// Runs the selected agent inside an empty temporary playground directory.
98///
99/// This is similar to [`run_playground`], but it does not require a configured
100/// playground template and starts from an empty working directory instead.
101pub 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(&note_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(&note_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(&note_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}