1use std::{
13 collections::BTreeMap,
14 fs, io,
15 path::{Path, PathBuf},
16 process::{Command, Stdio},
17};
18
19use anyhow::{Context, Result, bail};
20use include_dir::{Dir, include_dir};
21use schemars::{JsonSchema, Schema, schema_for};
22use serde::{Deserialize, Serialize};
23
24const APP_CONFIG_DIR: &str = "agent-playground";
25const ROOT_CONFIG_FILE_NAME: &str = "config.toml";
26const PLAYGROUND_CONFIG_FILE_NAME: &str = "apg.toml";
27const PLAYGROUNDS_DIR_NAME: &str = "playgrounds";
28const DEFAULT_SUBCOMMAND_PLAYGROUND_ID: &str = "default";
29const DEFAULT_SAVED_PLAYGROUNDS_DIR_NAME: &str = "saved-playgrounds";
30static TEMPLATE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates");
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ConfigPaths {
35 pub root_dir: PathBuf,
39 pub config_file: PathBuf,
41 pub playgrounds_dir: PathBuf,
43}
44
45impl ConfigPaths {
46 pub fn from_user_config_dir() -> Result<Self> {
50 let config_dir = user_config_base_dir()?;
51
52 Ok(Self::from_root_dir(config_dir.join(APP_CONFIG_DIR)))
53 }
54
55 pub fn from_root_dir(root_dir: PathBuf) -> Self {
57 Self {
58 config_file: root_dir.join(ROOT_CONFIG_FILE_NAME),
59 playgrounds_dir: root_dir.join(PLAYGROUNDS_DIR_NAME),
60 root_dir,
61 }
62 }
63}
64
65fn user_config_base_dir() -> Result<PathBuf> {
66 let home_dir = dirs::home_dir().context("failed to locate the user's home directory")?;
67 Ok(home_dir.join(".config"))
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct AppConfig {
76 pub paths: ConfigPaths,
78 pub agents: BTreeMap<String, String>,
80 pub saved_playgrounds_dir: PathBuf,
82 pub playground_defaults: PlaygroundConfig,
84 pub playgrounds: BTreeMap<String, PlaygroundDefinition>,
86}
87
88impl AppConfig {
89 pub fn load() -> Result<Self> {
94 Self::load_from_paths(ConfigPaths::from_user_config_dir()?)
95 }
96
97 fn load_from_paths(paths: ConfigPaths) -> Result<Self> {
98 ensure_root_initialized(&paths)?;
99 let resolved_root_config = load_root_config(&paths)?;
100 let agents = resolved_root_config.agents;
101 let saved_playgrounds_dir = resolve_saved_playgrounds_dir(
102 &paths.root_dir,
103 resolved_root_config.saved_playgrounds_dir,
104 );
105 let playground_defaults = resolved_root_config.playground_defaults;
106
107 validate_default_agent_defined(
108 &agents,
109 playground_defaults.default_agent.as_deref(),
110 "default agent",
111 )?;
112
113 let playgrounds = load_playgrounds(&paths.playgrounds_dir, &agents, &playground_defaults)?;
114
115 Ok(Self {
116 paths,
117 agents,
118 saved_playgrounds_dir,
119 playground_defaults,
120 playgrounds,
121 })
122 }
123
124 pub(crate) fn resolve_playground_config(
127 &self,
128 playground: &PlaygroundDefinition,
129 ) -> Result<ResolvedPlaygroundConfig> {
130 playground
131 .playground
132 .resolve_over(&self.playground_defaults)
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct InitResult {
139 pub paths: ConfigPaths,
141 pub playground_id: String,
143 pub root_config_created: bool,
145 pub playground_config_created: bool,
147 pub initialized_agent_templates: Vec<String>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct RemoveResult {
154 pub paths: ConfigPaths,
156 pub playground_id: String,
158 pub playground_dir: PathBuf,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct PlaygroundDefinition {
165 pub id: String,
167 pub description: String,
169 pub directory: PathBuf,
171 pub config_file: PathBuf,
173 pub playground: PlaygroundConfig,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct ConfiguredPlayground {
180 pub id: String,
182 pub description: String,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
187#[serde(rename_all = "lowercase")]
188pub enum CreateMode {
190 #[default]
192 Copy,
193 Symlink,
195 Hardlink,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
200pub struct PlaygroundConfig {
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub default_agent: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub load_env: Option<bool>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub create_mode: Option<CreateMode>,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
214pub struct RootConfigFile {
216 #[serde(default)]
218 pub agent: BTreeMap<String, String>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub saved_playgrounds_dir: Option<PathBuf>,
224 #[serde(default, skip_serializing_if = "PlaygroundConfig::is_empty")]
226 pub playground: PlaygroundConfig,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
230struct ResolvedRootConfig {
231 agents: BTreeMap<String, String>,
232 saved_playgrounds_dir: PathBuf,
233 playground_defaults: PlaygroundConfig,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub(crate) struct ResolvedPlaygroundConfig {
238 pub(crate) default_agent: String,
239 pub(crate) load_env: bool,
240 pub(crate) create_mode: CreateMode,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
244pub struct PlaygroundConfigFile {
246 pub description: String,
248 #[serde(flatten)]
250 pub playground: PlaygroundConfig,
251}
252
253impl PlaygroundConfig {
254 fn builtin_defaults() -> Self {
255 Self {
256 default_agent: Some("claude".to_string()),
257 load_env: Some(false),
258 create_mode: Some(CreateMode::Copy),
259 }
260 }
261
262 fn is_empty(&self) -> bool {
263 self.default_agent.is_none() && self.load_env.is_none() && self.create_mode.is_none()
264 }
265
266 fn merged_over(&self, base: &Self) -> Self {
267 Self {
268 default_agent: self
269 .default_agent
270 .clone()
271 .or_else(|| base.default_agent.clone()),
272 load_env: self.load_env.or(base.load_env),
273 create_mode: self.create_mode.or(base.create_mode),
274 }
275 }
276
277 fn resolve_over(&self, base: &Self) -> Result<ResolvedPlaygroundConfig> {
278 let merged = self.merged_over(base);
279
280 Ok(ResolvedPlaygroundConfig {
281 default_agent: merged
282 .default_agent
283 .context("default playground config is missing default_agent")?,
284 load_env: merged.load_env.unwrap_or(false),
285 create_mode: merged.create_mode.unwrap_or(CreateMode::Copy),
286 })
287 }
288}
289
290impl RootConfigFile {
291 pub fn json_schema() -> Schema {
293 schema_for!(Self)
294 }
295
296 fn defaults_for_paths(paths: &ConfigPaths) -> Self {
297 let mut agent = BTreeMap::new();
298 agent.insert("claude".to_string(), "claude".to_string());
299 agent.insert("opencode".to_string(), "opencode".to_string());
300
301 Self {
302 agent,
303 saved_playgrounds_dir: Some(default_saved_playgrounds_dir(paths)),
304 playground: PlaygroundConfig::builtin_defaults(),
305 }
306 }
307
308 fn resolve(self, paths: &ConfigPaths) -> Result<ResolvedRootConfig> {
309 let defaults = Self::defaults_for_paths(paths);
310 let mut agents = defaults.agent;
311 agents.extend(self.agent);
312
313 let saved_playgrounds_dir = self
314 .saved_playgrounds_dir
315 .or(defaults.saved_playgrounds_dir)
316 .context("default root config is missing saved_playgrounds_dir")?;
317 let playground_defaults = self.playground.merged_over(&defaults.playground);
318
319 Ok(ResolvedRootConfig {
320 agents,
321 saved_playgrounds_dir,
322 playground_defaults,
323 })
324 }
325}
326
327impl PlaygroundConfigFile {
328 pub fn json_schema() -> Schema {
330 schema_for!(Self)
331 }
332
333 fn for_playground(playground_id: &str) -> Self {
334 Self {
335 description: format!("TODO: describe {playground_id}"),
336 playground: PlaygroundConfig::default(),
337 }
338 }
339}
340
341pub fn init_playground(playground_id: &str, agent_ids: &[String]) -> Result<InitResult> {
347 init_playground_at(
348 ConfigPaths::from_user_config_dir()?,
349 playground_id,
350 agent_ids,
351 )
352}
353
354fn init_playground_at(
355 paths: ConfigPaths,
356 playground_id: &str,
357 agent_ids: &[String],
358) -> Result<InitResult> {
359 init_playground_at_with_git(
360 paths,
361 playground_id,
362 agent_ids,
363 git_is_available,
364 init_git_repo,
365 )
366}
367
368fn init_playground_at_with_git<GA, GI>(
369 paths: ConfigPaths,
370 playground_id: &str,
371 agent_ids: &[String],
372 git_is_available: GA,
373 init_git_repo: GI,
374) -> Result<InitResult>
375where
376 GA: Fn() -> Result<bool>,
377 GI: Fn(&Path) -> Result<()>,
378{
379 validate_playground_id(playground_id)?;
380 let root_config_created = ensure_root_initialized(&paths)?;
381 let selected_agent_templates = select_agent_templates(agent_ids)?;
382
383 let playground_dir = paths.playgrounds_dir.join(playground_id);
384 let playground_config_file = playground_dir.join(PLAYGROUND_CONFIG_FILE_NAME);
385
386 if playground_config_file.exists() {
387 bail!(
388 "playground '{}' already exists at {}",
389 playground_id,
390 playground_config_file.display()
391 );
392 }
393
394 fs::create_dir_all(&playground_dir)
395 .with_context(|| format!("failed to create {}", playground_dir.display()))?;
396 write_toml_file(
397 &playground_config_file,
398 &PlaygroundConfigFile::for_playground(playground_id),
399 )?;
400 copy_agent_templates(&playground_dir, &selected_agent_templates)?;
401 if git_is_available()?
402 && let Err(error) = init_git_repo(&playground_dir)
403 {
404 match fs::remove_dir_all(&playground_dir) {
405 Ok(()) => {
406 return Err(error).context(format!(
407 "failed to initialize git repository in {}; removed partially initialized playground",
408 playground_dir.display()
409 ));
410 }
411 Err(cleanup_error) => {
412 return Err(error).context(format!(
413 "failed to initialize git repository in {}; additionally failed to remove partially initialized playground {}: {cleanup_error}",
414 playground_dir.display(),
415 playground_dir.display()
416 ));
417 }
418 }
419 }
420
421 Ok(InitResult {
422 paths,
423 playground_id: playground_id.to_string(),
424 root_config_created,
425 playground_config_created: true,
426 initialized_agent_templates: selected_agent_templates
427 .iter()
428 .map(|(agent_id, _)| agent_id.clone())
429 .collect(),
430 })
431}
432
433pub fn configured_playground_ids() -> Result<Vec<String>> {
439 Ok(configured_playgrounds()?
440 .into_iter()
441 .map(|playground| playground.id)
442 .collect())
443}
444
445pub fn configured_playgrounds() -> Result<Vec<ConfiguredPlayground>> {
451 configured_playgrounds_at(&ConfigPaths::from_user_config_dir()?.playgrounds_dir)
452}
453
454pub fn resolve_playground_dir(playground_id: &str) -> Result<PathBuf> {
456 resolve_playground_dir_at(ConfigPaths::from_user_config_dir()?, playground_id)
457}
458
459pub fn remove_playground(playground_id: &str) -> Result<RemoveResult> {
461 let paths = ConfigPaths::from_user_config_dir()?;
462 remove_playground_at(paths, playground_id)
463}
464
465fn remove_playground_at(paths: ConfigPaths, playground_id: &str) -> Result<RemoveResult> {
466 let playground_dir = resolve_playground_dir_at(paths.clone(), playground_id)?;
467
468 fs::remove_dir_all(&playground_dir)
469 .with_context(|| format!("failed to remove {}", playground_dir.display()))?;
470
471 Ok(RemoveResult {
472 paths,
473 playground_id: playground_id.to_string(),
474 playground_dir,
475 })
476}
477
478fn resolve_playground_dir_at(paths: ConfigPaths, playground_id: &str) -> Result<PathBuf> {
479 validate_playground_id(playground_id)?;
480
481 let playground_dir = paths.playgrounds_dir.join(playground_id);
482 if !playground_dir.exists() {
483 bail!("unknown playground '{playground_id}'");
484 }
485
486 let metadata = fs::symlink_metadata(&playground_dir)
487 .with_context(|| format!("failed to inspect {}", playground_dir.display()))?;
488 if metadata.file_type().is_symlink() {
489 bail!(
490 "playground '{}' cannot be removed because it is a symlink: {}",
491 playground_id,
492 playground_dir.display()
493 );
494 }
495 if !metadata.is_dir() {
496 bail!(
497 "playground '{}' is not a directory: {}",
498 playground_id,
499 playground_dir.display()
500 );
501 }
502
503 Ok(playground_dir)
504}
505
506fn configured_playgrounds_at(playgrounds_dir: &Path) -> Result<Vec<ConfiguredPlayground>> {
507 if !playgrounds_dir.exists() {
508 return Ok(Vec::new());
509 }
510
511 if !playgrounds_dir.is_dir() {
512 bail!(
513 "playground config path is not a directory: {}",
514 playgrounds_dir.display()
515 );
516 }
517
518 let mut playgrounds = Vec::new();
519 for entry_result in fs::read_dir(playgrounds_dir)
520 .with_context(|| format!("failed to read {}", playgrounds_dir.display()))?
521 {
522 let Ok(entry) = entry_result else {
523 continue;
525 };
526
527 let Ok(file_type) = entry.file_type() else {
528 continue;
530 };
531
532 if !file_type.is_dir() {
533 continue;
534 }
535
536 let playground_id = entry.file_name().to_string_lossy().into_owned();
537 if validate_playground_id(&playground_id).is_err() {
538 continue;
539 }
540
541 let config_file = entry.path().join(PLAYGROUND_CONFIG_FILE_NAME);
542 if !config_file.is_file() {
543 continue;
544 }
545
546 let Ok(playground_config) = read_toml_file::<PlaygroundConfigFile>(&config_file) else {
547 continue;
548 };
549
550 playgrounds.push(ConfiguredPlayground {
551 id: playground_id,
552 description: playground_config.description,
553 });
554 }
555
556 playgrounds.sort_by(|left, right| left.id.cmp(&right.id));
557 Ok(playgrounds)
558}
559
560fn validate_playground_id(playground_id: &str) -> Result<()> {
561 if playground_id.is_empty() {
562 bail!("playground id cannot be empty");
563 }
564 if playground_id == DEFAULT_SUBCOMMAND_PLAYGROUND_ID {
565 bail!(
566 "invalid playground id '{playground_id}': this name is reserved for the `default` subcommand"
567 );
568 }
569 if playground_id.starts_with("__") {
570 bail!(
571 "invalid playground id '{playground_id}': ids starting with '__' are reserved for internal use"
572 );
573 }
574 if matches!(playground_id, "." | "..")
575 || playground_id.contains('/')
576 || playground_id.contains('\\')
577 {
578 bail!(
579 "invalid playground id '{}': ids must not contain path separators or parent-directory segments",
580 playground_id
581 );
582 }
583
584 Ok(())
585}
586
587fn git_is_available() -> Result<bool> {
588 match Command::new("git")
589 .arg("--version")
590 .stdout(Stdio::null())
591 .stderr(Stdio::null())
592 .status()
593 {
594 Ok(status) => Ok(status.success()),
595 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
596 Err(error) => Err(error).context("failed to check whether git is available"),
597 }
598}
599
600fn init_git_repo(playground_dir: &Path) -> Result<()> {
601 let status = Command::new("git")
602 .arg("init")
603 .current_dir(playground_dir)
604 .stdout(Stdio::null())
605 .stderr(Stdio::null())
606 .status()
607 .with_context(|| {
608 format!(
609 "failed to initialize git repository in {}",
610 playground_dir.display()
611 )
612 })?;
613
614 if !status.success() {
615 bail!(
616 "git init exited with status {status} in {}",
617 playground_dir.display()
618 );
619 }
620
621 Ok(())
622}
623
624fn select_agent_templates(agent_ids: &[String]) -> Result<Vec<(String, &'static Dir<'static>)>> {
625 let available_templates = available_agent_templates();
626 let available_agent_ids = available_templates.keys().cloned().collect::<Vec<_>>();
627 let mut selected_templates = Vec::new();
628
629 for agent_id in agent_ids {
630 if selected_templates
631 .iter()
632 .any(|(selected_agent_id, _)| selected_agent_id == agent_id)
633 {
634 continue;
635 }
636
637 let template_dir = available_templates.get(agent_id).with_context(|| {
638 format!(
639 "unknown agent template '{agent_id}'. Available templates: {}",
640 if available_agent_ids.is_empty() {
641 "(none)".to_string()
642 } else {
643 available_agent_ids.join(", ")
644 }
645 )
646 })?;
647 selected_templates.push((agent_id.clone(), *template_dir));
648 }
649
650 Ok(selected_templates)
651}
652
653fn available_agent_templates() -> BTreeMap<String, &'static Dir<'static>> {
654 let mut agent_templates = BTreeMap::new();
655
656 for template_dir in TEMPLATE_DIR.dirs() {
657 let Some(dir_name) = template_dir
658 .path()
659 .file_name()
660 .and_then(|name| name.to_str())
661 else {
662 continue;
663 };
664 let Some(agent_id) = dir_name.strip_prefix('.') else {
665 continue;
666 };
667
668 if agent_id.is_empty() {
669 continue;
670 }
671
672 agent_templates.insert(agent_id.to_string(), template_dir);
673 }
674
675 agent_templates
676}
677
678fn copy_agent_templates(
679 playground_dir: &Path,
680 agent_templates: &[(String, &'static Dir<'static>)],
681) -> Result<()> {
682 for (agent_id, template_dir) in agent_templates {
683 copy_embedded_dir(template_dir, &playground_dir.join(format!(".{agent_id}")))?;
684 }
685
686 Ok(())
687}
688
689fn copy_embedded_dir(template_dir: &'static Dir<'static>, destination: &Path) -> Result<()> {
690 fs::create_dir_all(destination)
691 .with_context(|| format!("failed to create {}", destination.display()))?;
692
693 for nested_dir in template_dir.dirs() {
694 let nested_dir_name = nested_dir.path().file_name().with_context(|| {
695 format!(
696 "embedded template path has no name: {}",
697 nested_dir.path().display()
698 )
699 })?;
700 copy_embedded_dir(nested_dir, &destination.join(nested_dir_name))?;
701 }
702
703 for file in template_dir.files() {
704 let file_name = file.path().file_name().with_context(|| {
705 format!(
706 "embedded template file has no name: {}",
707 file.path().display()
708 )
709 })?;
710 let destination_file = destination.join(file_name);
711 fs::write(&destination_file, file.contents())
712 .with_context(|| format!("failed to write {}", destination_file.display()))?;
713 }
714
715 Ok(())
716}
717
718fn ensure_root_initialized(paths: &ConfigPaths) -> Result<bool> {
719 fs::create_dir_all(&paths.root_dir)
720 .with_context(|| format!("failed to create {}", paths.root_dir.display()))?;
721 fs::create_dir_all(&paths.playgrounds_dir)
722 .with_context(|| format!("failed to create {}", paths.playgrounds_dir.display()))?;
723
724 if paths.config_file.exists() {
725 return Ok(false);
726 }
727
728 write_toml_file(
729 &paths.config_file,
730 &RootConfigFile::defaults_for_paths(paths),
731 )?;
732
733 Ok(true)
734}
735
736fn load_root_config(paths: &ConfigPaths) -> Result<ResolvedRootConfig> {
737 read_toml_file::<RootConfigFile>(&paths.config_file)?.resolve(paths)
738}
739
740fn default_saved_playgrounds_dir(_paths: &ConfigPaths) -> PathBuf {
741 PathBuf::from(DEFAULT_SAVED_PLAYGROUNDS_DIR_NAME)
742}
743
744fn resolve_saved_playgrounds_dir(root_dir: &Path, configured_path: PathBuf) -> PathBuf {
745 if configured_path.is_absolute() {
746 return configured_path;
747 }
748
749 root_dir.join(configured_path)
750}
751
752fn validate_default_agent_defined(
753 agents: &BTreeMap<String, String>,
754 default_agent: Option<&str>,
755 label: &str,
756) -> Result<()> {
757 let Some(default_agent) = default_agent else {
758 bail!("{label} is missing");
759 };
760
761 if !agents.contains_key(default_agent) {
762 bail!("{label} '{default_agent}' is not defined in [agent]");
763 }
764
765 Ok(())
766}
767
768fn load_playgrounds(
769 playgrounds_dir: &Path,
770 agents: &BTreeMap<String, String>,
771 playground_defaults: &PlaygroundConfig,
772) -> Result<BTreeMap<String, PlaygroundDefinition>> {
773 if !playgrounds_dir.exists() {
774 return Ok(BTreeMap::new());
775 }
776
777 if !playgrounds_dir.is_dir() {
778 bail!(
779 "playground config path is not a directory: {}",
780 playgrounds_dir.display()
781 );
782 }
783
784 let mut playgrounds = BTreeMap::new();
785
786 for entry in fs::read_dir(playgrounds_dir)
787 .with_context(|| format!("failed to read {}", playgrounds_dir.display()))?
788 {
789 let entry = entry.with_context(|| {
790 format!(
791 "failed to inspect an entry under {}",
792 playgrounds_dir.display()
793 )
794 })?;
795 let file_type = entry.file_type().with_context(|| {
796 format!("failed to inspect file type for {}", entry.path().display())
797 })?;
798
799 if !file_type.is_dir() {
800 continue;
801 }
802
803 let directory = entry.path();
804 let config_file = directory.join(PLAYGROUND_CONFIG_FILE_NAME);
805
806 if !config_file.is_file() {
807 bail!(
808 "playground '{}' is missing {}",
809 directory.file_name().unwrap_or_default().to_string_lossy(),
810 PLAYGROUND_CONFIG_FILE_NAME
811 );
812 }
813
814 let playground_config: PlaygroundConfigFile = read_toml_file(&config_file)?;
815 let id = entry.file_name().to_string_lossy().into_owned();
816 validate_playground_id(&id).with_context(|| {
817 format!(
818 "invalid playground directory under {}",
819 playgrounds_dir.display()
820 )
821 })?;
822 let effective_config = playground_config
823 .playground
824 .merged_over(playground_defaults);
825 validate_default_agent_defined(
826 agents,
827 effective_config.default_agent.as_deref(),
828 &format!("playground '{id}' default agent"),
829 )?;
830
831 playgrounds.insert(
832 id.clone(),
833 PlaygroundDefinition {
834 id,
835 description: playground_config.description,
836 directory,
837 config_file,
838 playground: playground_config.playground,
839 },
840 );
841 }
842
843 Ok(playgrounds)
844}
845
846fn read_toml_file<T>(path: &Path) -> Result<T>
847where
848 T: for<'de> Deserialize<'de>,
849{
850 let content =
851 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
852
853 toml::from_str(&content)
854 .with_context(|| format!("failed to parse TOML from {}", path.display()))
855}
856
857fn write_toml_file<T>(path: &Path, value: &T) -> Result<()>
858where
859 T: Serialize,
860{
861 let content =
862 toml::to_string_pretty(value).context("failed to serialize configuration to TOML")?;
863 fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
864}
865
866#[cfg(test)]
867mod tests {
868 use super::{
869 APP_CONFIG_DIR, AppConfig, ConfigPaths, ConfiguredPlayground, CreateMode,
870 PlaygroundConfigFile, RootConfigFile, configured_playgrounds_at, init_playground_at,
871 init_playground_at_with_git, read_toml_file, remove_playground_at,
872 resolve_playground_dir_at, user_config_base_dir,
873 };
874 use serde_json::Value;
875 use std::{cell::Cell, fs, io};
876 use tempfile::TempDir;
877
878 #[test]
879 fn init_creates_root_and_playground_configs_from_file_models() {
880 let temp_dir = TempDir::new().expect("temp dir");
881 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
882
883 let result = init_playground_at(paths.clone(), "demo", &[]).expect("init should succeed");
884
885 assert!(result.root_config_created);
886 assert!(result.playground_config_created);
887 assert!(result.initialized_agent_templates.is_empty());
888 assert!(temp_dir.path().join("config.toml").is_file());
889 assert!(
890 temp_dir
891 .path()
892 .join("playgrounds")
893 .join("demo")
894 .join("apg.toml")
895 .is_file()
896 );
897 assert!(
898 !temp_dir
899 .path()
900 .join("playgrounds")
901 .join("demo")
902 .join(".claude")
903 .exists()
904 );
905 assert_eq!(
906 read_toml_file::<RootConfigFile>(&temp_dir.path().join("config.toml"))
907 .expect("root config"),
908 RootConfigFile::defaults_for_paths(&paths)
909 );
910 assert_eq!(
911 read_toml_file::<PlaygroundConfigFile>(
912 &temp_dir
913 .path()
914 .join("playgrounds")
915 .join("demo")
916 .join("apg.toml")
917 )
918 .expect("playground config"),
919 PlaygroundConfigFile::for_playground("demo")
920 );
921
922 let config = AppConfig::load_from_paths(paths).expect("config should load");
923 assert_eq!(config.agents.get("claude"), Some(&"claude".to_string()));
924 assert_eq!(config.agents.get("opencode"), Some(&"opencode".to_string()));
925 assert_eq!(
926 config.playground_defaults.default_agent.as_deref(),
927 Some("claude")
928 );
929 assert_eq!(config.playground_defaults.load_env, Some(false));
930 assert_eq!(
931 config.playground_defaults.create_mode,
932 Some(CreateMode::Copy)
933 );
934 assert_eq!(
935 config.saved_playgrounds_dir,
936 temp_dir.path().join("saved-playgrounds")
937 );
938 assert_eq!(
939 config
940 .playgrounds
941 .get("demo")
942 .expect("demo playground")
943 .description,
944 "TODO: describe demo"
945 );
946 assert!(
947 config
948 .playgrounds
949 .get("demo")
950 .expect("demo playground")
951 .playground
952 .is_empty()
953 );
954 }
955
956 #[test]
957 fn merges_root_agents_and_loads_playgrounds() {
958 let temp_dir = TempDir::new().expect("temp dir");
959 let root = temp_dir.path();
960 fs::write(
961 root.join("config.toml"),
962 r#"saved_playgrounds_dir = "archives"
963
964[agent]
965claude = "custom-claude"
966codex = "codex --fast"
967
968[playground]
969default_agent = "codex"
970load_env = true
971create_mode = "hardlink"
972"#,
973 )
974 .expect("write root config");
975
976 let playground_dir = root.join("playgrounds").join("demo");
977 fs::create_dir_all(&playground_dir).expect("create playground dir");
978 fs::write(
979 playground_dir.join("apg.toml"),
980 r#"description = "Demo playground"
981default_agent = "claude""#,
982 )
983 .expect("write playground config");
984
985 let config = AppConfig::load_from_paths(ConfigPaths::from_root_dir(root.to_path_buf()))
986 .expect("config should load");
987
988 assert_eq!(
989 config.agents.get("claude"),
990 Some(&"custom-claude".to_string())
991 );
992 assert_eq!(config.agents.get("opencode"), Some(&"opencode".to_string()));
993 assert_eq!(
994 config.agents.get("codex"),
995 Some(&"codex --fast".to_string())
996 );
997 assert_eq!(
998 config.playground_defaults.default_agent.as_deref(),
999 Some("codex")
1000 );
1001 assert_eq!(config.playground_defaults.load_env, Some(true));
1002 assert_eq!(
1003 config.playground_defaults.create_mode,
1004 Some(CreateMode::Hardlink)
1005 );
1006 assert_eq!(config.saved_playgrounds_dir, root.join("archives"));
1007
1008 let playground = config.playgrounds.get("demo").expect("demo playground");
1009 assert_eq!(playground.description, "Demo playground");
1010 assert_eq!(
1011 playground.playground.default_agent.as_deref(),
1012 Some("claude")
1013 );
1014 assert_eq!(playground.directory, playground_dir);
1015 let effective_config = config
1016 .resolve_playground_config(playground)
1017 .expect("effective playground config");
1018 assert_eq!(effective_config.default_agent, "claude");
1019 assert!(effective_config.load_env);
1020 assert_eq!(effective_config.create_mode, CreateMode::Hardlink);
1021 }
1022
1023 #[test]
1024 fn playground_create_mode_overrides_root_default() {
1025 let temp_dir = TempDir::new().expect("temp dir");
1026 fs::write(
1027 temp_dir.path().join("config.toml"),
1028 r#"[playground]
1029create_mode = "copy"
1030"#,
1031 )
1032 .expect("write root config");
1033 let playground_dir = temp_dir.path().join("playgrounds").join("demo");
1034 fs::create_dir_all(&playground_dir).expect("create playground dir");
1035 fs::write(
1036 playground_dir.join("apg.toml"),
1037 r#"description = "Demo playground"
1038create_mode = "symlink""#,
1039 )
1040 .expect("write playground config");
1041
1042 let config =
1043 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1044 .expect("config should load");
1045 let playground = config.playgrounds.get("demo").expect("demo playground");
1046 let effective_config = config
1047 .resolve_playground_config(playground)
1048 .expect("effective playground config");
1049
1050 assert_eq!(
1051 config.playground_defaults.create_mode,
1052 Some(CreateMode::Copy)
1053 );
1054 assert_eq!(playground.playground.create_mode, Some(CreateMode::Symlink));
1055 assert_eq!(effective_config.create_mode, CreateMode::Symlink);
1056 }
1057
1058 #[test]
1059 fn errors_when_playground_default_agent_is_not_defined() {
1060 let temp_dir = TempDir::new().expect("temp dir");
1061 fs::write(
1062 temp_dir.path().join("config.toml"),
1063 r#"[agent]
1064claude = "claude"
1065"#,
1066 )
1067 .expect("write root config");
1068 let playground_dir = temp_dir.path().join("playgrounds").join("demo");
1069 fs::create_dir_all(&playground_dir).expect("create playground dir");
1070 fs::write(
1071 playground_dir.join("apg.toml"),
1072 r#"description = "Demo playground"
1073default_agent = "codex""#,
1074 )
1075 .expect("write playground config");
1076
1077 let error =
1078 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1079 .expect_err("undefined playground default agent should fail");
1080
1081 assert!(
1082 error
1083 .to_string()
1084 .contains("playground 'demo' default agent 'codex' is not defined")
1085 );
1086 }
1087
1088 #[test]
1089 fn load_auto_initializes_missing_root_config() {
1090 let temp_dir = TempDir::new().expect("temp dir");
1091 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1092
1093 let config = AppConfig::load_from_paths(paths).expect("missing root config should init");
1094
1095 assert!(temp_dir.path().join("config.toml").is_file());
1096 assert!(temp_dir.path().join("playgrounds").is_dir());
1097 assert_eq!(config.agents.get("claude"), Some(&"claude".to_string()));
1098 assert_eq!(
1099 config.playground_defaults.default_agent.as_deref(),
1100 Some("claude")
1101 );
1102 assert_eq!(config.playground_defaults.load_env, Some(false));
1103 assert_eq!(
1104 config.playground_defaults.create_mode,
1105 Some(CreateMode::Copy)
1106 );
1107 assert_eq!(
1108 config.saved_playgrounds_dir,
1109 temp_dir.path().join("saved-playgrounds")
1110 );
1111 }
1112
1113 #[test]
1114 fn respects_absolute_saved_playgrounds_dir() {
1115 let temp_dir = TempDir::new().expect("temp dir");
1116 let archive_dir = TempDir::new().expect("archive dir");
1117 let archive_path = archive_dir
1118 .path()
1119 .display()
1120 .to_string()
1121 .replace('\\', "\\\\");
1122 fs::write(
1123 temp_dir.path().join("config.toml"),
1124 format!(
1125 r#"saved_playgrounds_dir = "{}"
1126
1127[agent]
1128claude = "claude"
1129"#,
1130 archive_path
1131 ),
1132 )
1133 .expect("write root config");
1134
1135 let config =
1136 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1137 .expect("config should load");
1138
1139 assert_eq!(config.saved_playgrounds_dir, archive_dir.path());
1140 }
1141
1142 #[test]
1143 fn errors_when_playground_config_is_missing() {
1144 let temp_dir = TempDir::new().expect("temp dir");
1145 fs::write(
1146 temp_dir.path().join("config.toml"),
1147 r#"[agent]
1148claude = "claude"
1149opencode = "opencode"
1150"#,
1151 )
1152 .expect("write root config");
1153 let playground_dir = temp_dir.path().join("playgrounds").join("broken");
1154 fs::create_dir_all(&playground_dir).expect("create playground dir");
1155
1156 let error =
1157 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1158 .expect_err("missing playground config should fail");
1159
1160 assert!(error.to_string().contains("missing apg.toml"));
1161 }
1162
1163 #[test]
1164 fn errors_when_default_agent_is_not_defined() {
1165 let temp_dir = TempDir::new().expect("temp dir");
1166 fs::write(
1167 temp_dir.path().join("config.toml"),
1168 r#"[playground]
1169default_agent = "codex""#,
1170 )
1171 .expect("write root config");
1172
1173 let error =
1174 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1175 .expect_err("undefined default agent should fail");
1176
1177 assert!(
1178 error
1179 .to_string()
1180 .contains("default agent 'codex' is not defined")
1181 );
1182 }
1183
1184 #[test]
1185 fn init_errors_when_playground_already_exists() {
1186 let temp_dir = TempDir::new().expect("temp dir");
1187 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1188
1189 init_playground_at(paths.clone(), "demo", &[]).expect("initial init should succeed");
1190 let error = init_playground_at(paths, "demo", &[]).expect_err("duplicate init should fail");
1191
1192 assert!(
1193 error
1194 .to_string()
1195 .contains("playground 'demo' already exists")
1196 );
1197 }
1198
1199 #[test]
1200 fn init_rejects_reserved_default_playground_id() {
1201 let temp_dir = TempDir::new().expect("temp dir");
1202 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1203
1204 let error = init_playground_at(paths, "default", &[]).expect_err("reserved id should fail");
1205
1206 assert!(
1207 error
1208 .to_string()
1209 .contains("invalid playground id 'default'")
1210 );
1211 assert!(
1212 error
1213 .to_string()
1214 .contains("reserved for the `default` subcommand")
1215 );
1216 }
1217
1218 #[test]
1219 fn init_rejects_internal_reserved_playground_id_prefix() {
1220 let temp_dir = TempDir::new().expect("temp dir");
1221 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1222
1223 let error =
1224 init_playground_at(paths, "__default__", &[]).expect_err("reserved id should fail");
1225
1226 assert!(
1227 error
1228 .to_string()
1229 .contains("ids starting with '__' are reserved for internal use")
1230 );
1231 }
1232
1233 #[test]
1234 fn remove_deletes_existing_playground_directory() {
1235 let temp_dir = TempDir::new().expect("temp dir");
1236 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1237 let nested_file = temp_dir
1238 .path()
1239 .join("playgrounds")
1240 .join("demo")
1241 .join("notes.txt");
1242
1243 init_playground_at(paths.clone(), "demo", &[]).expect("init should succeed");
1244 fs::write(&nested_file, "hello").expect("write nested file");
1245
1246 let result = remove_playground_at(paths.clone(), "demo").expect("remove should succeed");
1247
1248 assert_eq!(result.paths, paths);
1249 assert_eq!(result.playground_id, "demo");
1250 assert_eq!(
1251 result.playground_dir,
1252 temp_dir.path().join("playgrounds").join("demo")
1253 );
1254 assert!(!result.playground_dir.exists());
1255 }
1256
1257 #[test]
1258 fn remove_errors_for_unknown_playground() {
1259 let temp_dir = TempDir::new().expect("temp dir");
1260 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1261
1262 let error =
1263 remove_playground_at(paths, "missing").expect_err("missing playground should fail");
1264
1265 assert!(error.to_string().contains("unknown playground 'missing'"));
1266 }
1267
1268 #[test]
1269 fn resolve_playground_dir_rejects_path_traversal_ids() {
1270 let temp_dir = TempDir::new().expect("temp dir");
1271 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1272
1273 let error = resolve_playground_dir_at(paths, "../demo")
1274 .expect_err("path traversal playground id should fail");
1275
1276 assert!(
1277 error
1278 .to_string()
1279 .contains("invalid playground id '../demo'")
1280 );
1281 }
1282
1283 #[test]
1284 fn init_rejects_path_traversal_ids_before_writing_files() {
1285 let temp_dir = TempDir::new().expect("temp dir");
1286 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1287
1288 let error = init_playground_at(paths, "../demo", &[])
1289 .expect_err("path traversal playground id should fail");
1290
1291 assert!(
1292 error
1293 .to_string()
1294 .contains("invalid playground id '../demo'")
1295 );
1296 assert!(!temp_dir.path().join("config.toml").exists());
1297 assert!(!temp_dir.path().join("playgrounds").exists());
1298 assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1299 }
1300
1301 #[test]
1302 fn init_cleans_up_playground_directory_when_git_init_fails() {
1303 let temp_dir = TempDir::new().expect("temp dir");
1304 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1305
1306 let error = init_playground_at_with_git(
1307 paths,
1308 "demo",
1309 &[],
1310 || Ok(true),
1311 |_| Err(io::Error::other("git init failed").into()),
1312 )
1313 .expect_err("git init failure should fail init");
1314
1315 let error_message = format!("{error:#}");
1316
1317 assert!(error_message.contains("git init failed"));
1318 assert!(error_message.contains("removed partially initialized playground"));
1319 assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1320 }
1321
1322 #[test]
1323 fn init_copies_selected_agent_templates_into_playground() {
1324 let temp_dir = TempDir::new().expect("temp dir");
1325 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1326 let selected_agents = vec!["claude".to_string(), "codex".to_string()];
1327
1328 let result =
1329 init_playground_at(paths, "demo", &selected_agents).expect("init should succeed");
1330 let playground_dir = temp_dir.path().join("playgrounds").join("demo");
1331
1332 assert_eq!(
1333 result.initialized_agent_templates,
1334 vec!["claude".to_string(), "codex".to_string()]
1335 );
1336 assert!(
1337 playground_dir
1338 .join(".claude")
1339 .join("settings.json")
1340 .is_file()
1341 );
1342 assert!(playground_dir.join(".codex").join("config.toml").is_file());
1343 assert!(!playground_dir.join(".opencode").exists());
1344 }
1345
1346 #[test]
1347 fn init_initializes_git_repo_when_git_is_available() {
1348 let temp_dir = TempDir::new().expect("temp dir");
1349 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1350 let git_init_called = Cell::new(false);
1351
1352 init_playground_at_with_git(
1353 paths,
1354 "demo",
1355 &[],
1356 || Ok(true),
1357 |playground_dir| {
1358 git_init_called.set(true);
1359 fs::create_dir(playground_dir.join(".git")).expect("create .git directory");
1360 Ok(())
1361 },
1362 )
1363 .expect("init should succeed");
1364
1365 assert!(git_init_called.get());
1366 assert!(
1367 temp_dir
1368 .path()
1369 .join("playgrounds")
1370 .join("demo")
1371 .join(".git")
1372 .is_dir()
1373 );
1374 }
1375
1376 #[test]
1377 fn init_skips_git_repo_when_git_is_unavailable() {
1378 let temp_dir = TempDir::new().expect("temp dir");
1379 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1380 let git_init_called = Cell::new(false);
1381
1382 init_playground_at_with_git(
1383 paths,
1384 "demo",
1385 &[],
1386 || Ok(false),
1387 |_| {
1388 git_init_called.set(true);
1389 Ok(())
1390 },
1391 )
1392 .expect("init should succeed");
1393
1394 assert!(!git_init_called.get());
1395 assert!(
1396 !temp_dir
1397 .path()
1398 .join("playgrounds")
1399 .join("demo")
1400 .join(".git")
1401 .exists()
1402 );
1403 }
1404
1405 #[test]
1406 fn init_deduplicates_selected_agent_templates() {
1407 let temp_dir = TempDir::new().expect("temp dir");
1408 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1409 let selected_agents = vec![
1410 "claude".to_string(),
1411 "claude".to_string(),
1412 "codex".to_string(),
1413 ];
1414
1415 let result =
1416 init_playground_at(paths, "demo", &selected_agents).expect("init should succeed");
1417
1418 assert_eq!(
1419 result.initialized_agent_templates,
1420 vec!["claude".to_string(), "codex".to_string()]
1421 );
1422 }
1423
1424 #[test]
1425 fn init_errors_for_unknown_agent_template_before_creating_playground() {
1426 let temp_dir = TempDir::new().expect("temp dir");
1427 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1428 let selected_agents = vec!["missing".to_string()];
1429
1430 let error = init_playground_at(paths, "demo", &selected_agents)
1431 .expect_err("unknown agent template should fail");
1432
1433 assert!(
1434 error
1435 .to_string()
1436 .contains("unknown agent template 'missing'")
1437 );
1438 assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1439 }
1440
1441 #[test]
1442 fn errors_when_root_config_toml_is_invalid() {
1443 let temp_dir = TempDir::new().expect("temp dir");
1444 fs::write(
1445 temp_dir.path().join("config.toml"),
1446 "[playground]\ndefault_agent = ",
1447 )
1448 .expect("write invalid root config");
1449
1450 let error =
1451 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1452 .expect_err("invalid root config should fail");
1453
1454 assert!(error.to_string().contains("failed to parse TOML"));
1455 }
1456
1457 #[test]
1458 fn errors_when_playground_config_toml_is_invalid() {
1459 let temp_dir = TempDir::new().expect("temp dir");
1460 fs::write(
1461 temp_dir.path().join("config.toml"),
1462 r#"[agent]
1463claude = "claude"
1464"#,
1465 )
1466 .expect("write root config");
1467 let playground_dir = temp_dir.path().join("playgrounds").join("broken");
1468 fs::create_dir_all(&playground_dir).expect("create playground dir");
1469 fs::write(playground_dir.join("apg.toml"), "description = ")
1470 .expect("write invalid playground config");
1471
1472 let error =
1473 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1474 .expect_err("invalid playground config should fail");
1475
1476 assert!(error.to_string().contains("failed to parse TOML"));
1477 }
1478
1479 #[test]
1480 fn errors_when_create_mode_is_invalid() {
1481 let temp_dir = TempDir::new().expect("temp dir");
1482 fs::write(
1483 temp_dir.path().join("config.toml"),
1484 r#"[playground]
1485create_mode = "clone"
1486"#,
1487 )
1488 .expect("write invalid root config");
1489
1490 let error =
1491 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1492 .expect_err("invalid create_mode should fail");
1493
1494 let message = format!("{error:#}");
1495 assert!(message.contains("create_mode"));
1496 assert!(message.contains("clone"));
1497 }
1498
1499 #[test]
1500 fn errors_when_playground_directory_uses_reserved_id() {
1501 let temp_dir = TempDir::new().expect("temp dir");
1502 fs::write(
1503 temp_dir.path().join("config.toml"),
1504 r#"[agent]
1505claude = "claude"
1506"#,
1507 )
1508 .expect("write root config");
1509 let playground_dir = temp_dir.path().join("playgrounds").join("default");
1510 fs::create_dir_all(&playground_dir).expect("create playground dir");
1511 fs::write(playground_dir.join("apg.toml"), "description = 'reserved'")
1512 .expect("write playground config");
1513
1514 let error =
1515 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1516 .expect_err("reserved playground id should fail");
1517 let message = format!("{error:#}");
1518
1519 assert!(message.contains("invalid playground directory under"));
1520 assert!(message.contains("invalid playground id 'default'"));
1521 }
1522
1523 #[test]
1524 fn ignores_non_directory_entries_in_playgrounds_dir() {
1525 let temp_dir = TempDir::new().expect("temp dir");
1526 fs::write(
1527 temp_dir.path().join("config.toml"),
1528 r#"[agent]
1529claude = "claude"
1530"#,
1531 )
1532 .expect("write root config");
1533 let playgrounds_dir = temp_dir.path().join("playgrounds");
1534 fs::create_dir_all(&playgrounds_dir).expect("create playgrounds dir");
1535 fs::write(playgrounds_dir.join("README.md"), "ignore me").expect("write file entry");
1536
1537 let config =
1538 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1539 .expect("config should load");
1540
1541 assert!(config.playgrounds.is_empty());
1542 }
1543
1544 #[test]
1545 fn configured_playgrounds_only_returns_valid_initialized_directories() {
1546 let temp_dir = TempDir::new().expect("temp dir");
1547 let playgrounds_dir = temp_dir.path().join("playgrounds");
1548 fs::create_dir_all(&playgrounds_dir).expect("create playgrounds dir");
1549
1550 let demo_dir = playgrounds_dir.join("demo");
1551 fs::create_dir_all(&demo_dir).expect("create demo");
1552 fs::write(demo_dir.join("apg.toml"), "description = 'Demo'").expect("write demo config");
1553
1554 let ops_dir = playgrounds_dir.join("ops");
1555 fs::create_dir_all(&ops_dir).expect("create ops");
1556 fs::write(ops_dir.join("apg.toml"), "description = 'Ops'").expect("write ops config");
1557
1558 fs::create_dir_all(playgrounds_dir.join("broken")).expect("create broken");
1559 fs::create_dir_all(playgrounds_dir.join("default")).expect("create reserved");
1560 fs::create_dir_all(playgrounds_dir.join("invalid")).expect("create invalid");
1561 fs::write(
1562 playgrounds_dir.join("invalid").join("apg.toml"),
1563 "description = ",
1564 )
1565 .expect("write invalid config");
1566 fs::write(playgrounds_dir.join("README.md"), "ignore me").expect("write file");
1567
1568 assert_eq!(
1569 configured_playgrounds_at(&playgrounds_dir).expect("list playgrounds"),
1570 vec![
1571 ConfiguredPlayground {
1572 id: "demo".to_string(),
1573 description: "Demo".to_string(),
1574 },
1575 ConfiguredPlayground {
1576 id: "ops".to_string(),
1577 description: "Ops".to_string(),
1578 }
1579 ]
1580 );
1581 }
1582
1583 #[test]
1584 fn user_config_dir_uses_dot_config_on_all_platforms() {
1585 let base_dir = user_config_base_dir().expect("user config base dir");
1586 let paths = ConfigPaths::from_user_config_dir().expect("user config paths");
1587
1588 assert!(base_dir.ends_with(".config"));
1589 assert_eq!(paths.root_dir, base_dir.join(APP_CONFIG_DIR));
1590 }
1591
1592 #[test]
1593 fn root_config_schema_matches_file_shape() {
1594 let schema = serde_json::to_value(RootConfigFile::json_schema()).expect("schema json");
1595
1596 assert_eq!(schema["type"], Value::String("object".to_string()));
1597 assert!(schema["properties"]["agent"].is_object());
1598 assert!(schema["properties"]["saved_playgrounds_dir"].is_object());
1599 assert!(schema["properties"]["playground"].is_object());
1600 }
1601
1602 #[test]
1603 fn playground_config_schema_matches_file_shape() {
1604 let schema =
1605 serde_json::to_value(PlaygroundConfigFile::json_schema()).expect("schema json");
1606
1607 assert_eq!(schema["type"], Value::String("object".to_string()));
1608 assert!(schema["properties"]["description"].is_object());
1609 assert!(schema["properties"]["default_agent"].is_object());
1610 assert!(schema["properties"]["load_env"].is_object());
1611 assert!(schema["properties"]["create_mode"].is_object());
1612 assert_eq!(
1613 schema["required"],
1614 Value::Array(vec![Value::String("description".to_string())])
1615 );
1616 }
1617}