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