Skip to main content

agent_playground/
config.rs

1use std::{
2    collections::BTreeMap,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use anyhow::{Context, Result, bail};
8use include_dir::{Dir, include_dir};
9use schemars::{JsonSchema, Schema, schema_for};
10use serde::{Deserialize, Serialize};
11
12const APP_CONFIG_DIR: &str = "agent-playground";
13const ROOT_CONFIG_FILE_NAME: &str = "config.toml";
14const PLAYGROUND_CONFIG_FILE_NAME: &str = "apg.toml";
15const PLAYGROUNDS_DIR_NAME: &str = "playgrounds";
16static TEMPLATE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates");
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ConfigPaths {
20    pub root_dir: PathBuf,
21    pub config_file: PathBuf,
22    pub playgrounds_dir: PathBuf,
23}
24
25impl ConfigPaths {
26    pub fn from_user_config_dir() -> Result<Self> {
27        let config_dir = user_config_base_dir()?;
28
29        Ok(Self::from_root_dir(config_dir.join(APP_CONFIG_DIR)))
30    }
31
32    pub fn from_root_dir(root_dir: PathBuf) -> Self {
33        Self {
34            config_file: root_dir.join(ROOT_CONFIG_FILE_NAME),
35            playgrounds_dir: root_dir.join(PLAYGROUNDS_DIR_NAME),
36            root_dir,
37        }
38    }
39}
40
41fn user_config_base_dir() -> Result<PathBuf> {
42    let home_dir = dirs::home_dir().context("failed to locate the user's home directory")?;
43    Ok(home_dir.join(".config"))
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct AppConfig {
48    pub paths: ConfigPaths,
49    pub agents: BTreeMap<String, String>,
50    pub default_agent: String,
51    pub load_env: bool,
52    pub saved_playgrounds_dir: PathBuf,
53    pub playgrounds: BTreeMap<String, PlaygroundDefinition>,
54}
55
56impl AppConfig {
57    pub fn load() -> Result<Self> {
58        Self::load_from_paths(ConfigPaths::from_user_config_dir()?)
59    }
60
61    fn load_from_paths(paths: ConfigPaths) -> Result<Self> {
62        ensure_root_initialized(&paths)?;
63        let resolved_root_config = load_root_config(&paths)?;
64        let agents = resolved_root_config.agents;
65        let default_agent = resolved_root_config.default_agent;
66        let load_env = resolved_root_config.load_env;
67        let saved_playgrounds_dir = resolve_saved_playgrounds_dir(
68            &paths.root_dir,
69            resolved_root_config.saved_playgrounds_dir,
70        );
71
72        if !agents.contains_key(&default_agent) {
73            bail!("default agent '{default_agent}' is not defined in [agent]");
74        }
75
76        let playgrounds = load_playgrounds(&paths.playgrounds_dir, &agents)?;
77
78        Ok(Self {
79            paths,
80            agents,
81            default_agent,
82            load_env,
83            saved_playgrounds_dir,
84            playgrounds,
85        })
86    }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct InitResult {
91    pub paths: ConfigPaths,
92    pub playground_id: String,
93    pub root_config_created: bool,
94    pub playground_config_created: bool,
95    pub initialized_agent_templates: Vec<String>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct PlaygroundDefinition {
100    pub id: String,
101    pub description: String,
102    pub default_agent: Option<String>,
103    pub directory: PathBuf,
104    pub config_file: PathBuf,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
108pub struct RootConfigFile {
109    #[serde(default)]
110    pub agent: BTreeMap<String, String>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub default_agent: Option<String>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub load_env: Option<bool>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub saved_playgrounds_dir: Option<PathBuf>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
120pub struct PlaygroundConfigFile {
121    pub description: String,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub default_agent: Option<String>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127struct ResolvedRootConfig {
128    agents: BTreeMap<String, String>,
129    default_agent: String,
130    load_env: bool,
131    saved_playgrounds_dir: PathBuf,
132}
133
134impl RootConfigFile {
135    pub fn json_schema() -> Schema {
136        schema_for!(Self)
137    }
138
139    fn defaults_for_paths(paths: &ConfigPaths) -> Self {
140        let mut agent = BTreeMap::new();
141        agent.insert("claude".to_string(), "claude".to_string());
142        agent.insert("opencode".to_string(), "opencode".to_string());
143
144        Self {
145            agent,
146            default_agent: Some("claude".to_string()),
147            load_env: Some(false),
148            saved_playgrounds_dir: Some(default_saved_playgrounds_dir(paths)),
149        }
150    }
151
152    fn resolve(self, paths: &ConfigPaths) -> Result<ResolvedRootConfig> {
153        let defaults = Self::defaults_for_paths(paths);
154        let mut agents = defaults.agent;
155        agents.extend(self.agent);
156
157        let default_agent = self
158            .default_agent
159            .or(defaults.default_agent)
160            .context("default root config is missing default_agent")?;
161        let load_env = self
162            .load_env
163            .or(defaults.load_env)
164            .context("default root config is missing load_env")?;
165        let saved_playgrounds_dir = self
166            .saved_playgrounds_dir
167            .or(defaults.saved_playgrounds_dir)
168            .context("default root config is missing saved_playgrounds_dir")?;
169
170        Ok(ResolvedRootConfig {
171            agents,
172            default_agent,
173            load_env,
174            saved_playgrounds_dir,
175        })
176    }
177}
178
179impl PlaygroundConfigFile {
180    pub fn json_schema() -> Schema {
181        schema_for!(Self)
182    }
183
184    fn for_playground(playground_id: &str) -> Self {
185        Self {
186            description: format!("TODO: describe {playground_id}"),
187            default_agent: None,
188        }
189    }
190}
191
192pub fn init_playground(playground_id: &str, agent_ids: &[String]) -> Result<InitResult> {
193    init_playground_at(
194        ConfigPaths::from_user_config_dir()?,
195        playground_id,
196        agent_ids,
197    )
198}
199
200fn init_playground_at(
201    paths: ConfigPaths,
202    playground_id: &str,
203    agent_ids: &[String],
204) -> Result<InitResult> {
205    let root_config_created = ensure_root_initialized(&paths)?;
206    let selected_agent_templates = select_agent_templates(agent_ids)?;
207
208    let playground_dir = paths.playgrounds_dir.join(playground_id);
209    let playground_config_file = playground_dir.join(PLAYGROUND_CONFIG_FILE_NAME);
210
211    if playground_config_file.exists() {
212        bail!(
213            "playground '{}' already exists at {}",
214            playground_id,
215            playground_config_file.display()
216        );
217    }
218
219    fs::create_dir_all(&playground_dir)
220        .with_context(|| format!("failed to create {}", playground_dir.display()))?;
221    write_toml_file(
222        &playground_config_file,
223        &PlaygroundConfigFile::for_playground(playground_id),
224    )?;
225    copy_agent_templates(&playground_dir, &selected_agent_templates)?;
226
227    Ok(InitResult {
228        paths,
229        playground_id: playground_id.to_string(),
230        root_config_created,
231        playground_config_created: true,
232        initialized_agent_templates: selected_agent_templates
233            .iter()
234            .map(|(agent_id, _)| agent_id.clone())
235            .collect(),
236    })
237}
238
239fn select_agent_templates(agent_ids: &[String]) -> Result<Vec<(String, &'static Dir<'static>)>> {
240    let available_templates = available_agent_templates();
241    let available_agent_ids = available_templates.keys().cloned().collect::<Vec<_>>();
242    let mut selected_templates = Vec::new();
243
244    for agent_id in agent_ids {
245        if selected_templates
246            .iter()
247            .any(|(selected_agent_id, _)| selected_agent_id == agent_id)
248        {
249            continue;
250        }
251
252        let template_dir = available_templates.get(agent_id).with_context(|| {
253            format!(
254                "unknown agent template '{agent_id}'. Available templates: {}",
255                if available_agent_ids.is_empty() {
256                    "(none)".to_string()
257                } else {
258                    available_agent_ids.join(", ")
259                }
260            )
261        })?;
262        selected_templates.push((agent_id.clone(), *template_dir));
263    }
264
265    Ok(selected_templates)
266}
267
268fn available_agent_templates() -> BTreeMap<String, &'static Dir<'static>> {
269    let mut agent_templates = BTreeMap::new();
270
271    for template_dir in TEMPLATE_DIR.dirs() {
272        let Some(dir_name) = template_dir
273            .path()
274            .file_name()
275            .and_then(|name| name.to_str())
276        else {
277            continue;
278        };
279        let Some(agent_id) = dir_name.strip_prefix('.') else {
280            continue;
281        };
282
283        if agent_id.is_empty() {
284            continue;
285        }
286
287        agent_templates.insert(agent_id.to_string(), template_dir);
288    }
289
290    agent_templates
291}
292
293fn copy_agent_templates(
294    playground_dir: &Path,
295    agent_templates: &[(String, &'static Dir<'static>)],
296) -> Result<()> {
297    for (agent_id, template_dir) in agent_templates {
298        copy_embedded_dir(template_dir, &playground_dir.join(format!(".{agent_id}")))?;
299    }
300
301    Ok(())
302}
303
304fn copy_embedded_dir(template_dir: &'static Dir<'static>, destination: &Path) -> Result<()> {
305    fs::create_dir_all(destination)
306        .with_context(|| format!("failed to create {}", destination.display()))?;
307
308    for nested_dir in template_dir.dirs() {
309        let nested_dir_name = nested_dir.path().file_name().with_context(|| {
310            format!(
311                "embedded template path has no name: {}",
312                nested_dir.path().display()
313            )
314        })?;
315        copy_embedded_dir(nested_dir, &destination.join(nested_dir_name))?;
316    }
317
318    for file in template_dir.files() {
319        let file_name = file.path().file_name().with_context(|| {
320            format!(
321                "embedded template file has no name: {}",
322                file.path().display()
323            )
324        })?;
325        let destination_file = destination.join(file_name);
326        fs::write(&destination_file, file.contents())
327            .with_context(|| format!("failed to write {}", destination_file.display()))?;
328    }
329
330    Ok(())
331}
332
333fn ensure_root_initialized(paths: &ConfigPaths) -> Result<bool> {
334    fs::create_dir_all(&paths.root_dir)
335        .with_context(|| format!("failed to create {}", paths.root_dir.display()))?;
336    fs::create_dir_all(&paths.playgrounds_dir)
337        .with_context(|| format!("failed to create {}", paths.playgrounds_dir.display()))?;
338
339    if paths.config_file.exists() {
340        return Ok(false);
341    }
342
343    write_toml_file(
344        &paths.config_file,
345        &RootConfigFile::defaults_for_paths(paths),
346    )?;
347
348    Ok(true)
349}
350
351fn load_root_config(paths: &ConfigPaths) -> Result<ResolvedRootConfig> {
352    read_toml_file::<RootConfigFile>(&paths.config_file)?.resolve(paths)
353}
354
355fn default_saved_playgrounds_dir(paths: &ConfigPaths) -> PathBuf {
356    paths.root_dir.join("saved-playgrounds")
357}
358
359fn resolve_saved_playgrounds_dir(root_dir: &Path, configured_path: PathBuf) -> PathBuf {
360    if configured_path.is_absolute() {
361        return configured_path;
362    }
363
364    root_dir.join(configured_path)
365}
366
367fn load_playgrounds(
368    playgrounds_dir: &Path,
369    agents: &BTreeMap<String, String>,
370) -> Result<BTreeMap<String, PlaygroundDefinition>> {
371    if !playgrounds_dir.exists() {
372        return Ok(BTreeMap::new());
373    }
374
375    if !playgrounds_dir.is_dir() {
376        bail!(
377            "playground config path is not a directory: {}",
378            playgrounds_dir.display()
379        );
380    }
381
382    let mut playgrounds = BTreeMap::new();
383
384    for entry in fs::read_dir(playgrounds_dir)
385        .with_context(|| format!("failed to read {}", playgrounds_dir.display()))?
386    {
387        let entry = entry.with_context(|| {
388            format!(
389                "failed to inspect an entry under {}",
390                playgrounds_dir.display()
391            )
392        })?;
393        let file_type = entry.file_type().with_context(|| {
394            format!("failed to inspect file type for {}", entry.path().display())
395        })?;
396
397        if !file_type.is_dir() {
398            continue;
399        }
400
401        let directory = entry.path();
402        let config_file = directory.join(PLAYGROUND_CONFIG_FILE_NAME);
403
404        if !config_file.is_file() {
405            bail!(
406                "playground '{}' is missing {}",
407                directory.file_name().unwrap_or_default().to_string_lossy(),
408                PLAYGROUND_CONFIG_FILE_NAME
409            );
410        }
411
412        let playground_config: PlaygroundConfigFile = read_toml_file(&config_file)?;
413        let id = entry.file_name().to_string_lossy().into_owned();
414        if let Some(default_agent) = playground_config.default_agent.as_deref()
415            && !agents.contains_key(default_agent)
416        {
417            bail!("playground '{id}' default agent '{default_agent}' is not defined in [agent]");
418        }
419
420        playgrounds.insert(
421            id.clone(),
422            PlaygroundDefinition {
423                id,
424                description: playground_config.description,
425                default_agent: playground_config.default_agent,
426                directory,
427                config_file,
428            },
429        );
430    }
431
432    Ok(playgrounds)
433}
434
435fn read_toml_file<T>(path: &Path) -> Result<T>
436where
437    T: for<'de> Deserialize<'de>,
438{
439    let content =
440        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
441
442    toml::from_str(&content)
443        .with_context(|| format!("failed to parse TOML from {}", path.display()))
444}
445
446fn write_toml_file<T>(path: &Path, value: &T) -> Result<()>
447where
448    T: Serialize,
449{
450    let content =
451        toml::to_string_pretty(value).context("failed to serialize configuration to TOML")?;
452    fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
453}
454
455#[cfg(test)]
456mod tests {
457    use super::{
458        APP_CONFIG_DIR, AppConfig, ConfigPaths, PlaygroundConfigFile, RootConfigFile,
459        init_playground_at, read_toml_file, user_config_base_dir,
460    };
461    use serde_json::Value;
462    use std::fs;
463    use tempfile::TempDir;
464
465    #[test]
466    fn init_creates_root_and_playground_configs_from_file_models() {
467        let temp_dir = TempDir::new().expect("temp dir");
468        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
469
470        let result = init_playground_at(paths.clone(), "demo", &[]).expect("init should succeed");
471
472        assert!(result.root_config_created);
473        assert!(result.playground_config_created);
474        assert!(result.initialized_agent_templates.is_empty());
475        assert!(temp_dir.path().join("config.toml").is_file());
476        assert!(
477            temp_dir
478                .path()
479                .join("playgrounds")
480                .join("demo")
481                .join("apg.toml")
482                .is_file()
483        );
484        assert!(
485            !temp_dir
486                .path()
487                .join("playgrounds")
488                .join("demo")
489                .join(".claude")
490                .exists()
491        );
492        assert_eq!(
493            read_toml_file::<RootConfigFile>(&temp_dir.path().join("config.toml"))
494                .expect("root config"),
495            RootConfigFile::defaults_for_paths(&paths)
496        );
497        assert_eq!(
498            read_toml_file::<PlaygroundConfigFile>(
499                &temp_dir
500                    .path()
501                    .join("playgrounds")
502                    .join("demo")
503                    .join("apg.toml")
504            )
505            .expect("playground config"),
506            PlaygroundConfigFile::for_playground("demo")
507        );
508
509        let config = AppConfig::load_from_paths(paths).expect("config should load");
510        assert_eq!(config.agents.get("claude"), Some(&"claude".to_string()));
511        assert_eq!(config.agents.get("opencode"), Some(&"opencode".to_string()));
512        assert_eq!(config.default_agent, "claude");
513        assert!(!config.load_env);
514        assert_eq!(
515            config.saved_playgrounds_dir,
516            temp_dir.path().join("saved-playgrounds")
517        );
518        assert_eq!(
519            config
520                .playgrounds
521                .get("demo")
522                .expect("demo playground")
523                .description,
524            "TODO: describe demo"
525        );
526        assert_eq!(
527            config
528                .playgrounds
529                .get("demo")
530                .expect("demo playground")
531                .default_agent,
532            None
533        );
534    }
535
536    #[test]
537    fn merges_root_agents_and_loads_playgrounds() {
538        let temp_dir = TempDir::new().expect("temp dir");
539        let root = temp_dir.path();
540        fs::write(
541            root.join("config.toml"),
542            r#"default_agent = "codex"
543load_env = true
544saved_playgrounds_dir = "archives"
545
546[agent]
547claude = "custom-claude"
548codex = "codex --fast"
549"#,
550        )
551        .expect("write root config");
552
553        let playground_dir = root.join("playgrounds").join("demo");
554        fs::create_dir_all(&playground_dir).expect("create playground dir");
555        fs::write(
556            playground_dir.join("apg.toml"),
557            r#"description = "Demo playground"
558default_agent = "claude""#,
559        )
560        .expect("write playground config");
561
562        let config = AppConfig::load_from_paths(ConfigPaths::from_root_dir(root.to_path_buf()))
563            .expect("config should load");
564
565        assert_eq!(
566            config.agents.get("claude"),
567            Some(&"custom-claude".to_string())
568        );
569        assert_eq!(config.agents.get("opencode"), Some(&"opencode".to_string()));
570        assert_eq!(
571            config.agents.get("codex"),
572            Some(&"codex --fast".to_string())
573        );
574        assert_eq!(config.default_agent, "codex");
575        assert!(config.load_env);
576        assert_eq!(config.saved_playgrounds_dir, root.join("archives"));
577
578        let playground = config.playgrounds.get("demo").expect("demo playground");
579        assert_eq!(playground.description, "Demo playground");
580        assert_eq!(playground.default_agent.as_deref(), Some("claude"));
581        assert_eq!(playground.directory, playground_dir);
582    }
583
584    #[test]
585    fn errors_when_playground_default_agent_is_not_defined() {
586        let temp_dir = TempDir::new().expect("temp dir");
587        fs::write(
588            temp_dir.path().join("config.toml"),
589            r#"[agent]
590claude = "claude"
591"#,
592        )
593        .expect("write root config");
594        let playground_dir = temp_dir.path().join("playgrounds").join("demo");
595        fs::create_dir_all(&playground_dir).expect("create playground dir");
596        fs::write(
597            playground_dir.join("apg.toml"),
598            r#"description = "Demo playground"
599default_agent = "codex""#,
600        )
601        .expect("write playground config");
602
603        let error =
604            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
605                .expect_err("undefined playground default agent should fail");
606
607        assert!(
608            error
609                .to_string()
610                .contains("playground 'demo' default agent 'codex' is not defined")
611        );
612    }
613
614    #[test]
615    fn load_auto_initializes_missing_root_config() {
616        let temp_dir = TempDir::new().expect("temp dir");
617        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
618
619        let config = AppConfig::load_from_paths(paths).expect("missing root config should init");
620
621        assert!(temp_dir.path().join("config.toml").is_file());
622        assert!(temp_dir.path().join("playgrounds").is_dir());
623        assert_eq!(config.agents.get("claude"), Some(&"claude".to_string()));
624        assert_eq!(config.default_agent, "claude");
625        assert!(!config.load_env);
626        assert_eq!(
627            config.saved_playgrounds_dir,
628            temp_dir.path().join("saved-playgrounds")
629        );
630    }
631
632    #[test]
633    fn respects_absolute_saved_playgrounds_dir() {
634        let temp_dir = TempDir::new().expect("temp dir");
635        let archive_dir = TempDir::new().expect("archive dir");
636        let archive_path = archive_dir
637            .path()
638            .display()
639            .to_string()
640            .replace('\\', "\\\\");
641        fs::write(
642            temp_dir.path().join("config.toml"),
643            format!(
644                r#"saved_playgrounds_dir = "{}"
645
646[agent]
647claude = "claude"
648"#,
649                archive_path
650            ),
651        )
652        .expect("write root config");
653
654        let config =
655            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
656                .expect("config should load");
657
658        assert_eq!(config.saved_playgrounds_dir, archive_dir.path());
659    }
660
661    #[test]
662    fn errors_when_playground_config_is_missing() {
663        let temp_dir = TempDir::new().expect("temp dir");
664        fs::write(
665            temp_dir.path().join("config.toml"),
666            r#"[agent]
667claude = "claude"
668opencode = "opencode"
669"#,
670        )
671        .expect("write root config");
672        let playground_dir = temp_dir.path().join("playgrounds").join("broken");
673        fs::create_dir_all(&playground_dir).expect("create playground dir");
674
675        let error =
676            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
677                .expect_err("missing playground config should fail");
678
679        assert!(error.to_string().contains("missing apg.toml"));
680    }
681
682    #[test]
683    fn errors_when_default_agent_is_not_defined() {
684        let temp_dir = TempDir::new().expect("temp dir");
685        fs::write(
686            temp_dir.path().join("config.toml"),
687            r#"default_agent = "codex""#,
688        )
689        .expect("write root config");
690
691        let error =
692            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
693                .expect_err("undefined default agent should fail");
694
695        assert!(
696            error
697                .to_string()
698                .contains("default agent 'codex' is not defined")
699        );
700    }
701
702    #[test]
703    fn init_errors_when_playground_already_exists() {
704        let temp_dir = TempDir::new().expect("temp dir");
705        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
706
707        init_playground_at(paths.clone(), "demo", &[]).expect("initial init should succeed");
708        let error = init_playground_at(paths, "demo", &[]).expect_err("duplicate init should fail");
709
710        assert!(
711            error
712                .to_string()
713                .contains("playground 'demo' already exists")
714        );
715    }
716
717    #[test]
718    fn init_copies_selected_agent_templates_into_playground() {
719        let temp_dir = TempDir::new().expect("temp dir");
720        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
721        let selected_agents = vec!["claude".to_string(), "codex".to_string()];
722
723        let result =
724            init_playground_at(paths, "demo", &selected_agents).expect("init should succeed");
725        let playground_dir = temp_dir.path().join("playgrounds").join("demo");
726
727        assert_eq!(
728            result.initialized_agent_templates,
729            vec!["claude".to_string(), "codex".to_string()]
730        );
731        assert!(
732            playground_dir
733                .join(".claude")
734                .join("settings.json")
735                .is_file()
736        );
737        assert!(playground_dir.join(".codex").join("config.toml").is_file());
738        assert!(!playground_dir.join(".opencode").exists());
739    }
740
741    #[test]
742    fn init_deduplicates_selected_agent_templates() {
743        let temp_dir = TempDir::new().expect("temp dir");
744        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
745        let selected_agents = vec![
746            "claude".to_string(),
747            "claude".to_string(),
748            "codex".to_string(),
749        ];
750
751        let result =
752            init_playground_at(paths, "demo", &selected_agents).expect("init should succeed");
753
754        assert_eq!(
755            result.initialized_agent_templates,
756            vec!["claude".to_string(), "codex".to_string()]
757        );
758    }
759
760    #[test]
761    fn init_errors_for_unknown_agent_template_before_creating_playground() {
762        let temp_dir = TempDir::new().expect("temp dir");
763        let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
764        let selected_agents = vec!["missing".to_string()];
765
766        let error = init_playground_at(paths, "demo", &selected_agents)
767            .expect_err("unknown agent template should fail");
768
769        assert!(
770            error
771                .to_string()
772                .contains("unknown agent template 'missing'")
773        );
774        assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
775    }
776
777    #[test]
778    fn errors_when_root_config_toml_is_invalid() {
779        let temp_dir = TempDir::new().expect("temp dir");
780        fs::write(temp_dir.path().join("config.toml"), "default_agent = ")
781            .expect("write invalid root config");
782
783        let error =
784            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
785                .expect_err("invalid root config should fail");
786
787        assert!(error.to_string().contains("failed to parse TOML"));
788    }
789
790    #[test]
791    fn errors_when_playground_config_toml_is_invalid() {
792        let temp_dir = TempDir::new().expect("temp dir");
793        fs::write(
794            temp_dir.path().join("config.toml"),
795            r#"[agent]
796claude = "claude"
797"#,
798        )
799        .expect("write root config");
800        let playground_dir = temp_dir.path().join("playgrounds").join("broken");
801        fs::create_dir_all(&playground_dir).expect("create playground dir");
802        fs::write(playground_dir.join("apg.toml"), "description = ")
803            .expect("write invalid playground config");
804
805        let error =
806            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
807                .expect_err("invalid playground config should fail");
808
809        assert!(error.to_string().contains("failed to parse TOML"));
810    }
811
812    #[test]
813    fn ignores_non_directory_entries_in_playgrounds_dir() {
814        let temp_dir = TempDir::new().expect("temp dir");
815        fs::write(
816            temp_dir.path().join("config.toml"),
817            r#"[agent]
818claude = "claude"
819"#,
820        )
821        .expect("write root config");
822        let playgrounds_dir = temp_dir.path().join("playgrounds");
823        fs::create_dir_all(&playgrounds_dir).expect("create playgrounds dir");
824        fs::write(playgrounds_dir.join("README.md"), "ignore me").expect("write file entry");
825
826        let config =
827            AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
828                .expect("config should load");
829
830        assert!(config.playgrounds.is_empty());
831    }
832
833    #[test]
834    fn user_config_dir_uses_dot_config_on_all_platforms() {
835        let base_dir = user_config_base_dir().expect("user config base dir");
836        let paths = ConfigPaths::from_user_config_dir().expect("user config paths");
837
838        assert!(base_dir.ends_with(".config"));
839        assert_eq!(paths.root_dir, base_dir.join(APP_CONFIG_DIR));
840    }
841
842    #[test]
843    fn root_config_schema_matches_file_shape() {
844        let schema = serde_json::to_value(RootConfigFile::json_schema()).expect("schema json");
845
846        assert_eq!(schema["type"], Value::String("object".to_string()));
847        assert!(schema["properties"]["agent"].is_object());
848        assert!(schema["properties"]["default_agent"].is_object());
849        assert!(schema["properties"]["load_env"].is_object());
850        assert!(schema["properties"]["saved_playgrounds_dir"].is_object());
851    }
852
853    #[test]
854    fn playground_config_schema_matches_file_shape() {
855        let schema =
856            serde_json::to_value(PlaygroundConfigFile::json_schema()).expect("schema json");
857
858        assert_eq!(schema["type"], Value::String("object".to_string()));
859        assert!(schema["properties"]["description"].is_object());
860        assert!(schema["properties"]["default_agent"].is_object());
861        assert_eq!(
862            schema["required"],
863            Value::Array(vec![Value::String("description".to_string())])
864        );
865    }
866}