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