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, Serialize, Deserialize, JsonSchema, Default)]
178pub struct PlaygroundConfig {
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub default_agent: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub load_env: Option<bool>,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
189pub struct RootConfigFile {
191 #[serde(default)]
193 pub agent: BTreeMap<String, String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub saved_playgrounds_dir: Option<PathBuf>,
199 #[serde(default, skip_serializing_if = "PlaygroundConfig::is_empty")]
201 pub playground: PlaygroundConfig,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
205struct ResolvedRootConfig {
206 agents: BTreeMap<String, String>,
207 saved_playgrounds_dir: PathBuf,
208 playground_defaults: PlaygroundConfig,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub(crate) struct ResolvedPlaygroundConfig {
213 pub(crate) default_agent: String,
214 pub(crate) load_env: bool,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
218pub struct PlaygroundConfigFile {
220 pub description: String,
222 #[serde(flatten)]
224 pub playground: PlaygroundConfig,
225}
226
227impl PlaygroundConfig {
228 fn builtin_defaults() -> Self {
229 Self {
230 default_agent: Some("claude".to_string()),
231 load_env: Some(false),
232 }
233 }
234
235 fn is_empty(&self) -> bool {
236 self.default_agent.is_none() && self.load_env.is_none()
237 }
238
239 fn merged_over(&self, base: &Self) -> Self {
240 Self {
241 default_agent: self
242 .default_agent
243 .clone()
244 .or_else(|| base.default_agent.clone()),
245 load_env: self.load_env.or(base.load_env),
246 }
247 }
248
249 fn resolve_over(&self, base: &Self) -> Result<ResolvedPlaygroundConfig> {
250 let merged = self.merged_over(base);
251
252 Ok(ResolvedPlaygroundConfig {
253 default_agent: merged
254 .default_agent
255 .context("default playground config is missing default_agent")?,
256 load_env: merged.load_env.unwrap_or(false),
257 })
258 }
259}
260
261impl RootConfigFile {
262 pub fn json_schema() -> Schema {
264 schema_for!(Self)
265 }
266
267 fn defaults_for_paths(paths: &ConfigPaths) -> Self {
268 let mut agent = BTreeMap::new();
269 agent.insert("claude".to_string(), "claude".to_string());
270 agent.insert("opencode".to_string(), "opencode".to_string());
271
272 Self {
273 agent,
274 saved_playgrounds_dir: Some(default_saved_playgrounds_dir(paths)),
275 playground: PlaygroundConfig::builtin_defaults(),
276 }
277 }
278
279 fn resolve(self, paths: &ConfigPaths) -> Result<ResolvedRootConfig> {
280 let defaults = Self::defaults_for_paths(paths);
281 let mut agents = defaults.agent;
282 agents.extend(self.agent);
283
284 let saved_playgrounds_dir = self
285 .saved_playgrounds_dir
286 .or(defaults.saved_playgrounds_dir)
287 .context("default root config is missing saved_playgrounds_dir")?;
288 let playground_defaults = self.playground.merged_over(&defaults.playground);
289
290 Ok(ResolvedRootConfig {
291 agents,
292 saved_playgrounds_dir,
293 playground_defaults,
294 })
295 }
296}
297
298impl PlaygroundConfigFile {
299 pub fn json_schema() -> Schema {
301 schema_for!(Self)
302 }
303
304 fn for_playground(playground_id: &str) -> Self {
305 Self {
306 description: format!("TODO: describe {playground_id}"),
307 playground: PlaygroundConfig::default(),
308 }
309 }
310}
311
312pub fn init_playground(playground_id: &str, agent_ids: &[String]) -> Result<InitResult> {
318 init_playground_at(
319 ConfigPaths::from_user_config_dir()?,
320 playground_id,
321 agent_ids,
322 )
323}
324
325fn init_playground_at(
326 paths: ConfigPaths,
327 playground_id: &str,
328 agent_ids: &[String],
329) -> Result<InitResult> {
330 init_playground_at_with_git(
331 paths,
332 playground_id,
333 agent_ids,
334 git_is_available,
335 init_git_repo,
336 )
337}
338
339fn init_playground_at_with_git<GA, GI>(
340 paths: ConfigPaths,
341 playground_id: &str,
342 agent_ids: &[String],
343 git_is_available: GA,
344 init_git_repo: GI,
345) -> Result<InitResult>
346where
347 GA: Fn() -> Result<bool>,
348 GI: Fn(&Path) -> Result<()>,
349{
350 validate_playground_id(playground_id)?;
351 let root_config_created = ensure_root_initialized(&paths)?;
352 let selected_agent_templates = select_agent_templates(agent_ids)?;
353
354 let playground_dir = paths.playgrounds_dir.join(playground_id);
355 let playground_config_file = playground_dir.join(PLAYGROUND_CONFIG_FILE_NAME);
356
357 if playground_config_file.exists() {
358 bail!(
359 "playground '{}' already exists at {}",
360 playground_id,
361 playground_config_file.display()
362 );
363 }
364
365 fs::create_dir_all(&playground_dir)
366 .with_context(|| format!("failed to create {}", playground_dir.display()))?;
367 write_toml_file(
368 &playground_config_file,
369 &PlaygroundConfigFile::for_playground(playground_id),
370 )?;
371 copy_agent_templates(&playground_dir, &selected_agent_templates)?;
372 if git_is_available()?
373 && let Err(error) = init_git_repo(&playground_dir)
374 {
375 match fs::remove_dir_all(&playground_dir) {
376 Ok(()) => {
377 return Err(error).context(format!(
378 "failed to initialize git repository in {}; removed partially initialized playground",
379 playground_dir.display()
380 ));
381 }
382 Err(cleanup_error) => {
383 return Err(error).context(format!(
384 "failed to initialize git repository in {}; additionally failed to remove partially initialized playground {}: {cleanup_error}",
385 playground_dir.display(),
386 playground_dir.display()
387 ));
388 }
389 }
390 }
391
392 Ok(InitResult {
393 paths,
394 playground_id: playground_id.to_string(),
395 root_config_created,
396 playground_config_created: true,
397 initialized_agent_templates: selected_agent_templates
398 .iter()
399 .map(|(agent_id, _)| agent_id.clone())
400 .collect(),
401 })
402}
403
404pub fn resolve_playground_dir(playground_id: &str) -> Result<PathBuf> {
406 resolve_playground_dir_at(ConfigPaths::from_user_config_dir()?, playground_id)
407}
408
409pub fn remove_playground(playground_id: &str) -> Result<RemoveResult> {
411 let paths = ConfigPaths::from_user_config_dir()?;
412 remove_playground_at(paths, playground_id)
413}
414
415fn remove_playground_at(paths: ConfigPaths, playground_id: &str) -> Result<RemoveResult> {
416 let playground_dir = resolve_playground_dir_at(paths.clone(), playground_id)?;
417
418 fs::remove_dir_all(&playground_dir)
419 .with_context(|| format!("failed to remove {}", playground_dir.display()))?;
420
421 Ok(RemoveResult {
422 paths,
423 playground_id: playground_id.to_string(),
424 playground_dir,
425 })
426}
427
428fn resolve_playground_dir_at(paths: ConfigPaths, playground_id: &str) -> Result<PathBuf> {
429 validate_playground_id(playground_id)?;
430
431 let playground_dir = paths.playgrounds_dir.join(playground_id);
432 if !playground_dir.exists() {
433 bail!("unknown playground '{playground_id}'");
434 }
435
436 let metadata = fs::symlink_metadata(&playground_dir)
437 .with_context(|| format!("failed to inspect {}", playground_dir.display()))?;
438 if metadata.file_type().is_symlink() {
439 bail!(
440 "playground '{}' cannot be removed because it is a symlink: {}",
441 playground_id,
442 playground_dir.display()
443 );
444 }
445 if !metadata.is_dir() {
446 bail!(
447 "playground '{}' is not a directory: {}",
448 playground_id,
449 playground_dir.display()
450 );
451 }
452
453 Ok(playground_dir)
454}
455
456fn validate_playground_id(playground_id: &str) -> Result<()> {
457 if playground_id.is_empty() {
458 bail!("playground id cannot be empty");
459 }
460 if playground_id == DEFAULT_SUBCOMMAND_PLAYGROUND_ID {
461 bail!(
462 "invalid playground id '{playground_id}': this name is reserved for the `default` subcommand"
463 );
464 }
465 if playground_id.starts_with("__") {
466 bail!(
467 "invalid playground id '{playground_id}': ids starting with '__' are reserved for internal use"
468 );
469 }
470 if matches!(playground_id, "." | "..")
471 || playground_id.contains('/')
472 || playground_id.contains('\\')
473 {
474 bail!(
475 "invalid playground id '{}': ids must not contain path separators or parent-directory segments",
476 playground_id
477 );
478 }
479
480 Ok(())
481}
482
483fn git_is_available() -> Result<bool> {
484 match Command::new("git")
485 .arg("--version")
486 .stdout(Stdio::null())
487 .stderr(Stdio::null())
488 .status()
489 {
490 Ok(status) => Ok(status.success()),
491 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
492 Err(error) => Err(error).context("failed to check whether git is available"),
493 }
494}
495
496fn init_git_repo(playground_dir: &Path) -> Result<()> {
497 let status = Command::new("git")
498 .arg("init")
499 .current_dir(playground_dir)
500 .stdout(Stdio::null())
501 .stderr(Stdio::null())
502 .status()
503 .with_context(|| {
504 format!(
505 "failed to initialize git repository in {}",
506 playground_dir.display()
507 )
508 })?;
509
510 if !status.success() {
511 bail!(
512 "git init exited with status {status} in {}",
513 playground_dir.display()
514 );
515 }
516
517 Ok(())
518}
519
520fn select_agent_templates(agent_ids: &[String]) -> Result<Vec<(String, &'static Dir<'static>)>> {
521 let available_templates = available_agent_templates();
522 let available_agent_ids = available_templates.keys().cloned().collect::<Vec<_>>();
523 let mut selected_templates = Vec::new();
524
525 for agent_id in agent_ids {
526 if selected_templates
527 .iter()
528 .any(|(selected_agent_id, _)| selected_agent_id == agent_id)
529 {
530 continue;
531 }
532
533 let template_dir = available_templates.get(agent_id).with_context(|| {
534 format!(
535 "unknown agent template '{agent_id}'. Available templates: {}",
536 if available_agent_ids.is_empty() {
537 "(none)".to_string()
538 } else {
539 available_agent_ids.join(", ")
540 }
541 )
542 })?;
543 selected_templates.push((agent_id.clone(), *template_dir));
544 }
545
546 Ok(selected_templates)
547}
548
549fn available_agent_templates() -> BTreeMap<String, &'static Dir<'static>> {
550 let mut agent_templates = BTreeMap::new();
551
552 for template_dir in TEMPLATE_DIR.dirs() {
553 let Some(dir_name) = template_dir
554 .path()
555 .file_name()
556 .and_then(|name| name.to_str())
557 else {
558 continue;
559 };
560 let Some(agent_id) = dir_name.strip_prefix('.') else {
561 continue;
562 };
563
564 if agent_id.is_empty() {
565 continue;
566 }
567
568 agent_templates.insert(agent_id.to_string(), template_dir);
569 }
570
571 agent_templates
572}
573
574fn copy_agent_templates(
575 playground_dir: &Path,
576 agent_templates: &[(String, &'static Dir<'static>)],
577) -> Result<()> {
578 for (agent_id, template_dir) in agent_templates {
579 copy_embedded_dir(template_dir, &playground_dir.join(format!(".{agent_id}")))?;
580 }
581
582 Ok(())
583}
584
585fn copy_embedded_dir(template_dir: &'static Dir<'static>, destination: &Path) -> Result<()> {
586 fs::create_dir_all(destination)
587 .with_context(|| format!("failed to create {}", destination.display()))?;
588
589 for nested_dir in template_dir.dirs() {
590 let nested_dir_name = nested_dir.path().file_name().with_context(|| {
591 format!(
592 "embedded template path has no name: {}",
593 nested_dir.path().display()
594 )
595 })?;
596 copy_embedded_dir(nested_dir, &destination.join(nested_dir_name))?;
597 }
598
599 for file in template_dir.files() {
600 let file_name = file.path().file_name().with_context(|| {
601 format!(
602 "embedded template file has no name: {}",
603 file.path().display()
604 )
605 })?;
606 let destination_file = destination.join(file_name);
607 fs::write(&destination_file, file.contents())
608 .with_context(|| format!("failed to write {}", destination_file.display()))?;
609 }
610
611 Ok(())
612}
613
614fn ensure_root_initialized(paths: &ConfigPaths) -> Result<bool> {
615 fs::create_dir_all(&paths.root_dir)
616 .with_context(|| format!("failed to create {}", paths.root_dir.display()))?;
617 fs::create_dir_all(&paths.playgrounds_dir)
618 .with_context(|| format!("failed to create {}", paths.playgrounds_dir.display()))?;
619
620 if paths.config_file.exists() {
621 return Ok(false);
622 }
623
624 write_toml_file(
625 &paths.config_file,
626 &RootConfigFile::defaults_for_paths(paths),
627 )?;
628
629 Ok(true)
630}
631
632fn load_root_config(paths: &ConfigPaths) -> Result<ResolvedRootConfig> {
633 read_toml_file::<RootConfigFile>(&paths.config_file)?.resolve(paths)
634}
635
636fn default_saved_playgrounds_dir(_paths: &ConfigPaths) -> PathBuf {
637 PathBuf::from(DEFAULT_SAVED_PLAYGROUNDS_DIR_NAME)
638}
639
640fn resolve_saved_playgrounds_dir(root_dir: &Path, configured_path: PathBuf) -> PathBuf {
641 if configured_path.is_absolute() {
642 return configured_path;
643 }
644
645 root_dir.join(configured_path)
646}
647
648fn validate_default_agent_defined(
649 agents: &BTreeMap<String, String>,
650 default_agent: Option<&str>,
651 label: &str,
652) -> Result<()> {
653 let Some(default_agent) = default_agent else {
654 bail!("{label} is missing");
655 };
656
657 if !agents.contains_key(default_agent) {
658 bail!("{label} '{default_agent}' is not defined in [agent]");
659 }
660
661 Ok(())
662}
663
664fn load_playgrounds(
665 playgrounds_dir: &Path,
666 agents: &BTreeMap<String, String>,
667 playground_defaults: &PlaygroundConfig,
668) -> Result<BTreeMap<String, PlaygroundDefinition>> {
669 if !playgrounds_dir.exists() {
670 return Ok(BTreeMap::new());
671 }
672
673 if !playgrounds_dir.is_dir() {
674 bail!(
675 "playground config path is not a directory: {}",
676 playgrounds_dir.display()
677 );
678 }
679
680 let mut playgrounds = BTreeMap::new();
681
682 for entry in fs::read_dir(playgrounds_dir)
683 .with_context(|| format!("failed to read {}", playgrounds_dir.display()))?
684 {
685 let entry = entry.with_context(|| {
686 format!(
687 "failed to inspect an entry under {}",
688 playgrounds_dir.display()
689 )
690 })?;
691 let file_type = entry.file_type().with_context(|| {
692 format!("failed to inspect file type for {}", entry.path().display())
693 })?;
694
695 if !file_type.is_dir() {
696 continue;
697 }
698
699 let directory = entry.path();
700 let config_file = directory.join(PLAYGROUND_CONFIG_FILE_NAME);
701
702 if !config_file.is_file() {
703 bail!(
704 "playground '{}' is missing {}",
705 directory.file_name().unwrap_or_default().to_string_lossy(),
706 PLAYGROUND_CONFIG_FILE_NAME
707 );
708 }
709
710 let playground_config: PlaygroundConfigFile = read_toml_file(&config_file)?;
711 let id = entry.file_name().to_string_lossy().into_owned();
712 validate_playground_id(&id).with_context(|| {
713 format!(
714 "invalid playground directory under {}",
715 playgrounds_dir.display()
716 )
717 })?;
718 let effective_config = playground_config
719 .playground
720 .merged_over(playground_defaults);
721 validate_default_agent_defined(
722 agents,
723 effective_config.default_agent.as_deref(),
724 &format!("playground '{id}' default agent"),
725 )?;
726
727 playgrounds.insert(
728 id.clone(),
729 PlaygroundDefinition {
730 id,
731 description: playground_config.description,
732 directory,
733 config_file,
734 playground: playground_config.playground,
735 },
736 );
737 }
738
739 Ok(playgrounds)
740}
741
742fn read_toml_file<T>(path: &Path) -> Result<T>
743where
744 T: for<'de> Deserialize<'de>,
745{
746 let content =
747 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
748
749 toml::from_str(&content)
750 .with_context(|| format!("failed to parse TOML from {}", path.display()))
751}
752
753fn write_toml_file<T>(path: &Path, value: &T) -> Result<()>
754where
755 T: Serialize,
756{
757 let content =
758 toml::to_string_pretty(value).context("failed to serialize configuration to TOML")?;
759 fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
760}
761
762#[cfg(test)]
763mod tests {
764 use super::{
765 APP_CONFIG_DIR, AppConfig, ConfigPaths, PlaygroundConfigFile, RootConfigFile,
766 init_playground_at, init_playground_at_with_git, read_toml_file, remove_playground_at,
767 resolve_playground_dir_at, user_config_base_dir,
768 };
769 use serde_json::Value;
770 use std::{cell::Cell, fs, io};
771 use tempfile::TempDir;
772
773 #[test]
774 fn init_creates_root_and_playground_configs_from_file_models() {
775 let temp_dir = TempDir::new().expect("temp dir");
776 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
777
778 let result = init_playground_at(paths.clone(), "demo", &[]).expect("init should succeed");
779
780 assert!(result.root_config_created);
781 assert!(result.playground_config_created);
782 assert!(result.initialized_agent_templates.is_empty());
783 assert!(temp_dir.path().join("config.toml").is_file());
784 assert!(
785 temp_dir
786 .path()
787 .join("playgrounds")
788 .join("demo")
789 .join("apg.toml")
790 .is_file()
791 );
792 assert!(
793 !temp_dir
794 .path()
795 .join("playgrounds")
796 .join("demo")
797 .join(".claude")
798 .exists()
799 );
800 assert_eq!(
801 read_toml_file::<RootConfigFile>(&temp_dir.path().join("config.toml"))
802 .expect("root config"),
803 RootConfigFile::defaults_for_paths(&paths)
804 );
805 assert_eq!(
806 read_toml_file::<PlaygroundConfigFile>(
807 &temp_dir
808 .path()
809 .join("playgrounds")
810 .join("demo")
811 .join("apg.toml")
812 )
813 .expect("playground config"),
814 PlaygroundConfigFile::for_playground("demo")
815 );
816
817 let config = AppConfig::load_from_paths(paths).expect("config should load");
818 assert_eq!(config.agents.get("claude"), Some(&"claude".to_string()));
819 assert_eq!(config.agents.get("opencode"), Some(&"opencode".to_string()));
820 assert_eq!(
821 config.playground_defaults.default_agent.as_deref(),
822 Some("claude")
823 );
824 assert_eq!(config.playground_defaults.load_env, Some(false));
825 assert_eq!(
826 config.saved_playgrounds_dir,
827 temp_dir.path().join("saved-playgrounds")
828 );
829 assert_eq!(
830 config
831 .playgrounds
832 .get("demo")
833 .expect("demo playground")
834 .description,
835 "TODO: describe demo"
836 );
837 assert!(
838 config
839 .playgrounds
840 .get("demo")
841 .expect("demo playground")
842 .playground
843 .is_empty()
844 );
845 }
846
847 #[test]
848 fn merges_root_agents_and_loads_playgrounds() {
849 let temp_dir = TempDir::new().expect("temp dir");
850 let root = temp_dir.path();
851 fs::write(
852 root.join("config.toml"),
853 r#"saved_playgrounds_dir = "archives"
854
855[agent]
856claude = "custom-claude"
857codex = "codex --fast"
858
859[playground]
860default_agent = "codex"
861load_env = true
862"#,
863 )
864 .expect("write root config");
865
866 let playground_dir = root.join("playgrounds").join("demo");
867 fs::create_dir_all(&playground_dir).expect("create playground dir");
868 fs::write(
869 playground_dir.join("apg.toml"),
870 r#"description = "Demo playground"
871default_agent = "claude""#,
872 )
873 .expect("write playground config");
874
875 let config = AppConfig::load_from_paths(ConfigPaths::from_root_dir(root.to_path_buf()))
876 .expect("config should load");
877
878 assert_eq!(
879 config.agents.get("claude"),
880 Some(&"custom-claude".to_string())
881 );
882 assert_eq!(config.agents.get("opencode"), Some(&"opencode".to_string()));
883 assert_eq!(
884 config.agents.get("codex"),
885 Some(&"codex --fast".to_string())
886 );
887 assert_eq!(
888 config.playground_defaults.default_agent.as_deref(),
889 Some("codex")
890 );
891 assert_eq!(config.playground_defaults.load_env, Some(true));
892 assert_eq!(config.saved_playgrounds_dir, root.join("archives"));
893
894 let playground = config.playgrounds.get("demo").expect("demo playground");
895 assert_eq!(playground.description, "Demo playground");
896 assert_eq!(
897 playground.playground.default_agent.as_deref(),
898 Some("claude")
899 );
900 assert_eq!(playground.directory, playground_dir);
901 let effective_config = config
902 .resolve_playground_config(playground)
903 .expect("effective playground config");
904 assert_eq!(effective_config.default_agent, "claude");
905 assert!(effective_config.load_env);
906 }
907
908 #[test]
909 fn errors_when_playground_default_agent_is_not_defined() {
910 let temp_dir = TempDir::new().expect("temp dir");
911 fs::write(
912 temp_dir.path().join("config.toml"),
913 r#"[agent]
914claude = "claude"
915"#,
916 )
917 .expect("write root config");
918 let playground_dir = temp_dir.path().join("playgrounds").join("demo");
919 fs::create_dir_all(&playground_dir).expect("create playground dir");
920 fs::write(
921 playground_dir.join("apg.toml"),
922 r#"description = "Demo playground"
923default_agent = "codex""#,
924 )
925 .expect("write playground config");
926
927 let error =
928 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
929 .expect_err("undefined playground default agent should fail");
930
931 assert!(
932 error
933 .to_string()
934 .contains("playground 'demo' default agent 'codex' is not defined")
935 );
936 }
937
938 #[test]
939 fn load_auto_initializes_missing_root_config() {
940 let temp_dir = TempDir::new().expect("temp dir");
941 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
942
943 let config = AppConfig::load_from_paths(paths).expect("missing root config should init");
944
945 assert!(temp_dir.path().join("config.toml").is_file());
946 assert!(temp_dir.path().join("playgrounds").is_dir());
947 assert_eq!(config.agents.get("claude"), Some(&"claude".to_string()));
948 assert_eq!(
949 config.playground_defaults.default_agent.as_deref(),
950 Some("claude")
951 );
952 assert_eq!(config.playground_defaults.load_env, Some(false));
953 assert_eq!(
954 config.saved_playgrounds_dir,
955 temp_dir.path().join("saved-playgrounds")
956 );
957 }
958
959 #[test]
960 fn respects_absolute_saved_playgrounds_dir() {
961 let temp_dir = TempDir::new().expect("temp dir");
962 let archive_dir = TempDir::new().expect("archive dir");
963 let archive_path = archive_dir
964 .path()
965 .display()
966 .to_string()
967 .replace('\\', "\\\\");
968 fs::write(
969 temp_dir.path().join("config.toml"),
970 format!(
971 r#"saved_playgrounds_dir = "{}"
972
973[agent]
974claude = "claude"
975"#,
976 archive_path
977 ),
978 )
979 .expect("write root config");
980
981 let config =
982 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
983 .expect("config should load");
984
985 assert_eq!(config.saved_playgrounds_dir, archive_dir.path());
986 }
987
988 #[test]
989 fn errors_when_playground_config_is_missing() {
990 let temp_dir = TempDir::new().expect("temp dir");
991 fs::write(
992 temp_dir.path().join("config.toml"),
993 r#"[agent]
994claude = "claude"
995opencode = "opencode"
996"#,
997 )
998 .expect("write root config");
999 let playground_dir = temp_dir.path().join("playgrounds").join("broken");
1000 fs::create_dir_all(&playground_dir).expect("create playground dir");
1001
1002 let error =
1003 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1004 .expect_err("missing playground config should fail");
1005
1006 assert!(error.to_string().contains("missing apg.toml"));
1007 }
1008
1009 #[test]
1010 fn errors_when_default_agent_is_not_defined() {
1011 let temp_dir = TempDir::new().expect("temp dir");
1012 fs::write(
1013 temp_dir.path().join("config.toml"),
1014 r#"[playground]
1015default_agent = "codex""#,
1016 )
1017 .expect("write root config");
1018
1019 let error =
1020 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1021 .expect_err("undefined default agent should fail");
1022
1023 assert!(
1024 error
1025 .to_string()
1026 .contains("default agent 'codex' is not defined")
1027 );
1028 }
1029
1030 #[test]
1031 fn init_errors_when_playground_already_exists() {
1032 let temp_dir = TempDir::new().expect("temp dir");
1033 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1034
1035 init_playground_at(paths.clone(), "demo", &[]).expect("initial init should succeed");
1036 let error = init_playground_at(paths, "demo", &[]).expect_err("duplicate init should fail");
1037
1038 assert!(
1039 error
1040 .to_string()
1041 .contains("playground 'demo' already exists")
1042 );
1043 }
1044
1045 #[test]
1046 fn init_rejects_reserved_default_playground_id() {
1047 let temp_dir = TempDir::new().expect("temp dir");
1048 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1049
1050 let error = init_playground_at(paths, "default", &[]).expect_err("reserved id should fail");
1051
1052 assert!(
1053 error
1054 .to_string()
1055 .contains("invalid playground id 'default'")
1056 );
1057 assert!(
1058 error
1059 .to_string()
1060 .contains("reserved for the `default` subcommand")
1061 );
1062 }
1063
1064 #[test]
1065 fn init_rejects_internal_reserved_playground_id_prefix() {
1066 let temp_dir = TempDir::new().expect("temp dir");
1067 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1068
1069 let error =
1070 init_playground_at(paths, "__default__", &[]).expect_err("reserved id should fail");
1071
1072 assert!(
1073 error
1074 .to_string()
1075 .contains("ids starting with '__' are reserved for internal use")
1076 );
1077 }
1078
1079 #[test]
1080 fn remove_deletes_existing_playground_directory() {
1081 let temp_dir = TempDir::new().expect("temp dir");
1082 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1083 let nested_file = temp_dir
1084 .path()
1085 .join("playgrounds")
1086 .join("demo")
1087 .join("notes.txt");
1088
1089 init_playground_at(paths.clone(), "demo", &[]).expect("init should succeed");
1090 fs::write(&nested_file, "hello").expect("write nested file");
1091
1092 let result = remove_playground_at(paths.clone(), "demo").expect("remove should succeed");
1093
1094 assert_eq!(result.paths, paths);
1095 assert_eq!(result.playground_id, "demo");
1096 assert_eq!(
1097 result.playground_dir,
1098 temp_dir.path().join("playgrounds").join("demo")
1099 );
1100 assert!(!result.playground_dir.exists());
1101 }
1102
1103 #[test]
1104 fn remove_errors_for_unknown_playground() {
1105 let temp_dir = TempDir::new().expect("temp dir");
1106 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1107
1108 let error =
1109 remove_playground_at(paths, "missing").expect_err("missing playground should fail");
1110
1111 assert!(error.to_string().contains("unknown playground 'missing'"));
1112 }
1113
1114 #[test]
1115 fn resolve_playground_dir_rejects_path_traversal_ids() {
1116 let temp_dir = TempDir::new().expect("temp dir");
1117 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1118
1119 let error = resolve_playground_dir_at(paths, "../demo")
1120 .expect_err("path traversal playground id should fail");
1121
1122 assert!(
1123 error
1124 .to_string()
1125 .contains("invalid playground id '../demo'")
1126 );
1127 }
1128
1129 #[test]
1130 fn init_rejects_path_traversal_ids_before_writing_files() {
1131 let temp_dir = TempDir::new().expect("temp dir");
1132 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1133
1134 let error = init_playground_at(paths, "../demo", &[])
1135 .expect_err("path traversal playground id should fail");
1136
1137 assert!(
1138 error
1139 .to_string()
1140 .contains("invalid playground id '../demo'")
1141 );
1142 assert!(!temp_dir.path().join("config.toml").exists());
1143 assert!(!temp_dir.path().join("playgrounds").exists());
1144 assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1145 }
1146
1147 #[test]
1148 fn init_cleans_up_playground_directory_when_git_init_fails() {
1149 let temp_dir = TempDir::new().expect("temp dir");
1150 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1151
1152 let error = init_playground_at_with_git(
1153 paths,
1154 "demo",
1155 &[],
1156 || Ok(true),
1157 |_| Err(io::Error::other("git init failed").into()),
1158 )
1159 .expect_err("git init failure should fail init");
1160
1161 let error_message = format!("{error:#}");
1162
1163 assert!(error_message.contains("git init failed"));
1164 assert!(error_message.contains("removed partially initialized playground"));
1165 assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1166 }
1167
1168 #[test]
1169 fn init_copies_selected_agent_templates_into_playground() {
1170 let temp_dir = TempDir::new().expect("temp dir");
1171 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1172 let selected_agents = vec!["claude".to_string(), "codex".to_string()];
1173
1174 let result =
1175 init_playground_at(paths, "demo", &selected_agents).expect("init should succeed");
1176 let playground_dir = temp_dir.path().join("playgrounds").join("demo");
1177
1178 assert_eq!(
1179 result.initialized_agent_templates,
1180 vec!["claude".to_string(), "codex".to_string()]
1181 );
1182 assert!(
1183 playground_dir
1184 .join(".claude")
1185 .join("settings.json")
1186 .is_file()
1187 );
1188 assert!(playground_dir.join(".codex").join("config.toml").is_file());
1189 assert!(!playground_dir.join(".opencode").exists());
1190 }
1191
1192 #[test]
1193 fn init_initializes_git_repo_when_git_is_available() {
1194 let temp_dir = TempDir::new().expect("temp dir");
1195 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1196 let git_init_called = Cell::new(false);
1197
1198 init_playground_at_with_git(
1199 paths,
1200 "demo",
1201 &[],
1202 || Ok(true),
1203 |playground_dir| {
1204 git_init_called.set(true);
1205 fs::create_dir(playground_dir.join(".git")).expect("create .git directory");
1206 Ok(())
1207 },
1208 )
1209 .expect("init should succeed");
1210
1211 assert!(git_init_called.get());
1212 assert!(
1213 temp_dir
1214 .path()
1215 .join("playgrounds")
1216 .join("demo")
1217 .join(".git")
1218 .is_dir()
1219 );
1220 }
1221
1222 #[test]
1223 fn init_skips_git_repo_when_git_is_unavailable() {
1224 let temp_dir = TempDir::new().expect("temp dir");
1225 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1226 let git_init_called = Cell::new(false);
1227
1228 init_playground_at_with_git(
1229 paths,
1230 "demo",
1231 &[],
1232 || Ok(false),
1233 |_| {
1234 git_init_called.set(true);
1235 Ok(())
1236 },
1237 )
1238 .expect("init should succeed");
1239
1240 assert!(!git_init_called.get());
1241 assert!(
1242 !temp_dir
1243 .path()
1244 .join("playgrounds")
1245 .join("demo")
1246 .join(".git")
1247 .exists()
1248 );
1249 }
1250
1251 #[test]
1252 fn init_deduplicates_selected_agent_templates() {
1253 let temp_dir = TempDir::new().expect("temp dir");
1254 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1255 let selected_agents = vec![
1256 "claude".to_string(),
1257 "claude".to_string(),
1258 "codex".to_string(),
1259 ];
1260
1261 let result =
1262 init_playground_at(paths, "demo", &selected_agents).expect("init should succeed");
1263
1264 assert_eq!(
1265 result.initialized_agent_templates,
1266 vec!["claude".to_string(), "codex".to_string()]
1267 );
1268 }
1269
1270 #[test]
1271 fn init_errors_for_unknown_agent_template_before_creating_playground() {
1272 let temp_dir = TempDir::new().expect("temp dir");
1273 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1274 let selected_agents = vec!["missing".to_string()];
1275
1276 let error = init_playground_at(paths, "demo", &selected_agents)
1277 .expect_err("unknown agent template should fail");
1278
1279 assert!(
1280 error
1281 .to_string()
1282 .contains("unknown agent template 'missing'")
1283 );
1284 assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1285 }
1286
1287 #[test]
1288 fn errors_when_root_config_toml_is_invalid() {
1289 let temp_dir = TempDir::new().expect("temp dir");
1290 fs::write(
1291 temp_dir.path().join("config.toml"),
1292 "[playground]\ndefault_agent = ",
1293 )
1294 .expect("write invalid root config");
1295
1296 let error =
1297 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1298 .expect_err("invalid root config should fail");
1299
1300 assert!(error.to_string().contains("failed to parse TOML"));
1301 }
1302
1303 #[test]
1304 fn errors_when_playground_config_toml_is_invalid() {
1305 let temp_dir = TempDir::new().expect("temp dir");
1306 fs::write(
1307 temp_dir.path().join("config.toml"),
1308 r#"[agent]
1309claude = "claude"
1310"#,
1311 )
1312 .expect("write root config");
1313 let playground_dir = temp_dir.path().join("playgrounds").join("broken");
1314 fs::create_dir_all(&playground_dir).expect("create playground dir");
1315 fs::write(playground_dir.join("apg.toml"), "description = ")
1316 .expect("write invalid playground config");
1317
1318 let error =
1319 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1320 .expect_err("invalid playground config should fail");
1321
1322 assert!(error.to_string().contains("failed to parse TOML"));
1323 }
1324
1325 #[test]
1326 fn errors_when_playground_directory_uses_reserved_id() {
1327 let temp_dir = TempDir::new().expect("temp dir");
1328 fs::write(
1329 temp_dir.path().join("config.toml"),
1330 r#"[agent]
1331claude = "claude"
1332"#,
1333 )
1334 .expect("write root config");
1335 let playground_dir = temp_dir.path().join("playgrounds").join("default");
1336 fs::create_dir_all(&playground_dir).expect("create playground dir");
1337 fs::write(playground_dir.join("apg.toml"), "description = 'reserved'")
1338 .expect("write playground config");
1339
1340 let error =
1341 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1342 .expect_err("reserved playground id should fail");
1343 let message = format!("{error:#}");
1344
1345 assert!(message.contains("invalid playground directory under"));
1346 assert!(message.contains("invalid playground id 'default'"));
1347 }
1348
1349 #[test]
1350 fn ignores_non_directory_entries_in_playgrounds_dir() {
1351 let temp_dir = TempDir::new().expect("temp dir");
1352 fs::write(
1353 temp_dir.path().join("config.toml"),
1354 r#"[agent]
1355claude = "claude"
1356"#,
1357 )
1358 .expect("write root config");
1359 let playgrounds_dir = temp_dir.path().join("playgrounds");
1360 fs::create_dir_all(&playgrounds_dir).expect("create playgrounds dir");
1361 fs::write(playgrounds_dir.join("README.md"), "ignore me").expect("write file entry");
1362
1363 let config =
1364 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1365 .expect("config should load");
1366
1367 assert!(config.playgrounds.is_empty());
1368 }
1369
1370 #[test]
1371 fn user_config_dir_uses_dot_config_on_all_platforms() {
1372 let base_dir = user_config_base_dir().expect("user config base dir");
1373 let paths = ConfigPaths::from_user_config_dir().expect("user config paths");
1374
1375 assert!(base_dir.ends_with(".config"));
1376 assert_eq!(paths.root_dir, base_dir.join(APP_CONFIG_DIR));
1377 }
1378
1379 #[test]
1380 fn root_config_schema_matches_file_shape() {
1381 let schema = serde_json::to_value(RootConfigFile::json_schema()).expect("schema json");
1382
1383 assert_eq!(schema["type"], Value::String("object".to_string()));
1384 assert!(schema["properties"]["agent"].is_object());
1385 assert!(schema["properties"]["saved_playgrounds_dir"].is_object());
1386 assert!(schema["properties"]["playground"].is_object());
1387 }
1388
1389 #[test]
1390 fn playground_config_schema_matches_file_shape() {
1391 let schema =
1392 serde_json::to_value(PlaygroundConfigFile::json_schema()).expect("schema json");
1393
1394 assert_eq!(schema["type"], Value::String("object".to_string()));
1395 assert!(schema["properties"]["description"].is_object());
1396 assert!(schema["properties"]["default_agent"].is_object());
1397 assert!(schema["properties"]["load_env"].is_object());
1398 assert_eq!(
1399 schema["required"],
1400 Value::Array(vec![Value::String("description".to_string())])
1401 );
1402 }
1403}