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, Serialize, Deserialize, JsonSchema, Default)]
178/// Shared playground-scoped config fields used by root defaults and per-playground overrides.
179pub struct PlaygroundConfig {
180    /// Optional default agent id override.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub default_agent: Option<String>,
183    /// Optional flag controlling `.env` loading in playground runs.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub load_env: Option<bool>,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
189/// Serializable model for the root `config.toml` file.
190pub struct RootConfigFile {
191    /// Agent id to command mapping under `[agent]`.
192    #[serde(default)]
193    pub agent: BTreeMap<String, String>,
194    /// Optional directory for persisted playground snapshots.
195    ///
196    /// Relative paths are resolved against [`ConfigPaths::root_dir`].
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub saved_playgrounds_dir: Option<PathBuf>,
199    /// Optional defaults inherited by all playgrounds.
200    #[serde(default, skip_serializing_if = "PlaygroundConfig::is_empty")]
201    pub playground: PlaygroundConfig,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
205struct ResolvedRootConfig {
206    agents: BTreeMap<String, String>,
207    saved_playgrounds_dir: PathBuf,
208    playground_defaults: PlaygroundConfig,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub(crate) struct ResolvedPlaygroundConfig {
213    pub(crate) default_agent: String,
214    pub(crate) load_env: bool,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
218/// Serializable model for a playground's `apg.toml` file.
219pub struct PlaygroundConfigFile {
220    /// Human-readable description shown in listing output.
221    pub description: String,
222    /// Optional playground-local runtime overrides.
223    #[serde(flatten)]
224    pub playground: PlaygroundConfig,
225}
226
227impl PlaygroundConfig {
228    fn builtin_defaults() -> Self {
229        Self {
230            default_agent: Some("claude".to_string()),
231            load_env: Some(false),
232        }
233    }
234
235    fn is_empty(&self) -> bool {
236        self.default_agent.is_none() && self.load_env.is_none()
237    }
238
239    fn merged_over(&self, base: &Self) -> Self {
240        Self {
241            default_agent: self
242                .default_agent
243                .clone()
244                .or_else(|| base.default_agent.clone()),
245            load_env: self.load_env.or(base.load_env),
246        }
247    }
248
249    fn resolve_over(&self, base: &Self) -> Result<ResolvedPlaygroundConfig> {
250        let merged = self.merged_over(base);
251
252        Ok(ResolvedPlaygroundConfig {
253            default_agent: merged
254                .default_agent
255                .context("default playground config is missing default_agent")?,
256            load_env: merged.load_env.unwrap_or(false),
257        })
258    }
259}
260
261impl RootConfigFile {
262    /// Returns a JSON Schema for the root config file format.
263    pub fn json_schema() -> Schema {
264        schema_for!(Self)
265    }
266
267    fn defaults_for_paths(paths: &ConfigPaths) -> Self {
268        let mut agent = BTreeMap::new();
269        agent.insert("claude".to_string(), "claude".to_string());
270        agent.insert("opencode".to_string(), "opencode".to_string());
271
272        Self {
273            agent,
274            saved_playgrounds_dir: Some(default_saved_playgrounds_dir(paths)),
275            playground: PlaygroundConfig::builtin_defaults(),
276        }
277    }
278
279    fn resolve(self, paths: &ConfigPaths) -> Result<ResolvedRootConfig> {
280        let defaults = Self::defaults_for_paths(paths);
281        let mut agents = defaults.agent;
282        agents.extend(self.agent);
283
284        let saved_playgrounds_dir = self
285            .saved_playgrounds_dir
286            .or(defaults.saved_playgrounds_dir)
287            .context("default root config is missing saved_playgrounds_dir")?;
288        let playground_defaults = self.playground.merged_over(&defaults.playground);
289
290        Ok(ResolvedRootConfig {
291            agents,
292            saved_playgrounds_dir,
293            playground_defaults,
294        })
295    }
296}
297
298impl PlaygroundConfigFile {
299    /// Returns a JSON Schema for the playground config file format.
300    pub fn json_schema() -> Schema {
301        schema_for!(Self)
302    }
303
304    fn for_playground(playground_id: &str) -> Self {
305        Self {
306            description: format!("TODO: describe {playground_id}"),
307            playground: PlaygroundConfig::default(),
308        }
309    }
310}
311
312/// Initializes a new playground directory and config file.
313///
314/// The playground is created under `playgrounds/<playground_id>`.
315/// When `agent_ids` are provided, matching embedded templates are copied
316/// to `.<agent_id>/` directories in the playground root.
317pub fn init_playground(playground_id: &str, agent_ids: &[String]) -> Result<InitResult> {
318    init_playground_at(
319        ConfigPaths::from_user_config_dir()?,
320        playground_id,
321        agent_ids,
322    )
323}
324
325fn init_playground_at(
326    paths: ConfigPaths,
327    playground_id: &str,
328    agent_ids: &[String],
329) -> Result<InitResult> {
330    init_playground_at_with_git(
331        paths,
332        playground_id,
333        agent_ids,
334        git_is_available,
335        init_git_repo,
336    )
337}
338
339fn init_playground_at_with_git<GA, GI>(
340    paths: ConfigPaths,
341    playground_id: &str,
342    agent_ids: &[String],
343    git_is_available: GA,
344    init_git_repo: GI,
345) -> Result<InitResult>
346where
347    GA: Fn() -> Result<bool>,
348    GI: Fn(&Path) -> Result<()>,
349{
350    validate_playground_id(playground_id)?;
351    let root_config_created = ensure_root_initialized(&paths)?;
352    let selected_agent_templates = select_agent_templates(agent_ids)?;
353
354    let playground_dir = paths.playgrounds_dir.join(playground_id);
355    let playground_config_file = playground_dir.join(PLAYGROUND_CONFIG_FILE_NAME);
356
357    if playground_config_file.exists() {
358        bail!(
359            "playground '{}' already exists at {}",
360            playground_id,
361            playground_config_file.display()
362        );
363    }
364
365    fs::create_dir_all(&playground_dir)
366        .with_context(|| format!("failed to create {}", playground_dir.display()))?;
367    write_toml_file(
368        &playground_config_file,
369        &PlaygroundConfigFile::for_playground(playground_id),
370    )?;
371    copy_agent_templates(&playground_dir, &selected_agent_templates)?;
372    if git_is_available()?
373        && let Err(error) = init_git_repo(&playground_dir)
374    {
375        match fs::remove_dir_all(&playground_dir) {
376            Ok(()) => {
377                return Err(error).context(format!(
378                    "failed to initialize git repository in {}; removed partially initialized playground",
379                    playground_dir.display()
380                ));
381            }
382            Err(cleanup_error) => {
383                return Err(error).context(format!(
384                    "failed to initialize git repository in {}; additionally failed to remove partially initialized playground {}: {cleanup_error}",
385                    playground_dir.display(),
386                    playground_dir.display()
387                ));
388            }
389        }
390    }
391
392    Ok(InitResult {
393        paths,
394        playground_id: playground_id.to_string(),
395        root_config_created,
396        playground_config_created: true,
397        initialized_agent_templates: selected_agent_templates
398            .iter()
399            .map(|(agent_id, _)| agent_id.clone())
400            .collect(),
401    })
402}
403
404/// Resolves an existing playground directory under the global config root.
405pub fn resolve_playground_dir(playground_id: &str) -> Result<PathBuf> {
406    resolve_playground_dir_at(ConfigPaths::from_user_config_dir()?, playground_id)
407}
408
409/// Removes a playground directory from the global config root.
410pub fn remove_playground(playground_id: &str) -> Result<RemoveResult> {
411    let paths = ConfigPaths::from_user_config_dir()?;
412    remove_playground_at(paths, playground_id)
413}
414
415fn remove_playground_at(paths: ConfigPaths, playground_id: &str) -> Result<RemoveResult> {
416    let playground_dir = resolve_playground_dir_at(paths.clone(), playground_id)?;
417
418    fs::remove_dir_all(&playground_dir)
419        .with_context(|| format!("failed to remove {}", playground_dir.display()))?;
420
421    Ok(RemoveResult {
422        paths,
423        playground_id: playground_id.to_string(),
424        playground_dir,
425    })
426}
427
428fn resolve_playground_dir_at(paths: ConfigPaths, playground_id: &str) -> Result<PathBuf> {
429    validate_playground_id(playground_id)?;
430
431    let playground_dir = paths.playgrounds_dir.join(playground_id);
432    if !playground_dir.exists() {
433        bail!("unknown playground '{playground_id}'");
434    }
435
436    let metadata = fs::symlink_metadata(&playground_dir)
437        .with_context(|| format!("failed to inspect {}", playground_dir.display()))?;
438    if metadata.file_type().is_symlink() {
439        bail!(
440            "playground '{}' cannot be removed because it is a symlink: {}",
441            playground_id,
442            playground_dir.display()
443        );
444    }
445    if !metadata.is_dir() {
446        bail!(
447            "playground '{}' is not a directory: {}",
448            playground_id,
449            playground_dir.display()
450        );
451    }
452
453    Ok(playground_dir)
454}
455
456fn validate_playground_id(playground_id: &str) -> Result<()> {
457    if playground_id.is_empty() {
458        bail!("playground id cannot be empty");
459    }
460    if playground_id == DEFAULT_SUBCOMMAND_PLAYGROUND_ID {
461        bail!(
462            "invalid playground id '{playground_id}': this name is reserved for the `default` subcommand"
463        );
464    }
465    if playground_id.starts_with("__") {
466        bail!(
467            "invalid playground id '{playground_id}': ids starting with '__' are reserved for internal use"
468        );
469    }
470    if matches!(playground_id, "." | "..")
471        || playground_id.contains('/')
472        || playground_id.contains('\\')
473    {
474        bail!(
475            "invalid playground id '{}': ids must not contain path separators or parent-directory segments",
476            playground_id
477        );
478    }
479
480    Ok(())
481}
482
483fn git_is_available() -> Result<bool> {
484    match Command::new("git")
485        .arg("--version")
486        .stdout(Stdio::null())
487        .stderr(Stdio::null())
488        .status()
489    {
490        Ok(status) => Ok(status.success()),
491        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
492        Err(error) => Err(error).context("failed to check whether git is available"),
493    }
494}
495
496fn init_git_repo(playground_dir: &Path) -> Result<()> {
497    let status = Command::new("git")
498        .arg("init")
499        .current_dir(playground_dir)
500        .stdout(Stdio::null())
501        .stderr(Stdio::null())
502        .status()
503        .with_context(|| {
504            format!(
505                "failed to initialize git repository in {}",
506                playground_dir.display()
507            )
508        })?;
509
510    if !status.success() {
511        bail!(
512            "git init exited with status {status} in {}",
513            playground_dir.display()
514        );
515    }
516
517    Ok(())
518}
519
520fn select_agent_templates(agent_ids: &[String]) -> Result<Vec<(String, &'static Dir<'static>)>> {
521    let available_templates = available_agent_templates();
522    let available_agent_ids = available_templates.keys().cloned().collect::<Vec<_>>();
523    let mut selected_templates = Vec::new();
524
525    for agent_id in agent_ids {
526        if selected_templates
527            .iter()
528            .any(|(selected_agent_id, _)| selected_agent_id == agent_id)
529        {
530            continue;
531        }
532
533        let template_dir = available_templates.get(agent_id).with_context(|| {
534            format!(
535                "unknown agent template '{agent_id}'. Available templates: {}",
536                if available_agent_ids.is_empty() {
537                    "(none)".to_string()
538                } else {
539                    available_agent_ids.join(", ")
540                }
541            )
542        })?;
543        selected_templates.push((agent_id.clone(), *template_dir));
544    }
545
546    Ok(selected_templates)
547}
548
549fn available_agent_templates() -> BTreeMap<String, &'static Dir<'static>> {
550    let mut agent_templates = BTreeMap::new();
551
552    for template_dir in TEMPLATE_DIR.dirs() {
553        let Some(dir_name) = template_dir
554            .path()
555            .file_name()
556            .and_then(|name| name.to_str())
557        else {
558            continue;
559        };
560        let Some(agent_id) = dir_name.strip_prefix('.') else {
561            continue;
562        };
563
564        if agent_id.is_empty() {
565            continue;
566        }
567
568        agent_templates.insert(agent_id.to_string(), template_dir);
569    }
570
571    agent_templates
572}
573
574fn copy_agent_templates(
575    playground_dir: &Path,
576    agent_templates: &[(String, &'static Dir<'static>)],
577) -> Result<()> {
578    for (agent_id, template_dir) in agent_templates {
579        copy_embedded_dir(template_dir, &playground_dir.join(format!(".{agent_id}")))?;
580    }
581
582    Ok(())
583}
584
585fn copy_embedded_dir(template_dir: &'static Dir<'static>, destination: &Path) -> Result<()> {
586    fs::create_dir_all(destination)
587        .with_context(|| format!("failed to create {}", destination.display()))?;
588
589    for nested_dir in template_dir.dirs() {
590        let nested_dir_name = nested_dir.path().file_name().with_context(|| {
591            format!(
592                "embedded template path has no name: {}",
593                nested_dir.path().display()
594            )
595        })?;
596        copy_embedded_dir(nested_dir, &destination.join(nested_dir_name))?;
597    }
598
599    for file in template_dir.files() {
600        let file_name = file.path().file_name().with_context(|| {
601            format!(
602                "embedded template file has no name: {}",
603                file.path().display()
604            )
605        })?;
606        let destination_file = destination.join(file_name);
607        fs::write(&destination_file, file.contents())
608            .with_context(|| format!("failed to write {}", destination_file.display()))?;
609    }
610
611    Ok(())
612}
613
614fn ensure_root_initialized(paths: &ConfigPaths) -> Result<bool> {
615    fs::create_dir_all(&paths.root_dir)
616        .with_context(|| format!("failed to create {}", paths.root_dir.display()))?;
617    fs::create_dir_all(&paths.playgrounds_dir)
618        .with_context(|| format!("failed to create {}", paths.playgrounds_dir.display()))?;
619
620    if paths.config_file.exists() {
621        return Ok(false);
622    }
623
624    write_toml_file(
625        &paths.config_file,
626        &RootConfigFile::defaults_for_paths(paths),
627    )?;
628
629    Ok(true)
630}
631
632fn load_root_config(paths: &ConfigPaths) -> Result<ResolvedRootConfig> {
633    read_toml_file::<RootConfigFile>(&paths.config_file)?.resolve(paths)
634}
635
636fn default_saved_playgrounds_dir(_paths: &ConfigPaths) -> PathBuf {
637    PathBuf::from(DEFAULT_SAVED_PLAYGROUNDS_DIR_NAME)
638}
639
640fn resolve_saved_playgrounds_dir(root_dir: &Path, configured_path: PathBuf) -> PathBuf {
641    if configured_path.is_absolute() {
642        return configured_path;
643    }
644
645    root_dir.join(configured_path)
646}
647
648fn validate_default_agent_defined(
649    agents: &BTreeMap<String, String>,
650    default_agent: Option<&str>,
651    label: &str,
652) -> Result<()> {
653    let Some(default_agent) = default_agent else {
654        bail!("{label} is missing");
655    };
656
657    if !agents.contains_key(default_agent) {
658        bail!("{label} '{default_agent}' is not defined in [agent]");
659    }
660
661    Ok(())
662}
663
664fn load_playgrounds(
665    playgrounds_dir: &Path,
666    agents: &BTreeMap<String, String>,
667    playground_defaults: &PlaygroundConfig,
668) -> Result<BTreeMap<String, PlaygroundDefinition>> {
669    if !playgrounds_dir.exists() {
670        return Ok(BTreeMap::new());
671    }
672
673    if !playgrounds_dir.is_dir() {
674        bail!(
675            "playground config path is not a directory: {}",
676            playgrounds_dir.display()
677        );
678    }
679
680    let mut playgrounds = BTreeMap::new();
681
682    for entry in fs::read_dir(playgrounds_dir)
683        .with_context(|| format!("failed to read {}", playgrounds_dir.display()))?
684    {
685        let entry = entry.with_context(|| {
686            format!(
687                "failed to inspect an entry under {}",
688                playgrounds_dir.display()
689            )
690        })?;
691        let file_type = entry.file_type().with_context(|| {
692            format!("failed to inspect file type for {}", entry.path().display())
693        })?;
694
695        if !file_type.is_dir() {
696            continue;
697        }
698
699        let directory = entry.path();
700        let config_file = directory.join(PLAYGROUND_CONFIG_FILE_NAME);
701
702        if !config_file.is_file() {
703            bail!(
704                "playground '{}' is missing {}",
705                directory.file_name().unwrap_or_default().to_string_lossy(),
706                PLAYGROUND_CONFIG_FILE_NAME
707            );
708        }
709
710        let playground_config: PlaygroundConfigFile = read_toml_file(&config_file)?;
711        let id = entry.file_name().to_string_lossy().into_owned();
712        validate_playground_id(&id).with_context(|| {
713            format!(
714                "invalid playground directory under {}",
715                playgrounds_dir.display()
716            )
717        })?;
718        let effective_config = playground_config
719            .playground
720            .merged_over(playground_defaults);
721        validate_default_agent_defined(
722            agents,
723            effective_config.default_agent.as_deref(),
724            &format!("playground '{id}' default agent"),
725        )?;
726
727        playgrounds.insert(
728            id.clone(),
729            PlaygroundDefinition {
730                id,
731                description: playground_config.description,
732                directory,
733                config_file,
734                playground: playground_config.playground,
735            },
736        );
737    }
738
739    Ok(playgrounds)
740}
741
742fn read_toml_file<T>(path: &Path) -> Result<T>
743where
744    T: for<'de> Deserialize<'de>,
745{
746    let content =
747        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
748
749    toml::from_str(&content)
750        .with_context(|| format!("failed to parse TOML from {}", path.display()))
751}
752
753fn write_toml_file<T>(path: &Path, value: &T) -> Result<()>
754where
755    T: Serialize,
756{
757    let content =
758        toml::to_string_pretty(value).context("failed to serialize configuration to TOML")?;
759    fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
760}
761
762#[cfg(test)]
763mod tests {
764    use super::{
765        APP_CONFIG_DIR, AppConfig, ConfigPaths, PlaygroundConfigFile, RootConfigFile,
766        init_playground_at, init_playground_at_with_git, read_toml_file, remove_playground_at,
767        resolve_playground_dir_at, user_config_base_dir,
768    };
769    use serde_json::Value;
770    use std::{cell::Cell, fs, io};
771    use tempfile::TempDir;
772
773    #[test]
774    fn init_creates_root_and_playground_configs_from_file_models() {
775        let temp_dir = TempDir::new().expect("temp dir");
776        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
777
778        let result = init_playground_at(paths.clone(), "demo", &[]).expect("init should succeed");
779
780        assert!(result.root_config_created);
781        assert!(result.playground_config_created);
782        assert!(result.initialized_agent_templates.is_empty());
783        assert!(temp_dir.path().join("config.toml").is_file());
784        assert!(
785            temp_dir
786                .path()
787                .join("playgrounds")
788                .join("demo")
789                .join("apg.toml")
790                .is_file()
791        );
792        assert!(
793            !temp_dir
794                .path()
795                .join("playgrounds")
796                .join("demo")
797                .join(".claude")
798                .exists()
799        );
800        assert_eq!(
801            read_toml_file::<RootConfigFile>(&temp_dir.path().join("config.toml"))
802                .expect("root config"),
803            RootConfigFile::defaults_for_paths(&paths)
804        );
805        assert_eq!(
806            read_toml_file::<PlaygroundConfigFile>(
807                &temp_dir
808                    .path()
809                    .join("playgrounds")
810                    .join("demo")
811                    .join("apg.toml")
812            )
813            .expect("playground config"),
814            PlaygroundConfigFile::for_playground("demo")
815        );
816
817        let config = AppConfig::load_from_paths(paths).expect("config should load");
818        assert_eq!(config.agents.get("claude"), Some(&"claude".to_string()));
819        assert_eq!(config.agents.get("opencode"), Some(&"opencode".to_string()));
820        assert_eq!(
821            config.playground_defaults.default_agent.as_deref(),
822            Some("claude")
823        );
824        assert_eq!(config.playground_defaults.load_env, Some(false));
825        assert_eq!(
826            config.saved_playgrounds_dir,
827            temp_dir.path().join("saved-playgrounds")
828        );
829        assert_eq!(
830            config
831                .playgrounds
832                .get("demo")
833                .expect("demo playground")
834                .description,
835            "TODO: describe demo"
836        );
837        assert!(
838            config
839                .playgrounds
840                .get("demo")
841                .expect("demo playground")
842                .playground
843                .is_empty()
844        );
845    }
846
847    #[test]
848    fn merges_root_agents_and_loads_playgrounds() {
849        let temp_dir = TempDir::new().expect("temp dir");
850        let root = temp_dir.path();
851        fs::write(
852            root.join("config.toml"),
853            r#"saved_playgrounds_dir = "archives"
854
855[agent]
856claude = "custom-claude"
857codex = "codex --fast"
858
859[playground]
860default_agent = "codex"
861load_env = true
862"#,
863        )
864        .expect("write root config");
865
866        let playground_dir = root.join("playgrounds").join("demo");
867        fs::create_dir_all(&playground_dir).expect("create playground dir");
868        fs::write(
869            playground_dir.join("apg.toml"),
870            r#"description = "Demo playground"
871default_agent = "claude""#,
872        )
873        .expect("write playground config");
874
875        let config = AppConfig::load_from_paths(ConfigPaths::from_root_dir(root.to_path_buf()))
876            .expect("config should load");
877
878        assert_eq!(
879            config.agents.get("claude"),
880            Some(&"custom-claude".to_string())
881        );
882        assert_eq!(config.agents.get("opencode"), Some(&"opencode".to_string()));
883        assert_eq!(
884            config.agents.get("codex"),
885            Some(&"codex --fast".to_string())
886        );
887        assert_eq!(
888            config.playground_defaults.default_agent.as_deref(),
889            Some("codex")
890        );
891        assert_eq!(config.playground_defaults.load_env, Some(true));
892        assert_eq!(config.saved_playgrounds_dir, root.join("archives"));
893
894        let playground = config.playgrounds.get("demo").expect("demo playground");
895        assert_eq!(playground.description, "Demo playground");
896        assert_eq!(
897            playground.playground.default_agent.as_deref(),
898            Some("claude")
899        );
900        assert_eq!(playground.directory, playground_dir);
901        let effective_config = config
902            .resolve_playground_config(playground)
903            .expect("effective playground config");
904        assert_eq!(effective_config.default_agent, "claude");
905        assert!(effective_config.load_env);
906    }
907
908    #[test]
909    fn errors_when_playground_default_agent_is_not_defined() {
910        let temp_dir = TempDir::new().expect("temp dir");
911        fs::write(
912            temp_dir.path().join("config.toml"),
913            r#"[agent]
914claude = "claude"
915"#,
916        )
917        .expect("write root config");
918        let playground_dir = temp_dir.path().join("playgrounds").join("demo");
919        fs::create_dir_all(&playground_dir).expect("create playground dir");
920        fs::write(
921            playground_dir.join("apg.toml"),
922            r#"description = "Demo playground"
923default_agent = "codex""#,
924        )
925        .expect("write playground config");
926
927        let error =
928            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
929                .expect_err("undefined playground default agent should fail");
930
931        assert!(
932            error
933                .to_string()
934                .contains("playground 'demo' default agent 'codex' is not defined")
935        );
936    }
937
938    #[test]
939    fn load_auto_initializes_missing_root_config() {
940        let temp_dir = TempDir::new().expect("temp dir");
941        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
942
943        let config = AppConfig::load_from_paths(paths).expect("missing root config should init");
944
945        assert!(temp_dir.path().join("config.toml").is_file());
946        assert!(temp_dir.path().join("playgrounds").is_dir());
947        assert_eq!(config.agents.get("claude"), Some(&"claude".to_string()));
948        assert_eq!(
949            config.playground_defaults.default_agent.as_deref(),
950            Some("claude")
951        );
952        assert_eq!(config.playground_defaults.load_env, Some(false));
953        assert_eq!(
954            config.saved_playgrounds_dir,
955            temp_dir.path().join("saved-playgrounds")
956        );
957    }
958
959    #[test]
960    fn respects_absolute_saved_playgrounds_dir() {
961        let temp_dir = TempDir::new().expect("temp dir");
962        let archive_dir = TempDir::new().expect("archive dir");
963        let archive_path = archive_dir
964            .path()
965            .display()
966            .to_string()
967            .replace('\\', "\\\\");
968        fs::write(
969            temp_dir.path().join("config.toml"),
970            format!(
971                r#"saved_playgrounds_dir = "{}"
972
973[agent]
974claude = "claude"
975"#,
976                archive_path
977            ),
978        )
979        .expect("write root config");
980
981        let config =
982            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
983                .expect("config should load");
984
985        assert_eq!(config.saved_playgrounds_dir, archive_dir.path());
986    }
987
988    #[test]
989    fn errors_when_playground_config_is_missing() {
990        let temp_dir = TempDir::new().expect("temp dir");
991        fs::write(
992            temp_dir.path().join("config.toml"),
993            r#"[agent]
994claude = "claude"
995opencode = "opencode"
996"#,
997        )
998        .expect("write root config");
999        let playground_dir = temp_dir.path().join("playgrounds").join("broken");
1000        fs::create_dir_all(&playground_dir).expect("create playground dir");
1001
1002        let error =
1003            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1004                .expect_err("missing playground config should fail");
1005
1006        assert!(error.to_string().contains("missing apg.toml"));
1007    }
1008
1009    #[test]
1010    fn errors_when_default_agent_is_not_defined() {
1011        let temp_dir = TempDir::new().expect("temp dir");
1012        fs::write(
1013            temp_dir.path().join("config.toml"),
1014            r#"[playground]
1015default_agent = "codex""#,
1016        )
1017        .expect("write root config");
1018
1019        let error =
1020            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1021                .expect_err("undefined default agent should fail");
1022
1023        assert!(
1024            error
1025                .to_string()
1026                .contains("default agent 'codex' is not defined")
1027        );
1028    }
1029
1030    #[test]
1031    fn init_errors_when_playground_already_exists() {
1032        let temp_dir = TempDir::new().expect("temp dir");
1033        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1034
1035        init_playground_at(paths.clone(), "demo", &[]).expect("initial init should succeed");
1036        let error = init_playground_at(paths, "demo", &[]).expect_err("duplicate init should fail");
1037
1038        assert!(
1039            error
1040                .to_string()
1041                .contains("playground 'demo' already exists")
1042        );
1043    }
1044
1045    #[test]
1046    fn init_rejects_reserved_default_playground_id() {
1047        let temp_dir = TempDir::new().expect("temp dir");
1048        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1049
1050        let error = init_playground_at(paths, "default", &[]).expect_err("reserved id should fail");
1051
1052        assert!(
1053            error
1054                .to_string()
1055                .contains("invalid playground id 'default'")
1056        );
1057        assert!(
1058            error
1059                .to_string()
1060                .contains("reserved for the `default` subcommand")
1061        );
1062    }
1063
1064    #[test]
1065    fn init_rejects_internal_reserved_playground_id_prefix() {
1066        let temp_dir = TempDir::new().expect("temp dir");
1067        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1068
1069        let error =
1070            init_playground_at(paths, "__default__", &[]).expect_err("reserved id should fail");
1071
1072        assert!(
1073            error
1074                .to_string()
1075                .contains("ids starting with '__' are reserved for internal use")
1076        );
1077    }
1078
1079    #[test]
1080    fn remove_deletes_existing_playground_directory() {
1081        let temp_dir = TempDir::new().expect("temp dir");
1082        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1083        let nested_file = temp_dir
1084            .path()
1085            .join("playgrounds")
1086            .join("demo")
1087            .join("notes.txt");
1088
1089        init_playground_at(paths.clone(), "demo", &[]).expect("init should succeed");
1090        fs::write(&nested_file, "hello").expect("write nested file");
1091
1092        let result = remove_playground_at(paths.clone(), "demo").expect("remove should succeed");
1093
1094        assert_eq!(result.paths, paths);
1095        assert_eq!(result.playground_id, "demo");
1096        assert_eq!(
1097            result.playground_dir,
1098            temp_dir.path().join("playgrounds").join("demo")
1099        );
1100        assert!(!result.playground_dir.exists());
1101    }
1102
1103    #[test]
1104    fn remove_errors_for_unknown_playground() {
1105        let temp_dir = TempDir::new().expect("temp dir");
1106        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1107
1108        let error =
1109            remove_playground_at(paths, "missing").expect_err("missing playground should fail");
1110
1111        assert!(error.to_string().contains("unknown playground 'missing'"));
1112    }
1113
1114    #[test]
1115    fn resolve_playground_dir_rejects_path_traversal_ids() {
1116        let temp_dir = TempDir::new().expect("temp dir");
1117        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1118
1119        let error = resolve_playground_dir_at(paths, "../demo")
1120            .expect_err("path traversal playground id should fail");
1121
1122        assert!(
1123            error
1124                .to_string()
1125                .contains("invalid playground id '../demo'")
1126        );
1127    }
1128
1129    #[test]
1130    fn init_rejects_path_traversal_ids_before_writing_files() {
1131        let temp_dir = TempDir::new().expect("temp dir");
1132        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1133
1134        let error = init_playground_at(paths, "../demo", &[])
1135            .expect_err("path traversal playground id should fail");
1136
1137        assert!(
1138            error
1139                .to_string()
1140                .contains("invalid playground id '../demo'")
1141        );
1142        assert!(!temp_dir.path().join("config.toml").exists());
1143        assert!(!temp_dir.path().join("playgrounds").exists());
1144        assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1145    }
1146
1147    #[test]
1148    fn init_cleans_up_playground_directory_when_git_init_fails() {
1149        let temp_dir = TempDir::new().expect("temp dir");
1150        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1151
1152        let error = init_playground_at_with_git(
1153            paths,
1154            "demo",
1155            &[],
1156            || Ok(true),
1157            |_| Err(io::Error::other("git init failed").into()),
1158        )
1159        .expect_err("git init failure should fail init");
1160
1161        let error_message = format!("{error:#}");
1162
1163        assert!(error_message.contains("git init failed"));
1164        assert!(error_message.contains("removed partially initialized playground"));
1165        assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1166    }
1167
1168    #[test]
1169    fn init_copies_selected_agent_templates_into_playground() {
1170        let temp_dir = TempDir::new().expect("temp dir");
1171        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1172        let selected_agents = vec!["claude".to_string(), "codex".to_string()];
1173
1174        let result =
1175            init_playground_at(paths, "demo", &selected_agents).expect("init should succeed");
1176        let playground_dir = temp_dir.path().join("playgrounds").join("demo");
1177
1178        assert_eq!(
1179            result.initialized_agent_templates,
1180            vec!["claude".to_string(), "codex".to_string()]
1181        );
1182        assert!(
1183            playground_dir
1184                .join(".claude")
1185                .join("settings.json")
1186                .is_file()
1187        );
1188        assert!(playground_dir.join(".codex").join("config.toml").is_file());
1189        assert!(!playground_dir.join(".opencode").exists());
1190    }
1191
1192    #[test]
1193    fn init_initializes_git_repo_when_git_is_available() {
1194        let temp_dir = TempDir::new().expect("temp dir");
1195        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1196        let git_init_called = Cell::new(false);
1197
1198        init_playground_at_with_git(
1199            paths,
1200            "demo",
1201            &[],
1202            || Ok(true),
1203            |playground_dir| {
1204                git_init_called.set(true);
1205                fs::create_dir(playground_dir.join(".git")).expect("create .git directory");
1206                Ok(())
1207            },
1208        )
1209        .expect("init should succeed");
1210
1211        assert!(git_init_called.get());
1212        assert!(
1213            temp_dir
1214                .path()
1215                .join("playgrounds")
1216                .join("demo")
1217                .join(".git")
1218                .is_dir()
1219        );
1220    }
1221
1222    #[test]
1223    fn init_skips_git_repo_when_git_is_unavailable() {
1224        let temp_dir = TempDir::new().expect("temp dir");
1225        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1226        let git_init_called = Cell::new(false);
1227
1228        init_playground_at_with_git(
1229            paths,
1230            "demo",
1231            &[],
1232            || Ok(false),
1233            |_| {
1234                git_init_called.set(true);
1235                Ok(())
1236            },
1237        )
1238        .expect("init should succeed");
1239
1240        assert!(!git_init_called.get());
1241        assert!(
1242            !temp_dir
1243                .path()
1244                .join("playgrounds")
1245                .join("demo")
1246                .join(".git")
1247                .exists()
1248        );
1249    }
1250
1251    #[test]
1252    fn init_deduplicates_selected_agent_templates() {
1253        let temp_dir = TempDir::new().expect("temp dir");
1254        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1255        let selected_agents = vec![
1256            "claude".to_string(),
1257            "claude".to_string(),
1258            "codex".to_string(),
1259        ];
1260
1261        let result =
1262            init_playground_at(paths, "demo", &selected_agents).expect("init should succeed");
1263
1264        assert_eq!(
1265            result.initialized_agent_templates,
1266            vec!["claude".to_string(), "codex".to_string()]
1267        );
1268    }
1269
1270    #[test]
1271    fn init_errors_for_unknown_agent_template_before_creating_playground() {
1272        let temp_dir = TempDir::new().expect("temp dir");
1273        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1274        let selected_agents = vec!["missing".to_string()];
1275
1276        let error = init_playground_at(paths, "demo", &selected_agents)
1277            .expect_err("unknown agent template should fail");
1278
1279        assert!(
1280            error
1281                .to_string()
1282                .contains("unknown agent template 'missing'")
1283        );
1284        assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1285    }
1286
1287    #[test]
1288    fn errors_when_root_config_toml_is_invalid() {
1289        let temp_dir = TempDir::new().expect("temp dir");
1290        fs::write(
1291            temp_dir.path().join("config.toml"),
1292            "[playground]\ndefault_agent = ",
1293        )
1294        .expect("write invalid root config");
1295
1296        let error =
1297            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1298                .expect_err("invalid root config should fail");
1299
1300        assert!(error.to_string().contains("failed to parse TOML"));
1301    }
1302
1303    #[test]
1304    fn errors_when_playground_config_toml_is_invalid() {
1305        let temp_dir = TempDir::new().expect("temp dir");
1306        fs::write(
1307            temp_dir.path().join("config.toml"),
1308            r#"[agent]
1309claude = "claude"
1310"#,
1311        )
1312        .expect("write root config");
1313        let playground_dir = temp_dir.path().join("playgrounds").join("broken");
1314        fs::create_dir_all(&playground_dir).expect("create playground dir");
1315        fs::write(playground_dir.join("apg.toml"), "description = ")
1316            .expect("write invalid playground config");
1317
1318        let error =
1319            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1320                .expect_err("invalid playground config should fail");
1321
1322        assert!(error.to_string().contains("failed to parse TOML"));
1323    }
1324
1325    #[test]
1326    fn errors_when_playground_directory_uses_reserved_id() {
1327        let temp_dir = TempDir::new().expect("temp dir");
1328        fs::write(
1329            temp_dir.path().join("config.toml"),
1330            r#"[agent]
1331claude = "claude"
1332"#,
1333        )
1334        .expect("write root config");
1335        let playground_dir = temp_dir.path().join("playgrounds").join("default");
1336        fs::create_dir_all(&playground_dir).expect("create playground dir");
1337        fs::write(playground_dir.join("apg.toml"), "description = 'reserved'")
1338            .expect("write playground config");
1339
1340        let error =
1341            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1342                .expect_err("reserved playground id should fail");
1343        let message = format!("{error:#}");
1344
1345        assert!(message.contains("invalid playground directory under"));
1346        assert!(message.contains("invalid playground id 'default'"));
1347    }
1348
1349    #[test]
1350    fn ignores_non_directory_entries_in_playgrounds_dir() {
1351        let temp_dir = TempDir::new().expect("temp dir");
1352        fs::write(
1353            temp_dir.path().join("config.toml"),
1354            r#"[agent]
1355claude = "claude"
1356"#,
1357        )
1358        .expect("write root config");
1359        let playgrounds_dir = temp_dir.path().join("playgrounds");
1360        fs::create_dir_all(&playgrounds_dir).expect("create playgrounds dir");
1361        fs::write(playgrounds_dir.join("README.md"), "ignore me").expect("write file entry");
1362
1363        let config =
1364            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1365                .expect("config should load");
1366
1367        assert!(config.playgrounds.is_empty());
1368    }
1369
1370    #[test]
1371    fn user_config_dir_uses_dot_config_on_all_platforms() {
1372        let base_dir = user_config_base_dir().expect("user config base dir");
1373        let paths = ConfigPaths::from_user_config_dir().expect("user config paths");
1374
1375        assert!(base_dir.ends_with(".config"));
1376        assert_eq!(paths.root_dir, base_dir.join(APP_CONFIG_DIR));
1377    }
1378
1379    #[test]
1380    fn root_config_schema_matches_file_shape() {
1381        let schema = serde_json::to_value(RootConfigFile::json_schema()).expect("schema json");
1382
1383        assert_eq!(schema["type"], Value::String("object".to_string()));
1384        assert!(schema["properties"]["agent"].is_object());
1385        assert!(schema["properties"]["saved_playgrounds_dir"].is_object());
1386        assert!(schema["properties"]["playground"].is_object());
1387    }
1388
1389    #[test]
1390    fn playground_config_schema_matches_file_shape() {
1391        let schema =
1392            serde_json::to_value(PlaygroundConfigFile::json_schema()).expect("schema json");
1393
1394        assert_eq!(schema["type"], Value::String("object".to_string()));
1395        assert!(schema["properties"]["description"].is_object());
1396        assert!(schema["properties"]["default_agent"].is_object());
1397        assert!(schema["properties"]["load_env"].is_object());
1398        assert_eq!(
1399            schema["required"],
1400            Value::Array(vec![Value::String("description".to_string())])
1401        );
1402    }
1403}