1use std::{
13 collections::BTreeMap,
14 fs, io,
15 path::{Component, Path, PathBuf},
16 process::{Command, Stdio},
17};
18
19use anyhow::{Context, Result, bail};
20use schemars::{JsonSchema, Schema, schema_for};
21use serde::{Deserialize, Serialize};
22
23use crate::utils::symlink::copy_symlink;
24
25const APP_CONFIG_DIR: &str = "agent-playground";
26const ROOT_CONFIG_FILE_NAME: &str = "config.toml";
27const PLAYGROUND_CONFIG_FILE_NAME: &str = "apg.toml";
28const PLAYGROUNDS_DIR_NAME: &str = "playgrounds";
29const AGENTS_DIR_NAME: &str = "agents";
30const DEFAULT_SUBCOMMAND_PLAYGROUND_ID: &str = "default";
31const DEFAULT_SAVED_PLAYGROUNDS_DIR_NAME: &str = "saved-playgrounds";
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ConfigPaths {
36 pub root_dir: PathBuf,
40 pub config_file: PathBuf,
42 pub playgrounds_dir: PathBuf,
44 pub agents_dir: PathBuf,
46}
47
48impl ConfigPaths {
49 pub fn from_user_config_dir() -> Result<Self> {
53 let config_dir = user_config_base_dir()?;
54
55 Ok(Self::from_root_dir(config_dir.join(APP_CONFIG_DIR)))
56 }
57
58 pub fn from_root_dir(root_dir: PathBuf) -> Self {
60 Self {
61 config_file: root_dir.join(ROOT_CONFIG_FILE_NAME),
62 playgrounds_dir: root_dir.join(PLAYGROUNDS_DIR_NAME),
63 agents_dir: root_dir.join(AGENTS_DIR_NAME),
64 root_dir,
65 }
66 }
67}
68
69fn user_config_base_dir() -> Result<PathBuf> {
70 let home_dir = dirs::home_dir().context("failed to locate the user's home directory")?;
71 Ok(home_dir.join(".config"))
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct AppConfig {
80 pub paths: ConfigPaths,
82 pub agents: BTreeMap<String, ResolvedAgentConfig>,
84 pub default_playground: Option<String>,
86 pub saved_playgrounds_dir: PathBuf,
88 pub playground_defaults: PlaygroundConfig,
90 pub playgrounds: BTreeMap<String, PlaygroundDefinition>,
92}
93
94impl AppConfig {
95 pub fn load() -> Result<Self> {
100 Self::load_from_paths(ConfigPaths::from_user_config_dir()?)
101 }
102
103 fn load_from_paths(paths: ConfigPaths) -> Result<Self> {
104 ensure_root_initialized(&paths)?;
105 let resolved_root_config = load_root_config(&paths)?;
106 let agents = resolved_root_config.agents;
107 let default_playground = resolved_root_config.default_playground;
108 let saved_playgrounds_dir = resolve_saved_playgrounds_dir(
109 &paths.root_dir,
110 resolved_root_config.saved_playgrounds_dir,
111 );
112 let playground_defaults = resolved_root_config.playground_defaults;
113
114 validate_default_agent_defined(
115 &agents,
116 playground_defaults.default_agent.as_deref(),
117 "default agent",
118 )?;
119
120 let playgrounds = load_playgrounds(&paths.playgrounds_dir, &agents, &playground_defaults)?;
121 validate_default_playground(&playgrounds, default_playground.as_deref())?;
122
123 Ok(Self {
124 paths,
125 agents,
126 default_playground,
127 saved_playgrounds_dir,
128 playground_defaults,
129 playgrounds,
130 })
131 }
132
133 pub(crate) fn resolve_playground_config(
136 &self,
137 playground: &PlaygroundDefinition,
138 ) -> Result<ResolvedPlaygroundConfig> {
139 playground
140 .playground
141 .resolve_over(&self.playground_defaults)
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct InitResult {
148 pub paths: ConfigPaths,
150 pub playground_id: String,
152 pub root_config_created: bool,
154 pub playground_config_created: bool,
156 pub initialized_agent_configs: Vec<String>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct RemoveResult {
163 pub paths: ConfigPaths,
165 pub playground_id: String,
167 pub playground_dir: PathBuf,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct PlaygroundDefinition {
174 pub id: String,
176 pub description: String,
178 pub directory: PathBuf,
180 pub config_file: PathBuf,
182 pub playground: PlaygroundConfig,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct ConfiguredPlayground {
189 pub id: String,
191 pub description: String,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
196#[serde(rename_all = "lowercase")]
197pub enum CreateMode {
199 #[default]
201 Copy,
202 Symlink,
204 Hardlink,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
209pub struct PlaygroundConfig {
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub default_agent: Option<String>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub load_env: Option<bool>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub create_mode: Option<CreateMode>,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
223pub struct AgentConfigFile {
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub cmd: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub config_dir: Option<PathBuf>,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
234pub struct RootConfigFile {
236 #[serde(default)]
238 pub agent: BTreeMap<String, AgentConfigFile>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub default_playground: Option<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub saved_playgrounds_dir: Option<PathBuf>,
247 #[serde(default, skip_serializing_if = "PlaygroundConfig::is_empty")]
249 pub playground: PlaygroundConfig,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
253struct ResolvedRootConfig {
254 agents: BTreeMap<String, ResolvedAgentConfig>,
255 default_playground: Option<String>,
256 saved_playgrounds_dir: PathBuf,
257 playground_defaults: PlaygroundConfig,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct ResolvedAgentConfig {
263 pub cmd: String,
265 pub config_dir: PathBuf,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub(crate) struct ResolvedPlaygroundConfig {
271 pub(crate) default_agent: String,
272 pub(crate) load_env: bool,
273 pub(crate) create_mode: CreateMode,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
277pub struct PlaygroundConfigFile {
279 pub description: String,
281 #[serde(flatten)]
283 pub playground: PlaygroundConfig,
284}
285
286impl PlaygroundConfig {
287 fn builtin_defaults() -> Self {
288 Self {
289 default_agent: Some("claude".to_string()),
290 load_env: Some(false),
291 create_mode: Some(CreateMode::Copy),
292 }
293 }
294
295 fn is_empty(&self) -> bool {
296 self.default_agent.is_none() && self.load_env.is_none() && self.create_mode.is_none()
297 }
298
299 fn merged_over(&self, base: &Self) -> Self {
300 Self {
301 default_agent: self
302 .default_agent
303 .clone()
304 .or_else(|| base.default_agent.clone()),
305 load_env: self.load_env.or(base.load_env),
306 create_mode: self.create_mode.or(base.create_mode),
307 }
308 }
309
310 fn resolve_over(&self, base: &Self) -> Result<ResolvedPlaygroundConfig> {
311 let merged = self.merged_over(base);
312
313 Ok(ResolvedPlaygroundConfig {
314 default_agent: merged
315 .default_agent
316 .context("default playground config is missing default_agent")?,
317 load_env: merged.load_env.unwrap_or(false),
318 create_mode: merged.create_mode.unwrap_or(CreateMode::Copy),
319 })
320 }
321}
322
323impl AgentConfigFile {
324 fn merged_over(&self, base: &Self) -> Self {
325 Self {
326 cmd: self.cmd.clone().or_else(|| base.cmd.clone()),
327 config_dir: self.config_dir.clone().or_else(|| base.config_dir.clone()),
328 }
329 }
330
331 fn resolve(&self, agent_id: &str) -> Result<ResolvedAgentConfig> {
332 let cmd = self.cmd.clone().unwrap_or_else(|| agent_id.to_string());
333 let config_dir = self
334 .config_dir
335 .clone()
336 .unwrap_or_else(|| PathBuf::from(format!(".{agent_id}/")));
337 let config_dir = normalize_agent_config_dir(agent_id, &config_dir)?;
338
339 Ok(ResolvedAgentConfig { cmd, config_dir })
340 }
341}
342
343impl RootConfigFile {
344 pub fn json_schema() -> Schema {
346 schema_for!(Self)
347 }
348
349 fn defaults_for_paths(paths: &ConfigPaths) -> Self {
350 let mut agent = BTreeMap::new();
351 agent.insert(
352 "claude".to_string(),
353 AgentConfigFile {
354 cmd: Some("claude".to_string()),
355 config_dir: Some(PathBuf::from(".claude/")),
356 },
357 );
358 agent.insert(
359 "opencode".to_string(),
360 AgentConfigFile {
361 cmd: Some("opencode".to_string()),
362 config_dir: Some(PathBuf::from(".opencode/")),
363 },
364 );
365
366 Self {
367 agent,
368 default_playground: None,
369 saved_playgrounds_dir: Some(default_saved_playgrounds_dir(paths)),
370 playground: PlaygroundConfig::builtin_defaults(),
371 }
372 }
373
374 fn resolve(self, paths: &ConfigPaths) -> Result<ResolvedRootConfig> {
375 let defaults = Self::defaults_for_paths(paths);
376 let mut merged_agents = defaults.agent;
377 for (agent_id, agent_config) in self.agent {
378 if let Some(default_agent_config) = merged_agents.get(&agent_id) {
379 merged_agents.insert(agent_id, agent_config.merged_over(default_agent_config));
380 } else {
381 merged_agents.insert(agent_id, agent_config);
382 }
383 }
384 let mut agents = BTreeMap::new();
385 for (agent_id, agent_config) in merged_agents {
386 validate_agent_id(&agent_id)
387 .with_context(|| format!("invalid agent id in root config: '{agent_id}'"))?;
388 agents.insert(agent_id.clone(), agent_config.resolve(&agent_id)?);
389 }
390 let default_playground = self.default_playground;
391
392 let saved_playgrounds_dir = self
393 .saved_playgrounds_dir
394 .or(defaults.saved_playgrounds_dir)
395 .context("default root config is missing saved_playgrounds_dir")?;
396 let playground_defaults = self.playground.merged_over(&defaults.playground);
397
398 Ok(ResolvedRootConfig {
399 agents,
400 default_playground,
401 saved_playgrounds_dir,
402 playground_defaults,
403 })
404 }
405}
406
407impl PlaygroundConfigFile {
408 pub fn json_schema() -> Schema {
410 schema_for!(Self)
411 }
412
413 fn for_playground(playground_id: &str) -> Self {
414 Self {
415 description: format!("TODO: describe {playground_id}"),
416 playground: PlaygroundConfig::default(),
417 }
418 }
419}
420
421pub fn init_playground(playground_id: &str, agent_ids: &[String]) -> Result<InitResult> {
427 init_playground_at(
428 ConfigPaths::from_user_config_dir()?,
429 playground_id,
430 agent_ids,
431 )
432}
433
434fn init_playground_at(
435 paths: ConfigPaths,
436 playground_id: &str,
437 agent_ids: &[String],
438) -> Result<InitResult> {
439 init_playground_at_with_git(
440 paths,
441 playground_id,
442 agent_ids,
443 git_is_available,
444 init_git_repo,
445 )
446}
447
448fn init_playground_at_with_git<GA, GI>(
449 paths: ConfigPaths,
450 playground_id: &str,
451 agent_ids: &[String],
452 git_is_available: GA,
453 init_git_repo: GI,
454) -> Result<InitResult>
455where
456 GA: Fn() -> Result<bool>,
457 GI: Fn(&Path) -> Result<()>,
458{
459 validate_playground_id(playground_id)?;
460 let root_config_created = ensure_root_initialized(&paths)?;
461 let root_config = load_root_config(&paths)?;
462 let selected_agent_configs = select_agent_configs(&paths, &root_config.agents, agent_ids)?;
463
464 let playground_dir = paths.playgrounds_dir.join(playground_id);
465 let playground_config_file = playground_dir.join(PLAYGROUND_CONFIG_FILE_NAME);
466
467 if playground_config_file.exists() {
468 bail!(
469 "playground '{}' already exists at {}",
470 playground_id,
471 playground_config_file.display()
472 );
473 }
474
475 fs::create_dir_all(&playground_dir)
476 .with_context(|| format!("failed to create {}", playground_dir.display()))?;
477 write_toml_file(
478 &playground_config_file,
479 &PlaygroundConfigFile::for_playground(playground_id),
480 )?;
481 copy_agent_configs(&playground_dir, &selected_agent_configs)?;
482 if git_is_available()?
483 && let Err(error) = init_git_repo(&playground_dir)
484 {
485 match fs::remove_dir_all(&playground_dir) {
486 Ok(()) => {
487 return Err(error).context(format!(
488 "failed to initialize git repository in {}; removed partially initialized playground",
489 playground_dir.display()
490 ));
491 }
492 Err(cleanup_error) => {
493 return Err(error).context(format!(
494 "failed to initialize git repository in {}; additionally failed to remove partially initialized playground {}: {cleanup_error}",
495 playground_dir.display(),
496 playground_dir.display()
497 ));
498 }
499 }
500 }
501
502 Ok(InitResult {
503 paths,
504 playground_id: playground_id.to_string(),
505 root_config_created,
506 playground_config_created: true,
507 initialized_agent_configs: selected_agent_configs
508 .iter()
509 .map(|agent| agent.agent_id.clone())
510 .collect(),
511 })
512}
513
514pub fn configured_playground_ids() -> Result<Vec<String>> {
520 Ok(configured_playgrounds()?
521 .into_iter()
522 .map(|playground| playground.id)
523 .collect())
524}
525
526pub fn configured_playgrounds() -> Result<Vec<ConfiguredPlayground>> {
532 configured_playgrounds_at(&ConfigPaths::from_user_config_dir()?.playgrounds_dir)
533}
534
535pub fn resolve_playground_dir(playground_id: &str) -> Result<PathBuf> {
537 resolve_playground_dir_at(ConfigPaths::from_user_config_dir()?, playground_id)
538}
539
540pub fn remove_playground(playground_id: &str) -> Result<RemoveResult> {
542 let paths = ConfigPaths::from_user_config_dir()?;
543 remove_playground_at(paths, playground_id)
544}
545
546fn remove_playground_at(paths: ConfigPaths, playground_id: &str) -> Result<RemoveResult> {
547 let playground_dir = resolve_playground_dir_at(paths.clone(), playground_id)?;
548
549 fs::remove_dir_all(&playground_dir)
550 .with_context(|| format!("failed to remove {}", playground_dir.display()))?;
551
552 Ok(RemoveResult {
553 paths,
554 playground_id: playground_id.to_string(),
555 playground_dir,
556 })
557}
558
559fn resolve_playground_dir_at(paths: ConfigPaths, playground_id: &str) -> Result<PathBuf> {
560 validate_playground_id(playground_id)?;
561
562 let playground_dir = paths.playgrounds_dir.join(playground_id);
563 if !playground_dir.exists() {
564 bail!("unknown playground '{playground_id}'");
565 }
566
567 let metadata = fs::symlink_metadata(&playground_dir)
568 .with_context(|| format!("failed to inspect {}", playground_dir.display()))?;
569 if metadata.file_type().is_symlink() {
570 bail!(
571 "playground '{}' cannot be removed because it is a symlink: {}",
572 playground_id,
573 playground_dir.display()
574 );
575 }
576 if !metadata.is_dir() {
577 bail!(
578 "playground '{}' is not a directory: {}",
579 playground_id,
580 playground_dir.display()
581 );
582 }
583
584 Ok(playground_dir)
585}
586
587fn configured_playgrounds_at(playgrounds_dir: &Path) -> Result<Vec<ConfiguredPlayground>> {
588 if !playgrounds_dir.exists() {
589 return Ok(Vec::new());
590 }
591
592 if !playgrounds_dir.is_dir() {
593 bail!(
594 "playground config path is not a directory: {}",
595 playgrounds_dir.display()
596 );
597 }
598
599 let mut playgrounds = Vec::new();
600 for entry_result in fs::read_dir(playgrounds_dir)
601 .with_context(|| format!("failed to read {}", playgrounds_dir.display()))?
602 {
603 let Ok(entry) = entry_result else {
604 continue;
606 };
607
608 let Ok(file_type) = entry.file_type() else {
609 continue;
611 };
612
613 if !file_type.is_dir() {
614 continue;
615 }
616
617 let playground_id = entry.file_name().to_string_lossy().into_owned();
618 if validate_playground_id(&playground_id).is_err() {
619 continue;
620 }
621
622 let config_file = entry.path().join(PLAYGROUND_CONFIG_FILE_NAME);
623 if !config_file.is_file() {
624 continue;
625 }
626
627 let Ok(playground_config) = read_toml_file::<PlaygroundConfigFile>(&config_file) else {
628 continue;
629 };
630
631 playgrounds.push(ConfiguredPlayground {
632 id: playground_id,
633 description: playground_config.description,
634 });
635 }
636
637 playgrounds.sort_by(|left, right| left.id.cmp(&right.id));
638 Ok(playgrounds)
639}
640
641fn validate_playground_id(playground_id: &str) -> Result<()> {
642 if playground_id.is_empty() {
643 bail!("playground id cannot be empty");
644 }
645 if playground_id == DEFAULT_SUBCOMMAND_PLAYGROUND_ID {
646 bail!(
647 "invalid playground id '{playground_id}': this name is reserved for the `default` subcommand"
648 );
649 }
650 if playground_id.starts_with("__") {
651 bail!(
652 "invalid playground id '{playground_id}': ids starting with '__' are reserved for internal use"
653 );
654 }
655 if matches!(playground_id, "." | "..")
656 || playground_id.contains('/')
657 || playground_id.contains('\\')
658 {
659 bail!(
660 "invalid playground id '{}': ids must not contain path separators or parent-directory segments",
661 playground_id
662 );
663 }
664
665 Ok(())
666}
667
668fn validate_agent_id(agent_id: &str) -> Result<()> {
669 if agent_id.is_empty() {
670 bail!("agent id cannot be empty");
671 }
672 if matches!(agent_id, "." | "..") || agent_id.contains('/') || agent_id.contains('\\') {
673 bail!(
674 "invalid agent id '{}': ids must not contain path separators or parent-directory segments",
675 agent_id
676 );
677 }
678
679 Ok(())
680}
681
682fn git_is_available() -> Result<bool> {
683 match Command::new("git")
684 .arg("--version")
685 .stdout(Stdio::null())
686 .stderr(Stdio::null())
687 .status()
688 {
689 Ok(status) => Ok(status.success()),
690 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
691 Err(error) => Err(error).context("failed to check whether git is available"),
692 }
693}
694
695fn init_git_repo(playground_dir: &Path) -> Result<()> {
696 let status = Command::new("git")
697 .arg("init")
698 .current_dir(playground_dir)
699 .stdout(Stdio::null())
700 .stderr(Stdio::null())
701 .status()
702 .with_context(|| {
703 format!(
704 "failed to initialize git repository in {}",
705 playground_dir.display()
706 )
707 })?;
708
709 if !status.success() {
710 bail!(
711 "git init exited with status {status} in {}",
712 playground_dir.display()
713 );
714 }
715
716 Ok(())
717}
718
719#[derive(Debug, Clone, PartialEq, Eq)]
720struct SelectedAgentConfig {
721 agent_id: String,
722 source_dir: PathBuf,
723 destination_dir: PathBuf,
724}
725
726fn select_agent_configs(
727 paths: &ConfigPaths,
728 agents: &BTreeMap<String, ResolvedAgentConfig>,
729 agent_ids: &[String],
730) -> Result<Vec<SelectedAgentConfig>> {
731 let available_agent_ids = agents.keys().cloned().collect::<Vec<_>>();
732 let mut selected_agents = Vec::new();
733 let mut destination_agents: BTreeMap<PathBuf, String> = BTreeMap::new();
734
735 for agent_id in agent_ids {
736 validate_agent_id(agent_id)?;
737
738 if selected_agents
739 .iter()
740 .any(|selected_agent: &SelectedAgentConfig| &selected_agent.agent_id == agent_id)
741 {
742 continue;
743 }
744
745 let agent_config = agents.get(agent_id).with_context(|| {
746 format!(
747 "unknown agent '{agent_id}'. Available agents: {}",
748 if available_agent_ids.is_empty() {
749 "(none)".to_string()
750 } else {
751 available_agent_ids.join(", ")
752 }
753 )
754 })?;
755 if let Some(existing_agent_id) = destination_agents.get(&agent_config.config_dir) {
756 bail!(
757 "agent config_dir conflict: '{agent_id}' and '{existing_agent_id}' both target '{}'",
758 agent_config.config_dir.display()
759 );
760 }
761
762 destination_agents.insert(agent_config.config_dir.clone(), agent_id.clone());
763 selected_agents.push(SelectedAgentConfig {
764 agent_id: agent_id.clone(),
765 source_dir: paths.agents_dir.join(agent_id),
766 destination_dir: agent_config.config_dir.clone(),
767 });
768 }
769
770 Ok(selected_agents)
771}
772
773fn copy_agent_configs(playground_dir: &Path, agent_configs: &[SelectedAgentConfig]) -> Result<()> {
774 for agent_config in agent_configs {
775 let destination = playground_dir.join(&agent_config.destination_dir);
776 fs::create_dir_all(&destination)
777 .with_context(|| format!("failed to create {}", destination.display()))?;
778
779 if !agent_config.source_dir.exists() {
780 continue;
781 }
782
783 let source_metadata =
784 fs::symlink_metadata(&agent_config.source_dir).with_context(|| {
785 format!(
786 "failed to inspect {} for agent '{}'",
787 agent_config.source_dir.display(),
788 agent_config.agent_id
789 )
790 })?;
791 if !source_metadata.is_dir() {
792 bail!(
793 "agent config source for '{}' must be a directory: {}",
794 agent_config.agent_id,
795 agent_config.source_dir.display()
796 );
797 }
798
799 copy_directory_contents_recursively(&agent_config.source_dir, &destination)?;
800 }
801
802 Ok(())
803}
804
805fn copy_directory_contents_recursively(source_dir: &Path, destination_dir: &Path) -> Result<()> {
806 for entry in fs::read_dir(source_dir)
807 .with_context(|| format!("failed to read {}", source_dir.display()))?
808 {
809 let entry = entry.with_context(|| {
810 format!("failed to inspect an entry under {}", source_dir.display())
811 })?;
812 let source_path = entry.path();
813 let destination_path = destination_dir.join(entry.file_name());
814 let file_type = entry.file_type().with_context(|| {
815 format!("failed to inspect file type for {}", source_path.display())
816 })?;
817
818 if file_type.is_dir() {
819 fs::create_dir_all(&destination_path)
820 .with_context(|| format!("failed to create {}", destination_path.display()))?;
821 copy_directory_contents_recursively(&source_path, &destination_path)?;
822 } else if file_type.is_symlink() {
823 copy_symlink(&source_path, &destination_path)?;
824 } else if file_type.is_file() {
825 fs::copy(&source_path, &destination_path).with_context(|| {
826 format!(
827 "failed to copy {} to {}",
828 source_path.display(),
829 destination_path.display()
830 )
831 })?;
832 } else {
833 bail!(
834 "unsupported entry in agent config source: {}",
835 source_path.display()
836 );
837 }
838 }
839
840 Ok(())
841}
842
843fn ensure_root_initialized(paths: &ConfigPaths) -> Result<bool> {
844 fs::create_dir_all(&paths.root_dir)
845 .with_context(|| format!("failed to create {}", paths.root_dir.display()))?;
846 fs::create_dir_all(&paths.playgrounds_dir)
847 .with_context(|| format!("failed to create {}", paths.playgrounds_dir.display()))?;
848 fs::create_dir_all(&paths.agents_dir)
849 .with_context(|| format!("failed to create {}", paths.agents_dir.display()))?;
850
851 if paths.config_file.exists() {
852 return Ok(false);
853 }
854
855 write_toml_file(
856 &paths.config_file,
857 &RootConfigFile::defaults_for_paths(paths),
858 )?;
859
860 Ok(true)
861}
862
863fn load_root_config(paths: &ConfigPaths) -> Result<ResolvedRootConfig> {
864 read_toml_file::<RootConfigFile>(&paths.config_file)?.resolve(paths)
865}
866
867fn default_saved_playgrounds_dir(_paths: &ConfigPaths) -> PathBuf {
868 PathBuf::from(DEFAULT_SAVED_PLAYGROUNDS_DIR_NAME)
869}
870
871fn resolve_saved_playgrounds_dir(root_dir: &Path, configured_path: PathBuf) -> PathBuf {
872 if configured_path.is_absolute() {
873 return configured_path;
874 }
875
876 root_dir.join(configured_path)
877}
878
879fn normalize_agent_config_dir(agent_id: &str, config_dir: &Path) -> Result<PathBuf> {
880 if config_dir.as_os_str().is_empty() {
881 bail!("agent '{agent_id}' config_dir cannot be empty");
882 }
883
884 let mut normalized = PathBuf::new();
885 for component in config_dir.components() {
886 match component {
887 Component::Normal(part) => normalized.push(part),
888 Component::CurDir => {}
889 Component::ParentDir => {
890 bail!("agent '{agent_id}' config_dir must not contain '..'");
891 }
892 Component::RootDir | Component::Prefix(_) => {
893 bail!("agent '{agent_id}' config_dir must be a relative path");
894 }
895 }
896 }
897
898 if normalized.as_os_str().is_empty() {
899 bail!("agent '{agent_id}' config_dir cannot be empty");
900 }
901
902 Ok(normalized)
903}
904
905fn validate_default_agent_defined(
906 agents: &BTreeMap<String, ResolvedAgentConfig>,
907 default_agent: Option<&str>,
908 label: &str,
909) -> Result<()> {
910 let Some(default_agent) = default_agent else {
911 bail!("{label} is missing");
912 };
913
914 if !agents.contains_key(default_agent) {
915 bail!("{label} '{default_agent}' is not defined in [agent.<id>]");
916 }
917
918 Ok(())
919}
920
921fn validate_default_playground(
922 playgrounds: &BTreeMap<String, PlaygroundDefinition>,
923 default_playground: Option<&str>,
924) -> Result<()> {
925 let Some(default_playground) = default_playground else {
926 return Ok(());
927 };
928
929 validate_playground_id(default_playground)
930 .with_context(|| "default_playground is invalid".to_string())?;
931
932 if !playgrounds.contains_key(default_playground) {
933 bail!("default_playground '{default_playground}' is not a configured playground");
934 }
935
936 Ok(())
937}
938
939fn load_playgrounds(
940 playgrounds_dir: &Path,
941 agents: &BTreeMap<String, ResolvedAgentConfig>,
942 playground_defaults: &PlaygroundConfig,
943) -> Result<BTreeMap<String, PlaygroundDefinition>> {
944 if !playgrounds_dir.exists() {
945 return Ok(BTreeMap::new());
946 }
947
948 if !playgrounds_dir.is_dir() {
949 bail!(
950 "playground config path is not a directory: {}",
951 playgrounds_dir.display()
952 );
953 }
954
955 let mut playgrounds = BTreeMap::new();
956
957 for entry in fs::read_dir(playgrounds_dir)
958 .with_context(|| format!("failed to read {}", playgrounds_dir.display()))?
959 {
960 let entry = entry.with_context(|| {
961 format!(
962 "failed to inspect an entry under {}",
963 playgrounds_dir.display()
964 )
965 })?;
966 let file_type = entry.file_type().with_context(|| {
967 format!("failed to inspect file type for {}", entry.path().display())
968 })?;
969
970 if !file_type.is_dir() {
971 continue;
972 }
973
974 let directory = entry.path();
975 let config_file = directory.join(PLAYGROUND_CONFIG_FILE_NAME);
976
977 if !config_file.is_file() {
978 bail!(
979 "playground '{}' is missing {}",
980 directory.file_name().unwrap_or_default().to_string_lossy(),
981 PLAYGROUND_CONFIG_FILE_NAME
982 );
983 }
984
985 let playground_config: PlaygroundConfigFile = read_toml_file(&config_file)?;
986 let id = entry.file_name().to_string_lossy().into_owned();
987 validate_playground_id(&id).with_context(|| {
988 format!(
989 "invalid playground directory under {}",
990 playgrounds_dir.display()
991 )
992 })?;
993 let effective_config = playground_config
994 .playground
995 .merged_over(playground_defaults);
996 validate_default_agent_defined(
997 agents,
998 effective_config.default_agent.as_deref(),
999 &format!("playground '{id}' default agent"),
1000 )?;
1001
1002 playgrounds.insert(
1003 id.clone(),
1004 PlaygroundDefinition {
1005 id,
1006 description: playground_config.description,
1007 directory,
1008 config_file,
1009 playground: playground_config.playground,
1010 },
1011 );
1012 }
1013
1014 Ok(playgrounds)
1015}
1016
1017fn read_toml_file<T>(path: &Path) -> Result<T>
1018where
1019 T: for<'de> Deserialize<'de>,
1020{
1021 let content =
1022 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
1023
1024 toml::from_str(&content)
1025 .with_context(|| format!("failed to parse TOML from {}", path.display()))
1026}
1027
1028fn write_toml_file<T>(path: &Path, value: &T) -> Result<()>
1029where
1030 T: Serialize,
1031{
1032 let content =
1033 toml::to_string_pretty(value).context("failed to serialize configuration to TOML")?;
1034 fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039 use super::{
1040 APP_CONFIG_DIR, AppConfig, ConfigPaths, ConfiguredPlayground, CreateMode,
1041 PlaygroundConfigFile, RootConfigFile, configured_playgrounds_at, init_playground_at,
1042 init_playground_at_with_git, read_toml_file, remove_playground_at,
1043 resolve_playground_dir_at, user_config_base_dir,
1044 };
1045 use serde_json::Value;
1046 use std::{cell::Cell, fs, io};
1047 use tempfile::TempDir;
1048
1049 #[cfg(unix)]
1050 fn create_test_symlink(source: &std::path::Path, destination: &std::path::Path) {
1051 std::os::unix::fs::symlink(source, destination).expect("create symlink");
1052 }
1053
1054 #[cfg(windows)]
1055 fn create_test_symlink(source: &std::path::Path, destination: &std::path::Path) {
1056 std::os::windows::fs::symlink_file(source, destination).expect("create symlink");
1057 }
1058
1059 fn resolved_agent_cmd(config: &AppConfig, agent_id: &str) -> Option<String> {
1060 config.agents.get(agent_id).map(|agent| agent.cmd.clone())
1061 }
1062
1063 fn resolved_agent_config_dir(config: &AppConfig, agent_id: &str) -> Option<std::path::PathBuf> {
1064 config
1065 .agents
1066 .get(agent_id)
1067 .map(|agent| agent.config_dir.clone())
1068 }
1069
1070 #[test]
1071 fn init_creates_root_and_playground_configs_from_file_models() {
1072 let temp_dir = TempDir::new().expect("temp dir");
1073 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1074
1075 let result = init_playground_at(paths.clone(), "demo", &[]).expect("init should succeed");
1076
1077 assert!(result.root_config_created);
1078 assert!(result.playground_config_created);
1079 assert!(result.initialized_agent_configs.is_empty());
1080 assert!(temp_dir.path().join("config.toml").is_file());
1081 assert!(
1082 temp_dir
1083 .path()
1084 .join("playgrounds")
1085 .join("demo")
1086 .join("apg.toml")
1087 .is_file()
1088 );
1089 assert!(
1090 !temp_dir
1091 .path()
1092 .join("playgrounds")
1093 .join("demo")
1094 .join(".claude")
1095 .exists()
1096 );
1097 assert_eq!(
1098 read_toml_file::<RootConfigFile>(&temp_dir.path().join("config.toml"))
1099 .expect("root config"),
1100 RootConfigFile::defaults_for_paths(&paths)
1101 );
1102 assert_eq!(
1103 read_toml_file::<PlaygroundConfigFile>(
1104 &temp_dir
1105 .path()
1106 .join("playgrounds")
1107 .join("demo")
1108 .join("apg.toml")
1109 )
1110 .expect("playground config"),
1111 PlaygroundConfigFile::for_playground("demo")
1112 );
1113
1114 let config = AppConfig::load_from_paths(paths).expect("config should load");
1115 assert_eq!(
1116 resolved_agent_cmd(&config, "claude"),
1117 Some("claude".to_string())
1118 );
1119 assert_eq!(
1120 resolved_agent_cmd(&config, "opencode"),
1121 Some("opencode".to_string())
1122 );
1123 assert_eq!(
1124 resolved_agent_config_dir(&config, "claude"),
1125 Some(std::path::PathBuf::from(".claude"))
1126 );
1127 assert_eq!(
1128 resolved_agent_config_dir(&config, "opencode"),
1129 Some(std::path::PathBuf::from(".opencode"))
1130 );
1131 assert_eq!(
1132 config.playground_defaults.default_agent.as_deref(),
1133 Some("claude")
1134 );
1135 assert_eq!(config.default_playground, None);
1136 assert_eq!(config.playground_defaults.load_env, Some(false));
1137 assert_eq!(
1138 config.playground_defaults.create_mode,
1139 Some(CreateMode::Copy)
1140 );
1141 assert_eq!(
1142 config.saved_playgrounds_dir,
1143 temp_dir.path().join("saved-playgrounds")
1144 );
1145 assert_eq!(
1146 config
1147 .playgrounds
1148 .get("demo")
1149 .expect("demo playground")
1150 .description,
1151 "TODO: describe demo"
1152 );
1153 assert!(
1154 config
1155 .playgrounds
1156 .get("demo")
1157 .expect("demo playground")
1158 .playground
1159 .is_empty()
1160 );
1161 }
1162
1163 #[test]
1164 fn merges_root_agents_and_loads_playgrounds() {
1165 let temp_dir = TempDir::new().expect("temp dir");
1166 let root = temp_dir.path();
1167 fs::write(
1168 root.join("config.toml"),
1169 r#"saved_playgrounds_dir = "archives"
1170default_playground = "demo"
1171
1172[agent.claude]
1173cmd = "custom-claude"
1174
1175[agent.codex]
1176cmd = "codex --fast"
1177
1178[playground]
1179default_agent = "codex"
1180load_env = true
1181create_mode = "hardlink"
1182"#,
1183 )
1184 .expect("write root config");
1185
1186 let playground_dir = root.join("playgrounds").join("demo");
1187 fs::create_dir_all(&playground_dir).expect("create playground dir");
1188 fs::write(
1189 playground_dir.join("apg.toml"),
1190 r#"description = "Demo playground"
1191default_agent = "claude""#,
1192 )
1193 .expect("write playground config");
1194
1195 let config = AppConfig::load_from_paths(ConfigPaths::from_root_dir(root.to_path_buf()))
1196 .expect("config should load");
1197
1198 assert_eq!(
1199 resolved_agent_cmd(&config, "claude"),
1200 Some("custom-claude".to_string())
1201 );
1202 assert_eq!(
1203 resolved_agent_cmd(&config, "opencode"),
1204 Some("opencode".to_string())
1205 );
1206 assert_eq!(
1207 resolved_agent_cmd(&config, "codex"),
1208 Some("codex --fast".to_string())
1209 );
1210 assert_eq!(
1211 config.playground_defaults.default_agent.as_deref(),
1212 Some("codex")
1213 );
1214 assert_eq!(config.default_playground.as_deref(), Some("demo"));
1215 assert_eq!(config.playground_defaults.load_env, Some(true));
1216 assert_eq!(
1217 config.playground_defaults.create_mode,
1218 Some(CreateMode::Hardlink)
1219 );
1220 assert_eq!(config.saved_playgrounds_dir, root.join("archives"));
1221
1222 let playground = config.playgrounds.get("demo").expect("demo playground");
1223 assert_eq!(playground.description, "Demo playground");
1224 assert_eq!(
1225 playground.playground.default_agent.as_deref(),
1226 Some("claude")
1227 );
1228 assert_eq!(playground.directory, playground_dir);
1229 let effective_config = config
1230 .resolve_playground_config(playground)
1231 .expect("effective playground config");
1232 assert_eq!(effective_config.default_agent, "claude");
1233 assert!(effective_config.load_env);
1234 assert_eq!(effective_config.create_mode, CreateMode::Hardlink);
1235 }
1236
1237 #[test]
1238 fn playground_create_mode_overrides_root_default() {
1239 let temp_dir = TempDir::new().expect("temp dir");
1240 fs::write(
1241 temp_dir.path().join("config.toml"),
1242 r#"[playground]
1243create_mode = "copy"
1244"#,
1245 )
1246 .expect("write root config");
1247 let playground_dir = temp_dir.path().join("playgrounds").join("demo");
1248 fs::create_dir_all(&playground_dir).expect("create playground dir");
1249 fs::write(
1250 playground_dir.join("apg.toml"),
1251 r#"description = "Demo playground"
1252create_mode = "symlink""#,
1253 )
1254 .expect("write playground config");
1255
1256 let config =
1257 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1258 .expect("config should load");
1259 let playground = config.playgrounds.get("demo").expect("demo playground");
1260 let effective_config = config
1261 .resolve_playground_config(playground)
1262 .expect("effective playground config");
1263
1264 assert_eq!(
1265 config.playground_defaults.create_mode,
1266 Some(CreateMode::Copy)
1267 );
1268 assert_eq!(playground.playground.create_mode, Some(CreateMode::Symlink));
1269 assert_eq!(effective_config.create_mode, CreateMode::Symlink);
1270 }
1271
1272 #[test]
1273 fn errors_when_playground_default_agent_is_not_defined() {
1274 let temp_dir = TempDir::new().expect("temp dir");
1275 fs::write(
1276 temp_dir.path().join("config.toml"),
1277 r#"[agent.claude]
1278cmd = "claude"
1279"#,
1280 )
1281 .expect("write root config");
1282 let playground_dir = temp_dir.path().join("playgrounds").join("demo");
1283 fs::create_dir_all(&playground_dir).expect("create playground dir");
1284 fs::write(
1285 playground_dir.join("apg.toml"),
1286 r#"description = "Demo playground"
1287default_agent = "codex""#,
1288 )
1289 .expect("write playground config");
1290
1291 let error =
1292 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1293 .expect_err("undefined playground default agent should fail");
1294
1295 assert!(
1296 error
1297 .to_string()
1298 .contains("playground 'demo' default agent 'codex' is not defined")
1299 );
1300 }
1301
1302 #[test]
1303 fn load_auto_initializes_missing_root_config() {
1304 let temp_dir = TempDir::new().expect("temp dir");
1305 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1306
1307 let config = AppConfig::load_from_paths(paths).expect("missing root config should init");
1308
1309 assert!(temp_dir.path().join("config.toml").is_file());
1310 assert!(temp_dir.path().join("playgrounds").is_dir());
1311 assert!(temp_dir.path().join("agents").is_dir());
1312 assert_eq!(
1313 resolved_agent_cmd(&config, "claude"),
1314 Some("claude".to_string())
1315 );
1316 assert_eq!(
1317 config.playground_defaults.default_agent.as_deref(),
1318 Some("claude")
1319 );
1320 assert_eq!(config.default_playground, None);
1321 assert_eq!(config.playground_defaults.load_env, Some(false));
1322 assert_eq!(
1323 config.playground_defaults.create_mode,
1324 Some(CreateMode::Copy)
1325 );
1326 assert_eq!(
1327 config.saved_playgrounds_dir,
1328 temp_dir.path().join("saved-playgrounds")
1329 );
1330 }
1331
1332 #[test]
1333 fn respects_absolute_saved_playgrounds_dir() {
1334 let temp_dir = TempDir::new().expect("temp dir");
1335 let archive_dir = TempDir::new().expect("archive dir");
1336 let archive_path = archive_dir
1337 .path()
1338 .display()
1339 .to_string()
1340 .replace('\\', "\\\\");
1341 fs::write(
1342 temp_dir.path().join("config.toml"),
1343 format!(
1344 r#"saved_playgrounds_dir = "{}"
1345
1346[agent.claude]
1347cmd = "claude"
1348"#,
1349 archive_path
1350 ),
1351 )
1352 .expect("write root config");
1353
1354 let config =
1355 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1356 .expect("config should load");
1357
1358 assert_eq!(config.saved_playgrounds_dir, archive_dir.path());
1359 }
1360
1361 #[test]
1362 fn errors_when_playground_config_is_missing() {
1363 let temp_dir = TempDir::new().expect("temp dir");
1364 fs::write(
1365 temp_dir.path().join("config.toml"),
1366 r#"[agent.claude]
1367cmd = "claude"
1368
1369[agent.opencode]
1370cmd = "opencode"
1371"#,
1372 )
1373 .expect("write root config");
1374 let playground_dir = temp_dir.path().join("playgrounds").join("broken");
1375 fs::create_dir_all(&playground_dir).expect("create playground dir");
1376
1377 let error =
1378 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1379 .expect_err("missing playground config should fail");
1380
1381 assert!(error.to_string().contains("missing apg.toml"));
1382 }
1383
1384 #[test]
1385 fn errors_when_default_agent_is_not_defined() {
1386 let temp_dir = TempDir::new().expect("temp dir");
1387 fs::write(
1388 temp_dir.path().join("config.toml"),
1389 r#"[playground]
1390default_agent = "codex""#,
1391 )
1392 .expect("write root config");
1393
1394 let error =
1395 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1396 .expect_err("undefined default agent should fail");
1397
1398 assert!(
1399 error
1400 .to_string()
1401 .contains("default agent 'codex' is not defined")
1402 );
1403 }
1404
1405 #[test]
1406 fn errors_when_default_playground_is_not_configured() {
1407 let temp_dir = TempDir::new().expect("temp dir");
1408 fs::write(
1409 temp_dir.path().join("config.toml"),
1410 r#"default_playground = "missing"
1411
1412[agent.claude]
1413cmd = "claude"
1414"#,
1415 )
1416 .expect("write root config");
1417
1418 let error =
1419 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1420 .expect_err("unknown default playground should fail");
1421
1422 assert!(
1423 error
1424 .to_string()
1425 .contains("default_playground 'missing' is not a configured playground")
1426 );
1427 }
1428
1429 #[test]
1430 fn errors_when_default_playground_uses_reserved_name() {
1431 let temp_dir = TempDir::new().expect("temp dir");
1432 fs::write(
1433 temp_dir.path().join("config.toml"),
1434 r#"default_playground = "default"
1435
1436[agent.claude]
1437cmd = "claude"
1438"#,
1439 )
1440 .expect("write root config");
1441
1442 let error =
1443 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1444 .expect_err("reserved default playground should fail");
1445 let message = format!("{error:#}");
1446
1447 assert!(message.contains("default_playground is invalid"));
1448 assert!(message.contains("reserved for the `default` subcommand"));
1449 }
1450
1451 #[test]
1452 fn init_errors_when_playground_already_exists() {
1453 let temp_dir = TempDir::new().expect("temp dir");
1454 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1455
1456 init_playground_at(paths.clone(), "demo", &[]).expect("initial init should succeed");
1457 let error = init_playground_at(paths, "demo", &[]).expect_err("duplicate init should fail");
1458
1459 assert!(
1460 error
1461 .to_string()
1462 .contains("playground 'demo' already exists")
1463 );
1464 }
1465
1466 #[test]
1467 fn init_rejects_reserved_default_playground_id() {
1468 let temp_dir = TempDir::new().expect("temp dir");
1469 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1470
1471 let error = init_playground_at(paths, "default", &[]).expect_err("reserved id should fail");
1472
1473 assert!(
1474 error
1475 .to_string()
1476 .contains("invalid playground id 'default'")
1477 );
1478 assert!(
1479 error
1480 .to_string()
1481 .contains("reserved for the `default` subcommand")
1482 );
1483 }
1484
1485 #[test]
1486 fn init_rejects_internal_reserved_playground_id_prefix() {
1487 let temp_dir = TempDir::new().expect("temp dir");
1488 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1489
1490 let error =
1491 init_playground_at(paths, "__default__", &[]).expect_err("reserved id should fail");
1492
1493 assert!(
1494 error
1495 .to_string()
1496 .contains("ids starting with '__' are reserved for internal use")
1497 );
1498 }
1499
1500 #[test]
1501 fn remove_deletes_existing_playground_directory() {
1502 let temp_dir = TempDir::new().expect("temp dir");
1503 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1504 let nested_file = temp_dir
1505 .path()
1506 .join("playgrounds")
1507 .join("demo")
1508 .join("notes.txt");
1509
1510 init_playground_at(paths.clone(), "demo", &[]).expect("init should succeed");
1511 fs::write(&nested_file, "hello").expect("write nested file");
1512
1513 let result = remove_playground_at(paths.clone(), "demo").expect("remove should succeed");
1514
1515 assert_eq!(result.paths, paths);
1516 assert_eq!(result.playground_id, "demo");
1517 assert_eq!(
1518 result.playground_dir,
1519 temp_dir.path().join("playgrounds").join("demo")
1520 );
1521 assert!(!result.playground_dir.exists());
1522 }
1523
1524 #[test]
1525 fn remove_errors_for_unknown_playground() {
1526 let temp_dir = TempDir::new().expect("temp dir");
1527 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1528
1529 let error =
1530 remove_playground_at(paths, "missing").expect_err("missing playground should fail");
1531
1532 assert!(error.to_string().contains("unknown playground 'missing'"));
1533 }
1534
1535 #[test]
1536 fn resolve_playground_dir_rejects_path_traversal_ids() {
1537 let temp_dir = TempDir::new().expect("temp dir");
1538 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1539
1540 let error = resolve_playground_dir_at(paths, "../demo")
1541 .expect_err("path traversal playground id should fail");
1542
1543 assert!(
1544 error
1545 .to_string()
1546 .contains("invalid playground id '../demo'")
1547 );
1548 }
1549
1550 #[test]
1551 fn init_rejects_path_traversal_ids_before_writing_files() {
1552 let temp_dir = TempDir::new().expect("temp dir");
1553 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1554
1555 let error = init_playground_at(paths, "../demo", &[])
1556 .expect_err("path traversal playground id should fail");
1557
1558 assert!(
1559 error
1560 .to_string()
1561 .contains("invalid playground id '../demo'")
1562 );
1563 assert!(!temp_dir.path().join("config.toml").exists());
1564 assert!(!temp_dir.path().join("playgrounds").exists());
1565 assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1566 }
1567
1568 #[test]
1569 fn init_cleans_up_playground_directory_when_git_init_fails() {
1570 let temp_dir = TempDir::new().expect("temp dir");
1571 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1572
1573 let error = init_playground_at_with_git(
1574 paths,
1575 "demo",
1576 &[],
1577 || Ok(true),
1578 |_| Err(io::Error::other("git init failed").into()),
1579 )
1580 .expect_err("git init failure should fail init");
1581
1582 let error_message = format!("{error:#}");
1583
1584 assert!(error_message.contains("git init failed"));
1585 assert!(error_message.contains("removed partially initialized playground"));
1586 assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1587 }
1588
1589 #[test]
1590 fn init_copies_existing_agent_sources_and_creates_missing_targets() {
1591 let temp_dir = TempDir::new().expect("temp dir");
1592 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1593 let selected_agents = vec!["claude".to_string(), "opencode".to_string()];
1594
1595 let claude_source_dir = paths.agents_dir.join("claude");
1596 fs::create_dir_all(&claude_source_dir).expect("create claude source");
1597 fs::write(
1598 claude_source_dir.join("settings.json"),
1599 r#"{"theme":"dark"}"#,
1600 )
1601 .expect("write claude source file");
1602
1603 let result =
1604 init_playground_at(paths, "demo", &selected_agents).expect("init should succeed");
1605 let playground_dir = temp_dir.path().join("playgrounds").join("demo");
1606
1607 assert_eq!(
1608 result.initialized_agent_configs,
1609 vec!["claude".to_string(), "opencode".to_string()]
1610 );
1611 assert!(
1612 playground_dir
1613 .join(".claude")
1614 .join("settings.json")
1615 .is_file()
1616 );
1617 assert!(playground_dir.join(".opencode").is_dir());
1618 }
1619
1620 #[test]
1621 fn init_initializes_git_repo_when_git_is_available() {
1622 let temp_dir = TempDir::new().expect("temp dir");
1623 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1624 let git_init_called = Cell::new(false);
1625
1626 init_playground_at_with_git(
1627 paths,
1628 "demo",
1629 &[],
1630 || Ok(true),
1631 |playground_dir| {
1632 git_init_called.set(true);
1633 fs::create_dir(playground_dir.join(".git")).expect("create .git directory");
1634 Ok(())
1635 },
1636 )
1637 .expect("init should succeed");
1638
1639 assert!(git_init_called.get());
1640 assert!(
1641 temp_dir
1642 .path()
1643 .join("playgrounds")
1644 .join("demo")
1645 .join(".git")
1646 .is_dir()
1647 );
1648 }
1649
1650 #[test]
1651 fn init_skips_git_repo_when_git_is_unavailable() {
1652 let temp_dir = TempDir::new().expect("temp dir");
1653 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1654 let git_init_called = Cell::new(false);
1655
1656 init_playground_at_with_git(
1657 paths,
1658 "demo",
1659 &[],
1660 || Ok(false),
1661 |_| {
1662 git_init_called.set(true);
1663 Ok(())
1664 },
1665 )
1666 .expect("init should succeed");
1667
1668 assert!(!git_init_called.get());
1669 assert!(
1670 !temp_dir
1671 .path()
1672 .join("playgrounds")
1673 .join("demo")
1674 .join(".git")
1675 .exists()
1676 );
1677 }
1678
1679 #[test]
1680 fn init_deduplicates_selected_agent_configs() {
1681 let temp_dir = TempDir::new().expect("temp dir");
1682 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1683 let selected_agents = vec![
1684 "claude".to_string(),
1685 "claude".to_string(),
1686 "opencode".to_string(),
1687 ];
1688
1689 let result =
1690 init_playground_at(paths, "demo", &selected_agents).expect("init should succeed");
1691
1692 assert_eq!(
1693 result.initialized_agent_configs,
1694 vec!["claude".to_string(), "opencode".to_string()]
1695 );
1696 }
1697
1698 #[test]
1699 fn init_errors_for_unknown_agent_before_creating_playground() {
1700 let temp_dir = TempDir::new().expect("temp dir");
1701 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1702 let selected_agents = vec!["missing".to_string()];
1703
1704 let error = init_playground_at(paths, "demo", &selected_agents)
1705 .expect_err("unknown agent should fail");
1706
1707 assert!(error.to_string().contains("unknown agent 'missing'"));
1708 assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
1709 }
1710
1711 #[test]
1712 fn init_errors_when_selected_agents_share_the_same_config_dir() {
1713 let temp_dir = TempDir::new().expect("temp dir");
1714 let root_dir = temp_dir.path();
1715 fs::write(
1716 root_dir.join("config.toml"),
1717 r#"[agent.alpha]
1718cmd = "alpha"
1719config_dir = ".shared/"
1720
1721[agent.beta]
1722cmd = "beta"
1723config_dir = ".shared/"
1724"#,
1725 )
1726 .expect("write root config");
1727
1728 let error = init_playground_at(
1729 ConfigPaths::from_root_dir(root_dir.to_path_buf()),
1730 "demo",
1731 &["alpha".to_string(), "beta".to_string()],
1732 )
1733 .expect_err("conflicting config_dir should fail");
1734
1735 assert!(error.to_string().contains("agent config_dir conflict"));
1736 }
1737
1738 #[test]
1739 fn errors_when_agent_config_dir_is_not_safe_relative_path() {
1740 let temp_dir = TempDir::new().expect("temp dir");
1741 fs::write(
1742 temp_dir.path().join("config.toml"),
1743 r#"[agent.bad]
1744cmd = "bad"
1745config_dir = "../outside"
1746"#,
1747 )
1748 .expect("write root config");
1749
1750 let error =
1751 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1752 .expect_err("unsafe config_dir should fail");
1753
1754 assert!(error.to_string().contains("config_dir"));
1755 assert!(error.to_string().contains("must not contain '..'"));
1756 }
1757
1758 #[test]
1759 fn errors_when_agent_id_is_not_safe_relative_key() {
1760 let temp_dir = TempDir::new().expect("temp dir");
1761 fs::write(
1762 temp_dir.path().join("config.toml"),
1763 r#"[agent."../escape"]
1764cmd = "bad"
1765"#,
1766 )
1767 .expect("write root config");
1768
1769 let error =
1770 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1771 .expect_err("invalid agent id should fail");
1772
1773 assert!(error.to_string().contains("invalid agent id"));
1774 }
1775
1776 #[test]
1777 fn init_copies_symlinks_from_agent_source_directory() {
1778 let temp_dir = TempDir::new().expect("temp dir");
1779 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
1780 let source_dir = paths.agents_dir.join("claude");
1781 fs::create_dir_all(&source_dir).expect("create source dir");
1782 fs::write(source_dir.join("settings.json"), "{}").expect("write source file");
1783 create_test_symlink(
1784 std::path::Path::new("settings.json"),
1785 &source_dir.join("settings.link"),
1786 );
1787
1788 init_playground_at(paths, "demo", &["claude".to_string()]).expect("init should succeed");
1789
1790 let destination = temp_dir
1791 .path()
1792 .join("playgrounds")
1793 .join("demo")
1794 .join(".claude")
1795 .join("settings.link");
1796 let metadata = fs::symlink_metadata(&destination).expect("symlink metadata");
1797 assert!(metadata.file_type().is_symlink());
1798 }
1799
1800 #[test]
1801 fn errors_when_root_config_toml_is_invalid() {
1802 let temp_dir = TempDir::new().expect("temp dir");
1803 fs::write(
1804 temp_dir.path().join("config.toml"),
1805 "[playground]\ndefault_agent = ",
1806 )
1807 .expect("write invalid root config");
1808
1809 let error =
1810 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1811 .expect_err("invalid root config should fail");
1812
1813 assert!(error.to_string().contains("failed to parse TOML"));
1814 }
1815
1816 #[test]
1817 fn errors_when_playground_config_toml_is_invalid() {
1818 let temp_dir = TempDir::new().expect("temp dir");
1819 fs::write(
1820 temp_dir.path().join("config.toml"),
1821 r#"[agent.claude]
1822cmd = "claude"
1823"#,
1824 )
1825 .expect("write root config");
1826 let playground_dir = temp_dir.path().join("playgrounds").join("broken");
1827 fs::create_dir_all(&playground_dir).expect("create playground dir");
1828 fs::write(playground_dir.join("apg.toml"), "description = ")
1829 .expect("write invalid playground config");
1830
1831 let error =
1832 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1833 .expect_err("invalid playground config should fail");
1834
1835 assert!(error.to_string().contains("failed to parse TOML"));
1836 }
1837
1838 #[test]
1839 fn errors_when_create_mode_is_invalid() {
1840 let temp_dir = TempDir::new().expect("temp dir");
1841 fs::write(
1842 temp_dir.path().join("config.toml"),
1843 r#"[playground]
1844create_mode = "clone"
1845"#,
1846 )
1847 .expect("write invalid root config");
1848
1849 let error =
1850 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1851 .expect_err("invalid create_mode should fail");
1852
1853 let message = format!("{error:#}");
1854 assert!(message.contains("create_mode"));
1855 assert!(message.contains("clone"));
1856 }
1857
1858 #[test]
1859 fn errors_when_playground_directory_uses_reserved_id() {
1860 let temp_dir = TempDir::new().expect("temp dir");
1861 fs::write(
1862 temp_dir.path().join("config.toml"),
1863 r#"[agent.claude]
1864cmd = "claude"
1865"#,
1866 )
1867 .expect("write root config");
1868 let playground_dir = temp_dir.path().join("playgrounds").join("default");
1869 fs::create_dir_all(&playground_dir).expect("create playground dir");
1870 fs::write(playground_dir.join("apg.toml"), "description = 'reserved'")
1871 .expect("write playground config");
1872
1873 let error =
1874 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1875 .expect_err("reserved playground id should fail");
1876 let message = format!("{error:#}");
1877
1878 assert!(message.contains("invalid playground directory under"));
1879 assert!(message.contains("invalid playground id 'default'"));
1880 }
1881
1882 #[test]
1883 fn ignores_non_directory_entries_in_playgrounds_dir() {
1884 let temp_dir = TempDir::new().expect("temp dir");
1885 fs::write(
1886 temp_dir.path().join("config.toml"),
1887 r#"[agent.claude]
1888cmd = "claude"
1889"#,
1890 )
1891 .expect("write root config");
1892 let playgrounds_dir = temp_dir.path().join("playgrounds");
1893 fs::create_dir_all(&playgrounds_dir).expect("create playgrounds dir");
1894 fs::write(playgrounds_dir.join("README.md"), "ignore me").expect("write file entry");
1895
1896 let config =
1897 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
1898 .expect("config should load");
1899
1900 assert!(config.playgrounds.is_empty());
1901 }
1902
1903 #[test]
1904 fn configured_playgrounds_only_returns_valid_initialized_directories() {
1905 let temp_dir = TempDir::new().expect("temp dir");
1906 let playgrounds_dir = temp_dir.path().join("playgrounds");
1907 fs::create_dir_all(&playgrounds_dir).expect("create playgrounds dir");
1908
1909 let demo_dir = playgrounds_dir.join("demo");
1910 fs::create_dir_all(&demo_dir).expect("create demo");
1911 fs::write(demo_dir.join("apg.toml"), "description = 'Demo'").expect("write demo config");
1912
1913 let ops_dir = playgrounds_dir.join("ops");
1914 fs::create_dir_all(&ops_dir).expect("create ops");
1915 fs::write(ops_dir.join("apg.toml"), "description = 'Ops'").expect("write ops config");
1916
1917 fs::create_dir_all(playgrounds_dir.join("broken")).expect("create broken");
1918 fs::create_dir_all(playgrounds_dir.join("default")).expect("create reserved");
1919 fs::create_dir_all(playgrounds_dir.join("invalid")).expect("create invalid");
1920 fs::write(
1921 playgrounds_dir.join("invalid").join("apg.toml"),
1922 "description = ",
1923 )
1924 .expect("write invalid config");
1925 fs::write(playgrounds_dir.join("README.md"), "ignore me").expect("write file");
1926
1927 assert_eq!(
1928 configured_playgrounds_at(&playgrounds_dir).expect("list playgrounds"),
1929 vec![
1930 ConfiguredPlayground {
1931 id: "demo".to_string(),
1932 description: "Demo".to_string(),
1933 },
1934 ConfiguredPlayground {
1935 id: "ops".to_string(),
1936 description: "Ops".to_string(),
1937 }
1938 ]
1939 );
1940 }
1941
1942 #[test]
1943 fn user_config_dir_uses_dot_config_on_all_platforms() {
1944 let base_dir = user_config_base_dir().expect("user config base dir");
1945 let paths = ConfigPaths::from_user_config_dir().expect("user config paths");
1946
1947 assert!(base_dir.ends_with(".config"));
1948 assert_eq!(paths.root_dir, base_dir.join(APP_CONFIG_DIR));
1949 }
1950
1951 #[test]
1952 fn root_config_schema_matches_file_shape() {
1953 let schema = serde_json::to_value(RootConfigFile::json_schema()).expect("schema json");
1954
1955 assert_eq!(schema["type"], Value::String("object".to_string()));
1956 assert!(schema["properties"]["agent"].is_object());
1957 assert_eq!(
1958 schema["properties"]["agent"]["additionalProperties"]["$ref"],
1959 Value::String("#/$defs/AgentConfigFile".to_string())
1960 );
1961 assert!(schema["$defs"]["AgentConfigFile"]["properties"]["cmd"].is_object());
1962 assert!(schema["$defs"]["AgentConfigFile"]["properties"]["config_dir"].is_object());
1963 assert!(schema["properties"]["default_playground"].is_object());
1964 assert!(schema["properties"]["saved_playgrounds_dir"].is_object());
1965 assert!(schema["properties"]["playground"].is_object());
1966 }
1967
1968 #[test]
1969 fn playground_config_schema_matches_file_shape() {
1970 let schema =
1971 serde_json::to_value(PlaygroundConfigFile::json_schema()).expect("schema json");
1972
1973 assert_eq!(schema["type"], Value::String("object".to_string()));
1974 assert!(schema["properties"]["description"].is_object());
1975 assert!(schema["properties"]["default_agent"].is_object());
1976 assert!(schema["properties"]["load_env"].is_object());
1977 assert!(schema["properties"]["create_mode"].is_object());
1978 assert_eq!(
1979 schema["required"],
1980 Value::Array(vec![Value::String("description".to_string())])
1981 );
1982 }
1983}