Skip to main content

agent_playground/
config.rs

1//! Configuration models and loaders for `agent-playground`.
2//!
3//! This module owns three related concerns:
4//! - Resolving where configuration files live on disk.
5//! - Reading/writing root and per-playground TOML config files.
6//! - Producing a fully resolved [`crate::config::AppConfig`] used by runtime
7//!   commands.
8//!
9//! The primary entry points are [`crate::config::AppConfig::load`] and
10//! [`crate::config::init_playground`].
11
12use std::{
13    collections::BTreeMap,
14    fs, io,
15    path::{Component, Path, PathBuf},
16    process::{Command, Stdio},
17};
18
19use anyhow::{Context, Result, bail};
20use schemars::{JsonSchema, Schema, schema_for};
21use serde::{Deserialize, Serialize};
22
23use crate::utils::symlink::copy_symlink;
24
25const APP_CONFIG_DIR: &str = "agent-playground";
26const ROOT_CONFIG_FILE_NAME: &str = "config.toml";
27const PLAYGROUND_CONFIG_FILE_NAME: &str = "apg.toml";
28const PLAYGROUNDS_DIR_NAME: &str = "playgrounds";
29const AGENTS_DIR_NAME: &str = "agents";
30const DEFAULT_SUBCOMMAND_PLAYGROUND_ID: &str = "default";
31const DEFAULT_SAVED_PLAYGROUNDS_DIR_NAME: &str = "saved-playgrounds";
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34/// Canonical filesystem paths used by the application config layer.
35pub struct ConfigPaths {
36    /// Root directory containing all app-managed config state.
37    ///
38    /// By default this resolves to `$HOME/.config/agent-playground`.
39    pub root_dir: PathBuf,
40    /// Path to the root config file (`config.toml`).
41    pub config_file: PathBuf,
42    /// Directory containing per-playground subdirectories.
43    pub playgrounds_dir: PathBuf,
44    /// Directory containing per-agent config directories copied during `init`.
45    pub agents_dir: PathBuf,
46}
47
48impl ConfigPaths {
49    /// Builds config paths from the current user's config base directory.
50    ///
51    /// This resolves to `$HOME/.config/agent-playground` on all platforms.
52    pub fn from_user_config_dir() -> Result<Self> {
53        let config_dir = user_config_base_dir()?;
54
55        Ok(Self::from_root_dir(config_dir.join(APP_CONFIG_DIR)))
56    }
57
58    /// Builds config paths from an explicit root directory.
59    pub fn from_root_dir(root_dir: PathBuf) -> Self {
60        Self {
61            config_file: root_dir.join(ROOT_CONFIG_FILE_NAME),
62            playgrounds_dir: root_dir.join(PLAYGROUNDS_DIR_NAME),
63            agents_dir: root_dir.join(AGENTS_DIR_NAME),
64            root_dir,
65        }
66    }
67}
68
69fn user_config_base_dir() -> Result<PathBuf> {
70    let home_dir = dirs::home_dir().context("failed to locate the user's home directory")?;
71    Ok(home_dir.join(".config"))
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75/// Fully resolved application configuration used by command execution.
76///
77/// Values in this struct are post-processed defaults/overrides loaded from
78/// [`RootConfigFile`] and playground-specific [`PlaygroundConfigFile`] entries.
79pub struct AppConfig {
80    /// Resolved filesystem locations for all config assets.
81    pub paths: ConfigPaths,
82    /// Agent identifier to runtime config mapping from `[agent.<id>]`.
83    pub agents: BTreeMap<String, ResolvedAgentConfig>,
84    /// Optional playground id used when `apg` runs without an explicit id.
85    pub default_playground: Option<String>,
86    /// Destination directory where saved snapshot copies are written.
87    pub saved_playgrounds_dir: PathBuf,
88    /// Default playground runtime config inherited by all playgrounds.
89    pub playground_defaults: PlaygroundConfig,
90    /// All discovered playground definitions keyed by playground id.
91    pub playgrounds: BTreeMap<String, PlaygroundDefinition>,
92}
93
94impl AppConfig {
95    /// Loads and validates application configuration from the default location.
96    ///
97    /// If the root config does not exist yet, default files/directories are
98    /// created first.
99    pub fn load() -> Result<Self> {
100        Self::load_from_paths(ConfigPaths::from_user_config_dir()?)
101    }
102
103    fn load_from_paths(paths: ConfigPaths) -> Result<Self> {
104        ensure_root_initialized(&paths)?;
105        let resolved_root_config = load_root_config(&paths)?;
106        let agents = resolved_root_config.agents;
107        let default_playground = resolved_root_config.default_playground;
108        let saved_playgrounds_dir = resolve_saved_playgrounds_dir(
109            &paths.root_dir,
110            resolved_root_config.saved_playgrounds_dir,
111        );
112        let playground_defaults = resolved_root_config.playground_defaults;
113
114        validate_default_agent_defined(
115            &agents,
116            playground_defaults.default_agent.as_deref(),
117            "default agent",
118        )?;
119
120        let playgrounds = load_playgrounds(&paths.playgrounds_dir, &agents, &playground_defaults)?;
121        validate_default_playground(&playgrounds, default_playground.as_deref())?;
122
123        Ok(Self {
124            paths,
125            agents,
126            default_playground,
127            saved_playgrounds_dir,
128            playground_defaults,
129            playgrounds,
130        })
131    }
132
133    /// Returns the effective runtime config for a playground after applying
134    /// root-level playground defaults.
135    pub(crate) fn resolve_playground_config(
136        &self,
137        playground: &PlaygroundDefinition,
138    ) -> Result<ResolvedPlaygroundConfig> {
139        playground
140            .playground
141            .resolve_over(&self.playground_defaults)
142    }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
146/// Result metadata returned by [`init_playground`].
147pub struct InitResult {
148    /// The config paths used for initialization.
149    pub paths: ConfigPaths,
150    /// The initialized playground id.
151    pub playground_id: String,
152    /// Whether `config.toml` was created as part of this call.
153    pub root_config_created: bool,
154    /// Whether the playground config file (`apg.toml`) was created.
155    pub playground_config_created: bool,
156    /// Agent ids whose config directories were initialized in the playground.
157    pub initialized_agent_configs: Vec<String>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
161/// Result metadata returned by [`remove_playground`].
162pub struct RemoveResult {
163    /// The config paths used to resolve the playground location.
164    pub paths: ConfigPaths,
165    /// The removed playground id.
166    pub playground_id: String,
167    /// Path to the removed playground directory.
168    pub playground_dir: PathBuf,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172/// A resolved playground entry loaded from the `playgrounds/` directory.
173pub struct PlaygroundDefinition {
174    /// Stable playground identifier (directory name).
175    pub id: String,
176    /// Human-readable description from `apg.toml`.
177    pub description: String,
178    /// Path to the playground directory.
179    pub directory: PathBuf,
180    /// Path to this playground's `apg.toml` file.
181    pub config_file: PathBuf,
182    /// Per-playground runtime config overrides loaded from `apg.toml`.
183    pub playground: PlaygroundConfig,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
187/// Lightweight playground metadata for side-effect-free UI surfaces like shell completion.
188pub struct ConfiguredPlayground {
189    /// Stable playground identifier (directory name).
190    pub id: String,
191    /// Human-readable description from `apg.toml`.
192    pub description: String,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
196#[serde(rename_all = "lowercase")]
197/// Strategy used to materialize playground contents into a temporary directory.
198pub enum CreateMode {
199    /// Recursively copy files into the temporary directory.
200    #[default]
201    Copy,
202    /// Create symlinks from the temporary directory back to the playground.
203    Symlink,
204    /// Recreate directories and hard-link regular files into the temporary directory.
205    Hardlink,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
209/// Shared playground-scoped config fields used by root defaults and per-playground overrides.
210pub struct PlaygroundConfig {
211    /// Optional default agent id override.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub default_agent: Option<String>,
214    /// Optional flag controlling `.env` loading in playground runs.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub load_env: Option<bool>,
217    /// Optional strategy for creating the temporary playground working tree.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub create_mode: Option<CreateMode>,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
223/// Serializable model for one `[agent.<id>]` entry in root `config.toml`.
224pub struct AgentConfigFile {
225    /// Command used to launch the agent.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub cmd: Option<String>,
228    /// Relative destination directory copied during `apg init --agent <id>`.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub config_dir: Option<PathBuf>,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
234/// Serializable model for the root `config.toml` file.
235pub struct RootConfigFile {
236    /// Agent id to structured config mapping under `[agent.<id>]`.
237    #[serde(default)]
238    pub agent: BTreeMap<String, AgentConfigFile>,
239    /// Optional playground id used when `apg` runs without an explicit id.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub default_playground: Option<String>,
242    /// Optional directory for persisted playground snapshots.
243    ///
244    /// Relative paths are resolved against [`ConfigPaths::root_dir`].
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub saved_playgrounds_dir: Option<PathBuf>,
247    /// Optional defaults inherited by all playgrounds.
248    #[serde(default, skip_serializing_if = "PlaygroundConfig::is_empty")]
249    pub playground: PlaygroundConfig,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
253struct ResolvedRootConfig {
254    agents: BTreeMap<String, ResolvedAgentConfig>,
255    default_playground: Option<String>,
256    saved_playgrounds_dir: PathBuf,
257    playground_defaults: PlaygroundConfig,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
261/// Runtime-ready agent configuration resolved from root `config.toml`.
262pub struct ResolvedAgentConfig {
263    /// Command used to launch the agent.
264    pub cmd: String,
265    /// Destination directory copied during `apg init --agent <id>`.
266    pub config_dir: PathBuf,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub(crate) struct ResolvedPlaygroundConfig {
271    pub(crate) default_agent: String,
272    pub(crate) load_env: bool,
273    pub(crate) create_mode: CreateMode,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
277/// Serializable model for a playground's `apg.toml` file.
278pub struct PlaygroundConfigFile {
279    /// Human-readable description shown in listing output.
280    pub description: String,
281    /// Optional playground-local runtime overrides.
282    #[serde(flatten)]
283    pub playground: PlaygroundConfig,
284}
285
286impl PlaygroundConfig {
287    fn builtin_defaults() -> Self {
288        Self {
289            default_agent: Some("claude".to_string()),
290            load_env: Some(false),
291            create_mode: Some(CreateMode::Copy),
292        }
293    }
294
295    fn is_empty(&self) -> bool {
296        self.default_agent.is_none() && self.load_env.is_none() && self.create_mode.is_none()
297    }
298
299    fn merged_over(&self, base: &Self) -> Self {
300        Self {
301            default_agent: self
302                .default_agent
303                .clone()
304                .or_else(|| base.default_agent.clone()),
305            load_env: self.load_env.or(base.load_env),
306            create_mode: self.create_mode.or(base.create_mode),
307        }
308    }
309
310    fn resolve_over(&self, base: &Self) -> Result<ResolvedPlaygroundConfig> {
311        let merged = self.merged_over(base);
312
313        Ok(ResolvedPlaygroundConfig {
314            default_agent: merged
315                .default_agent
316                .context("default playground config is missing default_agent")?,
317            load_env: merged.load_env.unwrap_or(false),
318            create_mode: merged.create_mode.unwrap_or(CreateMode::Copy),
319        })
320    }
321}
322
323impl AgentConfigFile {
324    fn merged_over(&self, base: &Self) -> Self {
325        Self {
326            cmd: self.cmd.clone().or_else(|| base.cmd.clone()),
327            config_dir: self.config_dir.clone().or_else(|| base.config_dir.clone()),
328        }
329    }
330
331    fn resolve(&self, agent_id: &str) -> Result<ResolvedAgentConfig> {
332        let cmd = self.cmd.clone().unwrap_or_else(|| agent_id.to_string());
333        let config_dir = self
334            .config_dir
335            .clone()
336            .unwrap_or_else(|| PathBuf::from(format!(".{agent_id}/")));
337        let config_dir = normalize_agent_config_dir(agent_id, &config_dir)?;
338
339        Ok(ResolvedAgentConfig { cmd, config_dir })
340    }
341}
342
343impl RootConfigFile {
344    /// Returns a JSON Schema for the root config file format.
345    pub fn json_schema() -> Schema {
346        schema_for!(Self)
347    }
348
349    fn defaults_for_paths(paths: &ConfigPaths) -> Self {
350        let mut agent = BTreeMap::new();
351        agent.insert(
352            "claude".to_string(),
353            AgentConfigFile {
354                cmd: Some("claude".to_string()),
355                config_dir: Some(PathBuf::from(".claude/")),
356            },
357        );
358        agent.insert(
359            "opencode".to_string(),
360            AgentConfigFile {
361                cmd: Some("opencode".to_string()),
362                config_dir: Some(PathBuf::from(".opencode/")),
363            },
364        );
365
366        Self {
367            agent,
368            default_playground: None,
369            saved_playgrounds_dir: Some(default_saved_playgrounds_dir(paths)),
370            playground: PlaygroundConfig::builtin_defaults(),
371        }
372    }
373
374    fn resolve(self, paths: &ConfigPaths) -> Result<ResolvedRootConfig> {
375        let defaults = Self::defaults_for_paths(paths);
376        let mut merged_agents = defaults.agent;
377        for (agent_id, agent_config) in self.agent {
378            if let Some(default_agent_config) = merged_agents.get(&agent_id) {
379                merged_agents.insert(agent_id, agent_config.merged_over(default_agent_config));
380            } else {
381                merged_agents.insert(agent_id, agent_config);
382            }
383        }
384        let mut agents = BTreeMap::new();
385        for (agent_id, agent_config) in merged_agents {
386            validate_agent_id(&agent_id)
387                .with_context(|| format!("invalid agent id in root config: '{agent_id}'"))?;
388            agents.insert(agent_id.clone(), agent_config.resolve(&agent_id)?);
389        }
390        let default_playground = self.default_playground;
391
392        let saved_playgrounds_dir = self
393            .saved_playgrounds_dir
394            .or(defaults.saved_playgrounds_dir)
395            .context("default root config is missing saved_playgrounds_dir")?;
396        let playground_defaults = self.playground.merged_over(&defaults.playground);
397
398        Ok(ResolvedRootConfig {
399            agents,
400            default_playground,
401            saved_playgrounds_dir,
402            playground_defaults,
403        })
404    }
405}
406
407impl PlaygroundConfigFile {
408    /// Returns a JSON Schema for the playground config file format.
409    pub fn json_schema() -> Schema {
410        schema_for!(Self)
411    }
412
413    fn for_playground(playground_id: &str) -> Self {
414        Self {
415            description: format!("TODO: describe {playground_id}"),
416            playground: PlaygroundConfig::default(),
417        }
418    }
419}
420
421/// Initializes a new playground directory and config file.
422///
423/// The playground is created under `playgrounds/<playground_id>`.
424/// When `agent_ids` are provided, matching configured agent directories under
425/// `agents/<agent_id>/` are copied into the configured `config_dir`.
426pub fn init_playground(playground_id: &str, agent_ids: &[String]) -> Result<InitResult> {
427    init_playground_at(
428        ConfigPaths::from_user_config_dir()?,
429        playground_id,
430        agent_ids,
431    )
432}
433
434fn init_playground_at(
435    paths: ConfigPaths,
436    playground_id: &str,
437    agent_ids: &[String],
438) -> Result<InitResult> {
439    init_playground_at_with_git(
440        paths,
441        playground_id,
442        agent_ids,
443        git_is_available,
444        init_git_repo,
445    )
446}
447
448fn init_playground_at_with_git<GA, GI>(
449    paths: ConfigPaths,
450    playground_id: &str,
451    agent_ids: &[String],
452    git_is_available: GA,
453    init_git_repo: GI,
454) -> Result<InitResult>
455where
456    GA: Fn() -> Result<bool>,
457    GI: Fn(&Path) -> Result<()>,
458{
459    validate_playground_id(playground_id)?;
460    let root_config_created = ensure_root_initialized(&paths)?;
461    let root_config = load_root_config(&paths)?;
462    let selected_agent_configs = select_agent_configs(&paths, &root_config.agents, agent_ids)?;
463
464    let playground_dir = paths.playgrounds_dir.join(playground_id);
465    let playground_config_file = playground_dir.join(PLAYGROUND_CONFIG_FILE_NAME);
466
467    if playground_config_file.exists() {
468        bail!(
469            "playground '{}' already exists at {}",
470            playground_id,
471            playground_config_file.display()
472        );
473    }
474
475    fs::create_dir_all(&playground_dir)
476        .with_context(|| format!("failed to create {}", playground_dir.display()))?;
477    write_toml_file(
478        &playground_config_file,
479        &PlaygroundConfigFile::for_playground(playground_id),
480    )?;
481    copy_agent_configs(&playground_dir, &selected_agent_configs)?;
482    if git_is_available()?
483        && let Err(error) = init_git_repo(&playground_dir)
484    {
485        match fs::remove_dir_all(&playground_dir) {
486            Ok(()) => {
487                return Err(error).context(format!(
488                    "failed to initialize git repository in {}; removed partially initialized playground",
489                    playground_dir.display()
490                ));
491            }
492            Err(cleanup_error) => {
493                return Err(error).context(format!(
494                    "failed to initialize git repository in {}; additionally failed to remove partially initialized playground {}: {cleanup_error}",
495                    playground_dir.display(),
496                    playground_dir.display()
497                ));
498            }
499        }
500    }
501
502    Ok(InitResult {
503        paths,
504        playground_id: playground_id.to_string(),
505        root_config_created,
506        playground_config_created: true,
507        initialized_agent_configs: selected_agent_configs
508            .iter()
509            .map(|agent| agent.agent_id.clone())
510            .collect(),
511    })
512}
513
514/// Returns the ids of configured playgrounds without mutating user config.
515///
516/// Unlike [`AppConfig::load`], this does not create default config files or
517/// directories when they are missing. Invalid or incomplete playground
518/// directories are ignored so completion-oriented callers can fail soft.
519pub fn configured_playground_ids() -> Result<Vec<String>> {
520    Ok(configured_playgrounds()?
521        .into_iter()
522        .map(|playground| playground.id)
523        .collect())
524}
525
526/// Returns configured playground metadata without mutating user config.
527///
528/// Unlike [`AppConfig::load`], this does not create default config files or
529/// directories when they are missing. Invalid or incomplete playground
530/// directories are ignored so completion-oriented callers can fail soft.
531pub fn configured_playgrounds() -> Result<Vec<ConfiguredPlayground>> {
532    configured_playgrounds_at(&ConfigPaths::from_user_config_dir()?.playgrounds_dir)
533}
534
535/// Resolves an existing playground directory under the global config root.
536pub fn resolve_playground_dir(playground_id: &str) -> Result<PathBuf> {
537    resolve_playground_dir_at(ConfigPaths::from_user_config_dir()?, playground_id)
538}
539
540/// Removes a playground directory from the global config root.
541pub fn remove_playground(playground_id: &str) -> Result<RemoveResult> {
542    let paths = ConfigPaths::from_user_config_dir()?;
543    remove_playground_at(paths, playground_id)
544}
545
546fn remove_playground_at(paths: ConfigPaths, playground_id: &str) -> Result<RemoveResult> {
547    let playground_dir = resolve_playground_dir_at(paths.clone(), playground_id)?;
548
549    fs::remove_dir_all(&playground_dir)
550        .with_context(|| format!("failed to remove {}", playground_dir.display()))?;
551
552    Ok(RemoveResult {
553        paths,
554        playground_id: playground_id.to_string(),
555        playground_dir,
556    })
557}
558
559fn resolve_playground_dir_at(paths: ConfigPaths, playground_id: &str) -> Result<PathBuf> {
560    validate_playground_id(playground_id)?;
561
562    let playground_dir = paths.playgrounds_dir.join(playground_id);
563    if !playground_dir.exists() {
564        bail!("unknown playground '{playground_id}'");
565    }
566
567    let metadata = fs::symlink_metadata(&playground_dir)
568        .with_context(|| format!("failed to inspect {}", playground_dir.display()))?;
569    if metadata.file_type().is_symlink() {
570        bail!(
571            "playground '{}' cannot be removed because it is a symlink: {}",
572            playground_id,
573            playground_dir.display()
574        );
575    }
576    if !metadata.is_dir() {
577        bail!(
578            "playground '{}' is not a directory: {}",
579            playground_id,
580            playground_dir.display()
581        );
582    }
583
584    Ok(playground_dir)
585}
586
587fn configured_playgrounds_at(playgrounds_dir: &Path) -> Result<Vec<ConfiguredPlayground>> {
588    if !playgrounds_dir.exists() {
589        return Ok(Vec::new());
590    }
591
592    if !playgrounds_dir.is_dir() {
593        bail!(
594            "playground config path is not a directory: {}",
595            playgrounds_dir.display()
596        );
597    }
598
599    let mut playgrounds = Vec::new();
600    for entry_result in fs::read_dir(playgrounds_dir)
601        .with_context(|| format!("failed to read {}", playgrounds_dir.display()))?
602    {
603        let Ok(entry) = entry_result else {
604            // Skip entries that cannot be inspected (e.g., PermissionDenied).
605            continue;
606        };
607
608        let Ok(file_type) = entry.file_type() else {
609            // Skip entries whose type cannot be determined.
610            continue;
611        };
612
613        if !file_type.is_dir() {
614            continue;
615        }
616
617        let playground_id = entry.file_name().to_string_lossy().into_owned();
618        if validate_playground_id(&playground_id).is_err() {
619            continue;
620        }
621
622        let config_file = entry.path().join(PLAYGROUND_CONFIG_FILE_NAME);
623        if !config_file.is_file() {
624            continue;
625        }
626
627        let Ok(playground_config) = read_toml_file::<PlaygroundConfigFile>(&config_file) else {
628            continue;
629        };
630
631        playgrounds.push(ConfiguredPlayground {
632            id: playground_id,
633            description: playground_config.description,
634        });
635    }
636
637    playgrounds.sort_by(|left, right| left.id.cmp(&right.id));
638    Ok(playgrounds)
639}
640
641fn validate_playground_id(playground_id: &str) -> Result<()> {
642    if playground_id.is_empty() {
643        bail!("playground id cannot be empty");
644    }
645    if playground_id == DEFAULT_SUBCOMMAND_PLAYGROUND_ID {
646        bail!(
647            "invalid playground id '{playground_id}': this name is reserved for the `default` subcommand"
648        );
649    }
650    if playground_id.starts_with("__") {
651        bail!(
652            "invalid playground id '{playground_id}': ids starting with '__' are reserved for internal use"
653        );
654    }
655    if matches!(playground_id, "." | "..")
656        || playground_id.contains('/')
657        || playground_id.contains('\\')
658    {
659        bail!(
660            "invalid playground id '{}': ids must not contain path separators or parent-directory segments",
661            playground_id
662        );
663    }
664
665    Ok(())
666}
667
668fn validate_agent_id(agent_id: &str) -> Result<()> {
669    if agent_id.is_empty() {
670        bail!("agent id cannot be empty");
671    }
672    if matches!(agent_id, "." | "..") || agent_id.contains('/') || agent_id.contains('\\') {
673        bail!(
674            "invalid agent id '{}': ids must not contain path separators or parent-directory segments",
675            agent_id
676        );
677    }
678
679    Ok(())
680}
681
682fn git_is_available() -> Result<bool> {
683    match Command::new("git")
684        .arg("--version")
685        .stdout(Stdio::null())
686        .stderr(Stdio::null())
687        .status()
688    {
689        Ok(status) => Ok(status.success()),
690        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
691        Err(error) => Err(error).context("failed to check whether git is available"),
692    }
693}
694
695fn init_git_repo(playground_dir: &Path) -> Result<()> {
696    let status = Command::new("git")
697        .arg("init")
698        .current_dir(playground_dir)
699        .stdout(Stdio::null())
700        .stderr(Stdio::null())
701        .status()
702        .with_context(|| {
703            format!(
704                "failed to initialize git repository in {}",
705                playground_dir.display()
706            )
707        })?;
708
709    if !status.success() {
710        bail!(
711            "git init exited with status {status} in {}",
712            playground_dir.display()
713        );
714    }
715
716    Ok(())
717}
718
719#[derive(Debug, Clone, PartialEq, Eq)]
720struct SelectedAgentConfig {
721    agent_id: String,
722    source_dir: PathBuf,
723    destination_dir: PathBuf,
724}
725
726fn select_agent_configs(
727    paths: &ConfigPaths,
728    agents: &BTreeMap<String, ResolvedAgentConfig>,
729    agent_ids: &[String],
730) -> Result<Vec<SelectedAgentConfig>> {
731    let available_agent_ids = agents.keys().cloned().collect::<Vec<_>>();
732    let mut selected_agents = Vec::new();
733    let mut destination_agents: BTreeMap<PathBuf, String> = BTreeMap::new();
734
735    for agent_id in agent_ids {
736        validate_agent_id(agent_id)?;
737
738        if selected_agents
739            .iter()
740            .any(|selected_agent: &SelectedAgentConfig| &selected_agent.agent_id == agent_id)
741        {
742            continue;
743        }
744
745        let agent_config = agents.get(agent_id).with_context(|| {
746            format!(
747                "unknown agent '{agent_id}'. Available agents: {}",
748                if available_agent_ids.is_empty() {
749                    "(none)".to_string()
750                } else {
751                    available_agent_ids.join(", ")
752                }
753            )
754        })?;
755        if let Some(existing_agent_id) = destination_agents.get(&agent_config.config_dir) {
756            bail!(
757                "agent config_dir conflict: '{agent_id}' and '{existing_agent_id}' both target '{}'",
758                agent_config.config_dir.display()
759            );
760        }
761
762        destination_agents.insert(agent_config.config_dir.clone(), agent_id.clone());
763        selected_agents.push(SelectedAgentConfig {
764            agent_id: agent_id.clone(),
765            source_dir: paths.agents_dir.join(agent_id),
766            destination_dir: agent_config.config_dir.clone(),
767        });
768    }
769
770    Ok(selected_agents)
771}
772
773fn copy_agent_configs(playground_dir: &Path, agent_configs: &[SelectedAgentConfig]) -> Result<()> {
774    for agent_config in agent_configs {
775        let destination = playground_dir.join(&agent_config.destination_dir);
776        fs::create_dir_all(&destination)
777            .with_context(|| format!("failed to create {}", destination.display()))?;
778
779        if !agent_config.source_dir.exists() {
780            continue;
781        }
782
783        let source_metadata =
784            fs::symlink_metadata(&agent_config.source_dir).with_context(|| {
785                format!(
786                    "failed to inspect {} for agent '{}'",
787                    agent_config.source_dir.display(),
788                    agent_config.agent_id
789                )
790            })?;
791        if !source_metadata.is_dir() {
792            bail!(
793                "agent config source for '{}' must be a directory: {}",
794                agent_config.agent_id,
795                agent_config.source_dir.display()
796            );
797        }
798
799        copy_directory_contents_recursively(&agent_config.source_dir, &destination)?;
800    }
801
802    Ok(())
803}
804
805fn copy_directory_contents_recursively(source_dir: &Path, destination_dir: &Path) -> Result<()> {
806    for entry in fs::read_dir(source_dir)
807        .with_context(|| format!("failed to read {}", source_dir.display()))?
808    {
809        let entry = entry.with_context(|| {
810            format!("failed to inspect an entry under {}", source_dir.display())
811        })?;
812        let source_path = entry.path();
813        let destination_path = destination_dir.join(entry.file_name());
814        let file_type = entry.file_type().with_context(|| {
815            format!("failed to inspect file type for {}", source_path.display())
816        })?;
817
818        if file_type.is_dir() {
819            fs::create_dir_all(&destination_path)
820                .with_context(|| format!("failed to create {}", destination_path.display()))?;
821            copy_directory_contents_recursively(&source_path, &destination_path)?;
822        } else if file_type.is_symlink() {
823            copy_symlink(&source_path, &destination_path)?;
824        } else if file_type.is_file() {
825            fs::copy(&source_path, &destination_path).with_context(|| {
826                format!(
827                    "failed to copy {} to {}",
828                    source_path.display(),
829                    destination_path.display()
830                )
831            })?;
832        } else {
833            bail!(
834                "unsupported entry in agent config source: {}",
835                source_path.display()
836            );
837        }
838    }
839
840    Ok(())
841}
842
843fn ensure_root_initialized(paths: &ConfigPaths) -> Result<bool> {
844    fs::create_dir_all(&paths.root_dir)
845        .with_context(|| format!("failed to create {}", paths.root_dir.display()))?;
846    fs::create_dir_all(&paths.playgrounds_dir)
847        .with_context(|| format!("failed to create {}", paths.playgrounds_dir.display()))?;
848    fs::create_dir_all(&paths.agents_dir)
849        .with_context(|| format!("failed to create {}", paths.agents_dir.display()))?;
850
851    if paths.config_file.exists() {
852        return Ok(false);
853    }
854
855    write_toml_file(
856        &paths.config_file,
857        &RootConfigFile::defaults_for_paths(paths),
858    )?;
859
860    Ok(true)
861}
862
863fn load_root_config(paths: &ConfigPaths) -> Result<ResolvedRootConfig> {
864    read_toml_file::<RootConfigFile>(&paths.config_file)?.resolve(paths)
865}
866
867fn default_saved_playgrounds_dir(_paths: &ConfigPaths) -> PathBuf {
868    PathBuf::from(DEFAULT_SAVED_PLAYGROUNDS_DIR_NAME)
869}
870
871fn resolve_saved_playgrounds_dir(root_dir: &Path, configured_path: PathBuf) -> PathBuf {
872    if configured_path.is_absolute() {
873        return configured_path;
874    }
875
876    root_dir.join(configured_path)
877}
878
879fn normalize_agent_config_dir(agent_id: &str, config_dir: &Path) -> Result<PathBuf> {
880    if config_dir.as_os_str().is_empty() {
881        bail!("agent '{agent_id}' config_dir cannot be empty");
882    }
883
884    let mut normalized = PathBuf::new();
885    for component in config_dir.components() {
886        match component {
887            Component::Normal(part) => normalized.push(part),
888            Component::CurDir => {}
889            Component::ParentDir => {
890                bail!("agent '{agent_id}' config_dir must not contain '..'");
891            }
892            Component::RootDir | Component::Prefix(_) => {
893                bail!("agent '{agent_id}' config_dir must be a relative path");
894            }
895        }
896    }
897
898    if normalized.as_os_str().is_empty() {
899        bail!("agent '{agent_id}' config_dir cannot be empty");
900    }
901
902    Ok(normalized)
903}
904
905fn validate_default_agent_defined(
906    agents: &BTreeMap<String, ResolvedAgentConfig>,
907    default_agent: Option<&str>,
908    label: &str,
909) -> Result<()> {
910    let Some(default_agent) = default_agent else {
911        bail!("{label} is missing");
912    };
913
914    if !agents.contains_key(default_agent) {
915        bail!("{label} '{default_agent}' is not defined in [agent.<id>]");
916    }
917
918    Ok(())
919}
920
921fn validate_default_playground(
922    playgrounds: &BTreeMap<String, PlaygroundDefinition>,
923    default_playground: Option<&str>,
924) -> Result<()> {
925    let Some(default_playground) = default_playground else {
926        return Ok(());
927    };
928
929    validate_playground_id(default_playground)
930        .with_context(|| "default_playground is invalid".to_string())?;
931
932    if !playgrounds.contains_key(default_playground) {
933        bail!("default_playground '{default_playground}' is not a configured playground");
934    }
935
936    Ok(())
937}
938
939fn load_playgrounds(
940    playgrounds_dir: &Path,
941    agents: &BTreeMap<String, ResolvedAgentConfig>,
942    playground_defaults: &PlaygroundConfig,
943) -> Result<BTreeMap<String, PlaygroundDefinition>> {
944    if !playgrounds_dir.exists() {
945        return Ok(BTreeMap::new());
946    }
947
948    if !playgrounds_dir.is_dir() {
949        bail!(
950            "playground config path is not a directory: {}",
951            playgrounds_dir.display()
952        );
953    }
954
955    let mut playgrounds = BTreeMap::new();
956
957    for entry in fs::read_dir(playgrounds_dir)
958        .with_context(|| format!("failed to read {}", playgrounds_dir.display()))?
959    {
960        let entry = entry.with_context(|| {
961            format!(
962                "failed to inspect an entry under {}",
963                playgrounds_dir.display()
964            )
965        })?;
966        let file_type = entry.file_type().with_context(|| {
967            format!("failed to inspect file type for {}", entry.path().display())
968        })?;
969
970        if !file_type.is_dir() {
971            continue;
972        }
973
974        let directory = entry.path();
975        let config_file = directory.join(PLAYGROUND_CONFIG_FILE_NAME);
976
977        if !config_file.is_file() {
978            bail!(
979                "playground '{}' is missing {}",
980                directory.file_name().unwrap_or_default().to_string_lossy(),
981                PLAYGROUND_CONFIG_FILE_NAME
982            );
983        }
984
985        let playground_config: PlaygroundConfigFile = read_toml_file(&config_file)?;
986        let id = entry.file_name().to_string_lossy().into_owned();
987        validate_playground_id(&id).with_context(|| {
988            format!(
989                "invalid playground directory under {}",
990                playgrounds_dir.display()
991            )
992        })?;
993        let effective_config = playground_config
994            .playground
995            .merged_over(playground_defaults);
996        validate_default_agent_defined(
997            agents,
998            effective_config.default_agent.as_deref(),
999            &format!("playground '{id}' default agent"),
1000        )?;
1001
1002        playgrounds.insert(
1003            id.clone(),
1004            PlaygroundDefinition {
1005                id,
1006                description: playground_config.description,
1007                directory,
1008                config_file,
1009                playground: playground_config.playground,
1010            },
1011        );
1012    }
1013
1014    Ok(playgrounds)
1015}
1016
1017fn read_toml_file<T>(path: &Path) -> Result<T>
1018where
1019    T: for<'de> Deserialize<'de>,
1020{
1021    let content =
1022        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
1023
1024    toml::from_str(&content)
1025        .with_context(|| format!("failed to parse TOML from {}", path.display()))
1026}
1027
1028fn write_toml_file<T>(path: &Path, value: &T) -> Result<()>
1029where
1030    T: Serialize,
1031{
1032    let content =
1033        toml::to_string_pretty(value).context("failed to serialize configuration to TOML")?;
1034    fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039    use super::{
1040        APP_CONFIG_DIR, AppConfig, ConfigPaths, ConfiguredPlayground, CreateMode,
1041        PlaygroundConfigFile, RootConfigFile, configured_playgrounds_at, init_playground_at,
1042        init_playground_at_with_git, read_toml_file, remove_playground_at,
1043        resolve_playground_dir_at, user_config_base_dir,
1044    };
1045    use serde_json::Value;
1046    use std::{cell::Cell, fs, io};
1047    use tempfile::TempDir;
1048
1049    #[cfg(unix)]
1050    fn create_test_symlink(source: &std::path::Path, destination: &std::path::Path) {
1051        std::os::unix::fs::symlink(source, destination).expect("create symlink");
1052    }
1053
1054    #[cfg(windows)]
1055    fn create_test_symlink(source: &std::path::Path, destination: &std::path::Path) {
1056        std::os::windows::fs::symlink_file(source, destination).expect("create symlink");
1057    }
1058
1059    fn resolved_agent_cmd(config: &AppConfig, agent_id: &str) -> Option<String> {
1060        config.agents.get(agent_id).map(|agent| agent.cmd.clone())
1061    }
1062
1063    fn resolved_agent_config_dir(config: &AppConfig, agent_id: &str) -> Option<std::path::PathBuf> {
1064        config
1065            .agents
1066            .get(agent_id)
1067            .map(|agent| agent.config_dir.clone())
1068    }
1069
1070    #[test]
1071    fn init_creates_root_and_playground_configs_from_file_models() {
1072        let temp_dir = TempDir::new().expect("temp dir");
1073        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1074
1075        let result = init_playground_at(paths.clone(), "demo", &[]).expect("init should succeed");
1076
1077        assert!(result.root_config_created);
1078        assert!(result.playground_config_created);
1079        assert!(result.initialized_agent_configs.is_empty());
1080        assert!(temp_dir.path().join("config.toml").is_file());
1081        assert!(
1082            temp_dir
1083                .path()
1084                .join("playgrounds")
1085                .join("demo")
1086                .join("apg.toml")
1087                .is_file()
1088        );
1089        assert!(
1090            !temp_dir
1091                .path()
1092                .join("playgrounds")
1093                .join("demo")
1094                .join(".claude")
1095                .exists()
1096        );
1097        assert_eq!(
1098            read_toml_file::<RootConfigFile>(&temp_dir.path().join("config.toml"))
1099                .expect("root config"),
1100            RootConfigFile::defaults_for_paths(&paths)
1101        );
1102        assert_eq!(
1103            read_toml_file::<PlaygroundConfigFile>(
1104                &temp_dir
1105                    .path()
1106                    .join("playgrounds")
1107                    .join("demo")
1108                    .join("apg.toml")
1109            )
1110            .expect("playground config"),
1111            PlaygroundConfigFile::for_playground("demo")
1112        );
1113
1114        let config = AppConfig::load_from_paths(paths).expect("config should load");
1115        assert_eq!(
1116            resolved_agent_cmd(&config, "claude"),
1117            Some("claude".to_string())
1118        );
1119        assert_eq!(
1120            resolved_agent_cmd(&config, "opencode"),
1121            Some("opencode".to_string())
1122        );
1123        assert_eq!(
1124            resolved_agent_config_dir(&config, "claude"),
1125            Some(std::path::PathBuf::from(".claude"))
1126        );
1127        assert_eq!(
1128            resolved_agent_config_dir(&config, "opencode"),
1129            Some(std::path::PathBuf::from(".opencode"))
1130        );
1131        assert_eq!(
1132            config.playground_defaults.default_agent.as_deref(),
1133            Some("claude")
1134        );
1135        assert_eq!(config.default_playground, None);
1136        assert_eq!(config.playground_defaults.load_env, Some(false));
1137        assert_eq!(
1138            config.playground_defaults.create_mode,
1139            Some(CreateMode::Copy)
1140        );
1141        assert_eq!(
1142            config.saved_playgrounds_dir,
1143            temp_dir.path().join("saved-playgrounds")
1144        );
1145        assert_eq!(
1146            config
1147                .playgrounds
1148                .get("demo")
1149                .expect("demo playground")
1150                .description,
1151            "TODO: describe demo"
1152        );
1153        assert!(
1154            config
1155                .playgrounds
1156                .get("demo")
1157                .expect("demo playground")
1158                .playground
1159                .is_empty()
1160        );
1161    }
1162
1163    #[test]
1164    fn merges_root_agents_and_loads_playgrounds() {
1165        let temp_dir = TempDir::new().expect("temp dir");
1166        let root = temp_dir.path();
1167        fs::write(
1168            root.join("config.toml"),
1169            r#"saved_playgrounds_dir = "archives"
1170default_playground = "demo"
1171
1172[agent.claude]
1173cmd = "custom-claude"
1174
1175[agent.codex]
1176cmd = "codex --fast"
1177
1178[playground]
1179default_agent = "codex"
1180load_env = true
1181create_mode = "hardlink"
1182"#,
1183        )
1184        .expect("write root config");
1185
1186        let playground_dir = root.join("playgrounds").join("demo");
1187        fs::create_dir_all(&playground_dir).expect("create playground dir");
1188        fs::write(
1189            playground_dir.join("apg.toml"),
1190            r#"description = "Demo playground"
1191default_agent = "claude""#,
1192        )
1193        .expect("write playground config");
1194
1195        let config = AppConfig::load_from_paths(ConfigPaths::from_root_dir(root.to_path_buf()))
1196            .expect("config should load");
1197
1198        assert_eq!(
1199            resolved_agent_cmd(&config, "claude"),
1200            Some("custom-claude".to_string())
1201        );
1202        assert_eq!(
1203            resolved_agent_cmd(&config, "opencode"),
1204            Some("opencode".to_string())
1205        );
1206        assert_eq!(
1207            resolved_agent_cmd(&config, "codex"),
1208            Some("codex --fast".to_string())
1209        );
1210        assert_eq!(
1211            config.playground_defaults.default_agent.as_deref(),
1212            Some("codex")
1213        );
1214        assert_eq!(config.default_playground.as_deref(), Some("demo"));
1215        assert_eq!(config.playground_defaults.load_env, Some(true));
1216        assert_eq!(
1217            config.playground_defaults.create_mode,
1218            Some(CreateMode::Hardlink)
1219        );
1220        assert_eq!(config.saved_playgrounds_dir, root.join("archives"));
1221
1222        let playground = config.playgrounds.get("demo").expect("demo playground");
1223        assert_eq!(playground.description, "Demo playground");
1224        assert_eq!(
1225            playground.playground.default_agent.as_deref(),
1226            Some("claude")
1227        );
1228        assert_eq!(playground.directory, playground_dir);
1229        let effective_config = config
1230            .resolve_playground_config(playground)
1231            .expect("effective playground config");
1232        assert_eq!(effective_config.default_agent, "claude");
1233        assert!(effective_config.load_env);
1234        assert_eq!(effective_config.create_mode, CreateMode::Hardlink);
1235    }
1236
1237    #[test]
1238    fn playground_create_mode_overrides_root_default() {
1239        let temp_dir = TempDir::new().expect("temp dir");
1240        fs::write(
1241            temp_dir.path().join("config.toml"),
1242            r#"[playground]
1243create_mode = "copy"
1244"#,
1245        )
1246        .expect("write root config");
1247        let playground_dir = temp_dir.path().join("playgrounds").join("demo");
1248        fs::create_dir_all(&playground_dir).expect("create playground dir");
1249        fs::write(
1250            playground_dir.join("apg.toml"),
1251            r#"description = "Demo playground"
1252create_mode = "symlink""#,
1253        )
1254        .expect("write playground config");
1255
1256        let config =
1257            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1258                .expect("config should load");
1259        let playground = config.playgrounds.get("demo").expect("demo playground");
1260        let effective_config = config
1261            .resolve_playground_config(playground)
1262            .expect("effective playground config");
1263
1264        assert_eq!(
1265            config.playground_defaults.create_mode,
1266            Some(CreateMode::Copy)
1267        );
1268        assert_eq!(playground.playground.create_mode, Some(CreateMode::Symlink));
1269        assert_eq!(effective_config.create_mode, CreateMode::Symlink);
1270    }
1271
1272    #[test]
1273    fn errors_when_playground_default_agent_is_not_defined() {
1274        let temp_dir = TempDir::new().expect("temp dir");
1275        fs::write(
1276            temp_dir.path().join("config.toml"),
1277            r#"[agent.claude]
1278cmd = "claude"
1279"#,
1280        )
1281        .expect("write root config");
1282        let playground_dir = temp_dir.path().join("playgrounds").join("demo");
1283        fs::create_dir_all(&playground_dir).expect("create playground dir");
1284        fs::write(
1285            playground_dir.join("apg.toml"),
1286            r#"description = "Demo playground"
1287default_agent = "codex""#,
1288        )
1289        .expect("write playground config");
1290
1291        let error =
1292            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1293                .expect_err("undefined playground default agent should fail");
1294
1295        assert!(
1296            error
1297                .to_string()
1298                .contains("playground 'demo' default agent 'codex' is not defined")
1299        );
1300    }
1301
1302    #[test]
1303    fn load_auto_initializes_missing_root_config() {
1304        let temp_dir = TempDir::new().expect("temp dir");
1305        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1306
1307        let config = AppConfig::load_from_paths(paths).expect("missing root config should init");
1308
1309        assert!(temp_dir.path().join("config.toml").is_file());
1310        assert!(temp_dir.path().join("playgrounds").is_dir());
1311        assert!(temp_dir.path().join("agents").is_dir());
1312        assert_eq!(
1313            resolved_agent_cmd(&config, "claude"),
1314            Some("claude".to_string())
1315        );
1316        assert_eq!(
1317            config.playground_defaults.default_agent.as_deref(),
1318            Some("claude")
1319        );
1320        assert_eq!(config.default_playground, None);
1321        assert_eq!(config.playground_defaults.load_env, Some(false));
1322        assert_eq!(
1323            config.playground_defaults.create_mode,
1324            Some(CreateMode::Copy)
1325        );
1326        assert_eq!(
1327            config.saved_playgrounds_dir,
1328            temp_dir.path().join("saved-playgrounds")
1329        );
1330    }
1331
1332    #[test]
1333    fn respects_absolute_saved_playgrounds_dir() {
1334        let temp_dir = TempDir::new().expect("temp dir");
1335        let archive_dir = TempDir::new().expect("archive dir");
1336        let archive_path = archive_dir
1337            .path()
1338            .display()
1339            .to_string()
1340            .replace('\\', "\\\\");
1341        fs::write(
1342            temp_dir.path().join("config.toml"),
1343            format!(
1344                r#"saved_playgrounds_dir = "{}"
1345
1346[agent.claude]
1347cmd = "claude"
1348"#,
1349                archive_path
1350            ),
1351        )
1352        .expect("write root config");
1353
1354        let config =
1355            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1356                .expect("config should load");
1357
1358        assert_eq!(config.saved_playgrounds_dir, archive_dir.path());
1359    }
1360
1361    #[test]
1362    fn errors_when_playground_config_is_missing() {
1363        let temp_dir = TempDir::new().expect("temp dir");
1364        fs::write(
1365            temp_dir.path().join("config.toml"),
1366            r#"[agent.claude]
1367cmd = "claude"
1368
1369[agent.opencode]
1370cmd = "opencode"
1371"#,
1372        )
1373        .expect("write root config");
1374        let playground_dir = temp_dir.path().join("playgrounds").join("broken");
1375        fs::create_dir_all(&playground_dir).expect("create playground dir");
1376
1377        let error =
1378            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1379                .expect_err("missing playground config should fail");
1380
1381        assert!(error.to_string().contains("missing apg.toml"));
1382    }
1383
1384    #[test]
1385    fn errors_when_default_agent_is_not_defined() {
1386        let temp_dir = TempDir::new().expect("temp dir");
1387        fs::write(
1388            temp_dir.path().join("config.toml"),
1389            r#"[playground]
1390default_agent = "codex""#,
1391        )
1392        .expect("write root config");
1393
1394        let error =
1395            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1396                .expect_err("undefined default agent should fail");
1397
1398        assert!(
1399            error
1400                .to_string()
1401                .contains("default agent 'codex' is not defined")
1402        );
1403    }
1404
1405    #[test]
1406    fn errors_when_default_playground_is_not_configured() {
1407        let temp_dir = TempDir::new().expect("temp dir");
1408        fs::write(
1409            temp_dir.path().join("config.toml"),
1410            r#"default_playground = "missing"
1411
1412[agent.claude]
1413cmd = "claude"
1414"#,
1415        )
1416        .expect("write root config");
1417
1418        let error =
1419            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1420                .expect_err("unknown default playground should fail");
1421
1422        assert!(
1423            error
1424                .to_string()
1425                .contains("default_playground 'missing' is not a configured playground")
1426        );
1427    }
1428
1429    #[test]
1430    fn errors_when_default_playground_uses_reserved_name() {
1431        let temp_dir = TempDir::new().expect("temp dir");
1432        fs::write(
1433            temp_dir.path().join("config.toml"),
1434            r#"default_playground = "default"
1435
1436[agent.claude]
1437cmd = "claude"
1438"#,
1439        )
1440        .expect("write root config");
1441
1442        let error =
1443            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1444                .expect_err("reserved default playground should fail");
1445        let message = format!("{error:#}");
1446
1447        assert!(message.contains("default_playground is invalid"));
1448        assert!(message.contains("reserved for the `default` subcommand"));
1449    }
1450
1451    #[test]
1452    fn init_errors_when_playground_already_exists() {
1453        let temp_dir = TempDir::new().expect("temp dir");
1454        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1455
1456        init_playground_at(paths.clone(), "demo", &[]).expect("initial init should succeed");
1457        let error = init_playground_at(paths, "demo", &[]).expect_err("duplicate init should fail");
1458
1459        assert!(
1460            error
1461                .to_string()
1462                .contains("playground 'demo' already exists")
1463        );
1464    }
1465
1466    #[test]
1467    fn init_rejects_reserved_default_playground_id() {
1468        let temp_dir = TempDir::new().expect("temp dir");
1469        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1470
1471        let error = init_playground_at(paths, "default", &[]).expect_err("reserved id should fail");
1472
1473        assert!(
1474            error
1475                .to_string()
1476                .contains("invalid playground id 'default'")
1477        );
1478        assert!(
1479            error
1480                .to_string()
1481                .contains("reserved for the `default` subcommand")
1482        );
1483    }
1484
1485    #[test]
1486    fn init_rejects_internal_reserved_playground_id_prefix() {
1487        let temp_dir = TempDir::new().expect("temp dir");
1488        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1489
1490        let error =
1491            init_playground_at(paths, "__default__", &[]).expect_err("reserved id should fail");
1492
1493        assert!(
1494            error
1495                .to_string()
1496                .contains("ids starting with '__' are reserved for internal use")
1497        );
1498    }
1499
1500    #[test]
1501    fn remove_deletes_existing_playground_directory() {
1502        let temp_dir = TempDir::new().expect("temp dir");
1503        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1504        let nested_file = temp_dir
1505            .path()
1506            .join("playgrounds")
1507            .join("demo")
1508            .join("notes.txt");
1509
1510        init_playground_at(paths.clone(), "demo", &[]).expect("init should succeed");
1511        fs::write(&nested_file, "hello").expect("write nested file");
1512
1513        let result = remove_playground_at(paths.clone(), "demo").expect("remove should succeed");
1514
1515        assert_eq!(result.paths, paths);
1516        assert_eq!(result.playground_id, "demo");
1517        assert_eq!(
1518            result.playground_dir,
1519            temp_dir.path().join("playgrounds").join("demo")
1520        );
1521        assert!(!result.playground_dir.exists());
1522    }
1523
1524    #[test]
1525    fn remove_errors_for_unknown_playground() {
1526        let temp_dir = TempDir::new().expect("temp dir");
1527        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1528
1529        let error =
1530            remove_playground_at(paths, "missing").expect_err("missing playground should fail");
1531
1532        assert!(error.to_string().contains("unknown playground 'missing'"));
1533    }
1534
1535    #[test]
1536    fn resolve_playground_dir_rejects_path_traversal_ids() {
1537        let temp_dir = TempDir::new().expect("temp dir");
1538        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1539
1540        let error = resolve_playground_dir_at(paths, "../demo")
1541            .expect_err("path traversal playground id should fail");
1542
1543        assert!(
1544            error
1545                .to_string()
1546                .contains("invalid playground id '../demo'")
1547        );
1548    }
1549
1550    #[test]
1551    fn init_rejects_path_traversal_ids_before_writing_files() {
1552        let temp_dir = TempDir::new().expect("temp dir");
1553        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1554
1555        let error = init_playground_at(paths, "../demo", &[])
1556            .expect_err("path traversal playground id should fail");
1557
1558        assert!(
1559            error
1560                .to_string()
1561                .contains("invalid playground id '../demo'")
1562        );
1563        assert!(!temp_dir.path().join("config.toml").exists());
1564        assert!(!temp_dir.path().join("playgrounds").exists());
1565        assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1566    }
1567
1568    #[test]
1569    fn init_cleans_up_playground_directory_when_git_init_fails() {
1570        let temp_dir = TempDir::new().expect("temp dir");
1571        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1572
1573        let error = init_playground_at_with_git(
1574            paths,
1575            "demo",
1576            &[],
1577            || Ok(true),
1578            |_| Err(io::Error::other("git init failed").into()),
1579        )
1580        .expect_err("git init failure should fail init");
1581
1582        let error_message = format!("{error:#}");
1583
1584        assert!(error_message.contains("git init failed"));
1585        assert!(error_message.contains("removed partially initialized playground"));
1586        assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1587    }
1588
1589    #[test]
1590    fn init_copies_existing_agent_sources_and_creates_missing_targets() {
1591        let temp_dir = TempDir::new().expect("temp dir");
1592        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1593        let selected_agents = vec!["claude".to_string(), "opencode".to_string()];
1594
1595        let claude_source_dir = paths.agents_dir.join("claude");
1596        fs::create_dir_all(&claude_source_dir).expect("create claude source");
1597        fs::write(
1598            claude_source_dir.join("settings.json"),
1599            r#"{"theme":"dark"}"#,
1600        )
1601        .expect("write claude source file");
1602
1603        let result =
1604            init_playground_at(paths, "demo", &selected_agents).expect("init should succeed");
1605        let playground_dir = temp_dir.path().join("playgrounds").join("demo");
1606
1607        assert_eq!(
1608            result.initialized_agent_configs,
1609            vec!["claude".to_string(), "opencode".to_string()]
1610        );
1611        assert!(
1612            playground_dir
1613                .join(".claude")
1614                .join("settings.json")
1615                .is_file()
1616        );
1617        assert!(playground_dir.join(".opencode").is_dir());
1618    }
1619
1620    #[test]
1621    fn init_initializes_git_repo_when_git_is_available() {
1622        let temp_dir = TempDir::new().expect("temp dir");
1623        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1624        let git_init_called = Cell::new(false);
1625
1626        init_playground_at_with_git(
1627            paths,
1628            "demo",
1629            &[],
1630            || Ok(true),
1631            |playground_dir| {
1632                git_init_called.set(true);
1633                fs::create_dir(playground_dir.join(".git")).expect("create .git directory");
1634                Ok(())
1635            },
1636        )
1637        .expect("init should succeed");
1638
1639        assert!(git_init_called.get());
1640        assert!(
1641            temp_dir
1642                .path()
1643                .join("playgrounds")
1644                .join("demo")
1645                .join(".git")
1646                .is_dir()
1647        );
1648    }
1649
1650    #[test]
1651    fn init_skips_git_repo_when_git_is_unavailable() {
1652        let temp_dir = TempDir::new().expect("temp dir");
1653        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1654        let git_init_called = Cell::new(false);
1655
1656        init_playground_at_with_git(
1657            paths,
1658            "demo",
1659            &[],
1660            || Ok(false),
1661            |_| {
1662                git_init_called.set(true);
1663                Ok(())
1664            },
1665        )
1666        .expect("init should succeed");
1667
1668        assert!(!git_init_called.get());
1669        assert!(
1670            !temp_dir
1671                .path()
1672                .join("playgrounds")
1673                .join("demo")
1674                .join(".git")
1675                .exists()
1676        );
1677    }
1678
1679    #[test]
1680    fn init_deduplicates_selected_agent_configs() {
1681        let temp_dir = TempDir::new().expect("temp dir");
1682        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1683        let selected_agents = vec![
1684            "claude".to_string(),
1685            "claude".to_string(),
1686            "opencode".to_string(),
1687        ];
1688
1689        let result =
1690            init_playground_at(paths, "demo", &selected_agents).expect("init should succeed");
1691
1692        assert_eq!(
1693            result.initialized_agent_configs,
1694            vec!["claude".to_string(), "opencode".to_string()]
1695        );
1696    }
1697
1698    #[test]
1699    fn init_errors_for_unknown_agent_before_creating_playground() {
1700        let temp_dir = TempDir::new().expect("temp dir");
1701        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1702        let selected_agents = vec!["missing".to_string()];
1703
1704        let error = init_playground_at(paths, "demo", &selected_agents)
1705            .expect_err("unknown agent should fail");
1706
1707        assert!(error.to_string().contains("unknown agent 'missing'"));
1708        assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1709    }
1710
1711    #[test]
1712    fn init_errors_when_selected_agents_share_the_same_config_dir() {
1713        let temp_dir = TempDir::new().expect("temp dir");
1714        let root_dir = temp_dir.path();
1715        fs::write(
1716            root_dir.join("config.toml"),
1717            r#"[agent.alpha]
1718cmd = "alpha"
1719config_dir = ".shared/"
1720
1721[agent.beta]
1722cmd = "beta"
1723config_dir = ".shared/"
1724"#,
1725        )
1726        .expect("write root config");
1727
1728        let error = init_playground_at(
1729            ConfigPaths::from_root_dir(root_dir.to_path_buf()),
1730            "demo",
1731            &["alpha".to_string(), "beta".to_string()],
1732        )
1733        .expect_err("conflicting config_dir should fail");
1734
1735        assert!(error.to_string().contains("agent config_dir conflict"));
1736    }
1737
1738    #[test]
1739    fn errors_when_agent_config_dir_is_not_safe_relative_path() {
1740        let temp_dir = TempDir::new().expect("temp dir");
1741        fs::write(
1742            temp_dir.path().join("config.toml"),
1743            r#"[agent.bad]
1744cmd = "bad"
1745config_dir = "../outside"
1746"#,
1747        )
1748        .expect("write root config");
1749
1750        let error =
1751            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1752                .expect_err("unsafe config_dir should fail");
1753
1754        assert!(error.to_string().contains("config_dir"));
1755        assert!(error.to_string().contains("must not contain '..'"));
1756    }
1757
1758    #[test]
1759    fn errors_when_agent_id_is_not_safe_relative_key() {
1760        let temp_dir = TempDir::new().expect("temp dir");
1761        fs::write(
1762            temp_dir.path().join("config.toml"),
1763            r#"[agent."../escape"]
1764cmd = "bad"
1765"#,
1766        )
1767        .expect("write root config");
1768
1769        let error =
1770            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1771                .expect_err("invalid agent id should fail");
1772
1773        assert!(error.to_string().contains("invalid agent id"));
1774    }
1775
1776    #[test]
1777    fn init_copies_symlinks_from_agent_source_directory() {
1778        let temp_dir = TempDir::new().expect("temp dir");
1779        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1780        let source_dir = paths.agents_dir.join("claude");
1781        fs::create_dir_all(&source_dir).expect("create source dir");
1782        fs::write(source_dir.join("settings.json"), "{}").expect("write source file");
1783        create_test_symlink(
1784            std::path::Path::new("settings.json"),
1785            &source_dir.join("settings.link"),
1786        );
1787
1788        init_playground_at(paths, "demo", &["claude".to_string()]).expect("init should succeed");
1789
1790        let destination = temp_dir
1791            .path()
1792            .join("playgrounds")
1793            .join("demo")
1794            .join(".claude")
1795            .join("settings.link");
1796        let metadata = fs::symlink_metadata(&destination).expect("symlink metadata");
1797        assert!(metadata.file_type().is_symlink());
1798    }
1799
1800    #[test]
1801    fn errors_when_root_config_toml_is_invalid() {
1802        let temp_dir = TempDir::new().expect("temp dir");
1803        fs::write(
1804            temp_dir.path().join("config.toml"),
1805            "[playground]\ndefault_agent = ",
1806        )
1807        .expect("write invalid root config");
1808
1809        let error =
1810            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1811                .expect_err("invalid root config should fail");
1812
1813        assert!(error.to_string().contains("failed to parse TOML"));
1814    }
1815
1816    #[test]
1817    fn errors_when_playground_config_toml_is_invalid() {
1818        let temp_dir = TempDir::new().expect("temp dir");
1819        fs::write(
1820            temp_dir.path().join("config.toml"),
1821            r#"[agent.claude]
1822cmd = "claude"
1823"#,
1824        )
1825        .expect("write root config");
1826        let playground_dir = temp_dir.path().join("playgrounds").join("broken");
1827        fs::create_dir_all(&playground_dir).expect("create playground dir");
1828        fs::write(playground_dir.join("apg.toml"), "description = ")
1829            .expect("write invalid playground config");
1830
1831        let error =
1832            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1833                .expect_err("invalid playground config should fail");
1834
1835        assert!(error.to_string().contains("failed to parse TOML"));
1836    }
1837
1838    #[test]
1839    fn errors_when_create_mode_is_invalid() {
1840        let temp_dir = TempDir::new().expect("temp dir");
1841        fs::write(
1842            temp_dir.path().join("config.toml"),
1843            r#"[playground]
1844create_mode = "clone"
1845"#,
1846        )
1847        .expect("write invalid root config");
1848
1849        let error =
1850            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1851                .expect_err("invalid create_mode should fail");
1852
1853        let message = format!("{error:#}");
1854        assert!(message.contains("create_mode"));
1855        assert!(message.contains("clone"));
1856    }
1857
1858    #[test]
1859    fn errors_when_playground_directory_uses_reserved_id() {
1860        let temp_dir = TempDir::new().expect("temp dir");
1861        fs::write(
1862            temp_dir.path().join("config.toml"),
1863            r#"[agent.claude]
1864cmd = "claude"
1865"#,
1866        )
1867        .expect("write root config");
1868        let playground_dir = temp_dir.path().join("playgrounds").join("default");
1869        fs::create_dir_all(&playground_dir).expect("create playground dir");
1870        fs::write(playground_dir.join("apg.toml"), "description = 'reserved'")
1871            .expect("write playground config");
1872
1873        let error =
1874            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1875                .expect_err("reserved playground id should fail");
1876        let message = format!("{error:#}");
1877
1878        assert!(message.contains("invalid playground directory under"));
1879        assert!(message.contains("invalid playground id 'default'"));
1880    }
1881
1882    #[test]
1883    fn ignores_non_directory_entries_in_playgrounds_dir() {
1884        let temp_dir = TempDir::new().expect("temp dir");
1885        fs::write(
1886            temp_dir.path().join("config.toml"),
1887            r#"[agent.claude]
1888cmd = "claude"
1889"#,
1890        )
1891        .expect("write root config");
1892        let playgrounds_dir = temp_dir.path().join("playgrounds");
1893        fs::create_dir_all(&playgrounds_dir).expect("create playgrounds dir");
1894        fs::write(playgrounds_dir.join("README.md"), "ignore me").expect("write file entry");
1895
1896        let config =
1897            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1898                .expect("config should load");
1899
1900        assert!(config.playgrounds.is_empty());
1901    }
1902
1903    #[test]
1904    fn configured_playgrounds_only_returns_valid_initialized_directories() {
1905        let temp_dir = TempDir::new().expect("temp dir");
1906        let playgrounds_dir = temp_dir.path().join("playgrounds");
1907        fs::create_dir_all(&playgrounds_dir).expect("create playgrounds dir");
1908
1909        let demo_dir = playgrounds_dir.join("demo");
1910        fs::create_dir_all(&demo_dir).expect("create demo");
1911        fs::write(demo_dir.join("apg.toml"), "description = 'Demo'").expect("write demo config");
1912
1913        let ops_dir = playgrounds_dir.join("ops");
1914        fs::create_dir_all(&ops_dir).expect("create ops");
1915        fs::write(ops_dir.join("apg.toml"), "description = 'Ops'").expect("write ops config");
1916
1917        fs::create_dir_all(playgrounds_dir.join("broken")).expect("create broken");
1918        fs::create_dir_all(playgrounds_dir.join("default")).expect("create reserved");
1919        fs::create_dir_all(playgrounds_dir.join("invalid")).expect("create invalid");
1920        fs::write(
1921            playgrounds_dir.join("invalid").join("apg.toml"),
1922            "description = ",
1923        )
1924        .expect("write invalid config");
1925        fs::write(playgrounds_dir.join("README.md"), "ignore me").expect("write file");
1926
1927        assert_eq!(
1928            configured_playgrounds_at(&playgrounds_dir).expect("list playgrounds"),
1929            vec![
1930                ConfiguredPlayground {
1931                    id: "demo".to_string(),
1932                    description: "Demo".to_string(),
1933                },
1934                ConfiguredPlayground {
1935                    id: "ops".to_string(),
1936                    description: "Ops".to_string(),
1937                }
1938            ]
1939        );
1940    }
1941
1942    #[test]
1943    fn user_config_dir_uses_dot_config_on_all_platforms() {
1944        let base_dir = user_config_base_dir().expect("user config base dir");
1945        let paths = ConfigPaths::from_user_config_dir().expect("user config paths");
1946
1947        assert!(base_dir.ends_with(".config"));
1948        assert_eq!(paths.root_dir, base_dir.join(APP_CONFIG_DIR));
1949    }
1950
1951    #[test]
1952    fn root_config_schema_matches_file_shape() {
1953        let schema = serde_json::to_value(RootConfigFile::json_schema()).expect("schema json");
1954
1955        assert_eq!(schema["type"], Value::String("object".to_string()));
1956        assert!(schema["properties"]["agent"].is_object());
1957        assert_eq!(
1958            schema["properties"]["agent"]["additionalProperties"]["$ref"],
1959            Value::String("#/$defs/AgentConfigFile".to_string())
1960        );
1961        assert!(schema["$defs"]["AgentConfigFile"]["properties"]["cmd"].is_object());
1962        assert!(schema["$defs"]["AgentConfigFile"]["properties"]["config_dir"].is_object());
1963        assert!(schema["properties"]["default_playground"].is_object());
1964        assert!(schema["properties"]["saved_playgrounds_dir"].is_object());
1965        assert!(schema["properties"]["playground"].is_object());
1966    }
1967
1968    #[test]
1969    fn playground_config_schema_matches_file_shape() {
1970        let schema =
1971            serde_json::to_value(PlaygroundConfigFile::json_schema()).expect("schema json");
1972
1973        assert_eq!(schema["type"], Value::String("object".to_string()));
1974        assert!(schema["properties"]["description"].is_object());
1975        assert!(schema["properties"]["default_agent"].is_object());
1976        assert!(schema["properties"]["load_env"].is_object());
1977        assert!(schema["properties"]["create_mode"].is_object());
1978        assert_eq!(
1979            schema["required"],
1980            Value::Array(vec![Value::String("description".to_string())])
1981        );
1982    }
1983}