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}