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}