1use std::{
2 collections::BTreeMap,
3 fs,
4 path::{Path, PathBuf},
5};
6
7use anyhow::{Context, Result, bail};
8use askama::Template;
9use include_dir::{Dir, include_dir};
10use serde::Deserialize;
11
12const APP_CONFIG_DIR: &str = "agent-playground";
13const ROOT_CONFIG_FILE_NAME: &str = "config.toml";
14const PLAYGROUND_CONFIG_FILE_NAME: &str = "apg.toml";
15const PLAYGROUNDS_DIR_NAME: &str = "playgrounds";
16static TEMPLATE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates");
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ConfigPaths {
20 pub root_dir: PathBuf,
21 pub config_file: PathBuf,
22 pub playgrounds_dir: PathBuf,
23}
24
25impl ConfigPaths {
26 pub fn from_user_config_dir() -> Result<Self> {
27 let config_dir = user_config_base_dir()?;
28
29 Ok(Self::from_root_dir(config_dir.join(APP_CONFIG_DIR)))
30 }
31
32 pub fn from_root_dir(root_dir: PathBuf) -> Self {
33 Self {
34 config_file: root_dir.join(ROOT_CONFIG_FILE_NAME),
35 playgrounds_dir: root_dir.join(PLAYGROUNDS_DIR_NAME),
36 root_dir,
37 }
38 }
39}
40
41fn user_config_base_dir() -> Result<PathBuf> {
42 let home_dir = dirs::home_dir().context("failed to locate the user's home directory")?;
43 Ok(home_dir.join(".config"))
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct AppConfig {
48 pub paths: ConfigPaths,
49 pub agents: BTreeMap<String, String>,
50 pub default_agent: String,
51 pub saved_playgrounds_dir: PathBuf,
52 pub playgrounds: BTreeMap<String, PlaygroundDefinition>,
53}
54
55impl AppConfig {
56 pub fn load() -> Result<Self> {
57 Self::load_from_paths(ConfigPaths::from_user_config_dir()?)
58 }
59
60 fn load_from_paths(paths: ConfigPaths) -> Result<Self> {
61 ensure_root_initialized(&paths)?;
62 let raw_config = load_root_config(&paths)?;
63 let agents = raw_config.agent;
64 let default_agent = raw_config.default_agent;
65 let saved_playgrounds_dir =
66 resolve_saved_playgrounds_dir(&paths.root_dir, raw_config.saved_playgrounds_dir);
67
68 if !agents.contains_key(&default_agent) {
69 bail!("default agent '{default_agent}' is not defined in [agent]");
70 }
71
72 let playgrounds = load_playgrounds(&paths.playgrounds_dir)?;
73
74 Ok(Self {
75 paths,
76 agents,
77 default_agent,
78 saved_playgrounds_dir,
79 playgrounds,
80 })
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct InitResult {
86 pub paths: ConfigPaths,
87 pub playground_id: String,
88 pub root_config_created: bool,
89 pub playground_config_created: bool,
90 pub initialized_agent_templates: Vec<String>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct PlaygroundDefinition {
95 pub id: String,
96 pub description: String,
97 pub directory: PathBuf,
98 pub config_file: PathBuf,
99}
100
101#[derive(Debug, Default, Deserialize)]
102struct RawRootConfig {
103 agent: BTreeMap<String, String>,
104 default_agent: String,
105 saved_playgrounds_dir: PathBuf,
106}
107
108#[derive(Debug, Default, Deserialize)]
109struct RawRootConfigPatch {
110 #[serde(default)]
111 agent: BTreeMap<String, String>,
112 default_agent: Option<String>,
113 saved_playgrounds_dir: Option<PathBuf>,
114}
115
116#[derive(Debug, Deserialize)]
117struct RawPlaygroundConfig {
118 description: String,
119}
120
121#[derive(Template)]
122#[template(path = "config/root_config.toml", escape = "none")]
123struct RootConfigTemplate<'a> {
124 saved_playgrounds_dir: &'a str,
125}
126
127#[derive(Template)]
128#[template(path = "config/playground_config.toml", escape = "none")]
129struct PlaygroundConfigTemplate<'a> {
130 playground_id: &'a str,
131}
132
133pub fn init_playground(playground_id: &str, agent_ids: &[String]) -> Result<InitResult> {
134 init_playground_at(
135 ConfigPaths::from_user_config_dir()?,
136 playground_id,
137 agent_ids,
138 )
139}
140
141fn init_playground_at(
142 paths: ConfigPaths,
143 playground_id: &str,
144 agent_ids: &[String],
145) -> Result<InitResult> {
146 let root_config_created = ensure_root_initialized(&paths)?;
147 let selected_agent_templates = select_agent_templates(agent_ids)?;
148
149 let playground_dir = paths.playgrounds_dir.join(playground_id);
150 let playground_config_file = playground_dir.join(PLAYGROUND_CONFIG_FILE_NAME);
151
152 if playground_config_file.exists() {
153 bail!(
154 "playground '{}' already exists at {}",
155 playground_id,
156 playground_config_file.display()
157 );
158 }
159
160 fs::create_dir_all(&playground_dir)
161 .with_context(|| format!("failed to create {}", playground_dir.display()))?;
162 let content = PlaygroundConfigTemplate { playground_id }
163 .render()
164 .context("failed to render playground config template")?;
165 fs::write(&playground_config_file, content)
166 .with_context(|| format!("failed to write {}", playground_config_file.display()))?;
167 copy_agent_templates(&playground_dir, &selected_agent_templates)?;
168
169 Ok(InitResult {
170 paths,
171 playground_id: playground_id.to_string(),
172 root_config_created,
173 playground_config_created: true,
174 initialized_agent_templates: selected_agent_templates
175 .iter()
176 .map(|(agent_id, _)| agent_id.clone())
177 .collect(),
178 })
179}
180
181fn select_agent_templates(agent_ids: &[String]) -> Result<Vec<(String, &'static Dir<'static>)>> {
182 let available_templates = available_agent_templates();
183 let available_agent_ids = available_templates.keys().cloned().collect::<Vec<_>>();
184 let mut selected_templates = Vec::new();
185
186 for agent_id in agent_ids {
187 if selected_templates
188 .iter()
189 .any(|(selected_agent_id, _)| selected_agent_id == agent_id)
190 {
191 continue;
192 }
193
194 let template_dir = available_templates.get(agent_id).with_context(|| {
195 format!(
196 "unknown agent template '{agent_id}'. Available templates: {}",
197 if available_agent_ids.is_empty() {
198 "(none)".to_string()
199 } else {
200 available_agent_ids.join(", ")
201 }
202 )
203 })?;
204 selected_templates.push((agent_id.clone(), *template_dir));
205 }
206
207 Ok(selected_templates)
208}
209
210fn available_agent_templates() -> BTreeMap<String, &'static Dir<'static>> {
211 let mut agent_templates = BTreeMap::new();
212
213 for template_dir in TEMPLATE_DIR.dirs() {
214 let Some(dir_name) = template_dir
215 .path()
216 .file_name()
217 .and_then(|name| name.to_str())
218 else {
219 continue;
220 };
221 let Some(agent_id) = dir_name.strip_prefix('.') else {
222 continue;
223 };
224
225 if agent_id.is_empty() {
226 continue;
227 }
228
229 agent_templates.insert(agent_id.to_string(), template_dir);
230 }
231
232 agent_templates
233}
234
235fn copy_agent_templates(
236 playground_dir: &Path,
237 agent_templates: &[(String, &'static Dir<'static>)],
238) -> Result<()> {
239 for (agent_id, template_dir) in agent_templates {
240 copy_embedded_dir(template_dir, &playground_dir.join(format!(".{agent_id}")))?;
241 }
242
243 Ok(())
244}
245
246fn copy_embedded_dir(template_dir: &'static Dir<'static>, destination: &Path) -> Result<()> {
247 fs::create_dir_all(destination)
248 .with_context(|| format!("failed to create {}", destination.display()))?;
249
250 for nested_dir in template_dir.dirs() {
251 let nested_dir_name = nested_dir.path().file_name().with_context(|| {
252 format!(
253 "embedded template path has no name: {}",
254 nested_dir.path().display()
255 )
256 })?;
257 copy_embedded_dir(nested_dir, &destination.join(nested_dir_name))?;
258 }
259
260 for file in template_dir.files() {
261 let file_name = file.path().file_name().with_context(|| {
262 format!(
263 "embedded template file has no name: {}",
264 file.path().display()
265 )
266 })?;
267 let destination_file = destination.join(file_name);
268 fs::write(&destination_file, file.contents())
269 .with_context(|| format!("failed to write {}", destination_file.display()))?;
270 }
271
272 Ok(())
273}
274
275fn ensure_root_initialized(paths: &ConfigPaths) -> Result<bool> {
276 fs::create_dir_all(&paths.root_dir)
277 .with_context(|| format!("failed to create {}", paths.root_dir.display()))?;
278 fs::create_dir_all(&paths.playgrounds_dir)
279 .with_context(|| format!("failed to create {}", paths.playgrounds_dir.display()))?;
280
281 if paths.config_file.exists() {
282 return Ok(false);
283 }
284
285 let saved_playgrounds_dir = default_saved_playgrounds_dir(paths);
286 let saved_playgrounds_dir = saved_playgrounds_dir.to_string_lossy();
287 let content = RootConfigTemplate {
288 saved_playgrounds_dir: saved_playgrounds_dir.as_ref(),
289 }
290 .render()
291 .context("failed to render root config template")?;
292 fs::write(&paths.config_file, content)
293 .with_context(|| format!("failed to write {}", paths.config_file.display()))?;
294
295 Ok(true)
296}
297
298fn load_root_config(paths: &ConfigPaths) -> Result<RawRootConfig> {
299 let mut config = default_root_config(paths)?;
300
301 let patch: RawRootConfigPatch = read_toml_file(&paths.config_file)?;
302 config.agent.extend(patch.agent);
303
304 if let Some(default_agent) = patch.default_agent {
305 config.default_agent = default_agent;
306 }
307
308 if let Some(saved_playgrounds_dir) = patch.saved_playgrounds_dir {
309 config.saved_playgrounds_dir = saved_playgrounds_dir;
310 }
311
312 Ok(config)
313}
314
315fn default_root_config(paths: &ConfigPaths) -> Result<RawRootConfig> {
316 let saved_playgrounds_dir = default_saved_playgrounds_dir(paths);
317 let saved_playgrounds_dir = saved_playgrounds_dir.to_string_lossy();
318 let content = RootConfigTemplate {
319 saved_playgrounds_dir: saved_playgrounds_dir.as_ref(),
320 }
321 .render()
322 .context("failed to render root config template")?;
323
324 toml::from_str(&content).context("failed to parse bundled root config template")
325}
326
327fn default_saved_playgrounds_dir(paths: &ConfigPaths) -> PathBuf {
328 paths.root_dir.join("saved-playgrounds")
329}
330
331fn resolve_saved_playgrounds_dir(root_dir: &Path, configured_path: PathBuf) -> PathBuf {
332 if configured_path.is_absolute() {
333 return configured_path;
334 }
335
336 root_dir.join(configured_path)
337}
338
339fn load_playgrounds(playgrounds_dir: &Path) -> Result<BTreeMap<String, PlaygroundDefinition>> {
340 if !playgrounds_dir.exists() {
341 return Ok(BTreeMap::new());
342 }
343
344 if !playgrounds_dir.is_dir() {
345 bail!(
346 "playground config path is not a directory: {}",
347 playgrounds_dir.display()
348 );
349 }
350
351 let mut playgrounds = BTreeMap::new();
352
353 for entry in fs::read_dir(playgrounds_dir)
354 .with_context(|| format!("failed to read {}", playgrounds_dir.display()))?
355 {
356 let entry = entry.with_context(|| {
357 format!(
358 "failed to inspect an entry under {}",
359 playgrounds_dir.display()
360 )
361 })?;
362 let file_type = entry.file_type().with_context(|| {
363 format!("failed to inspect file type for {}", entry.path().display())
364 })?;
365
366 if !file_type.is_dir() {
367 continue;
368 }
369
370 let directory = entry.path();
371 let config_file = directory.join(PLAYGROUND_CONFIG_FILE_NAME);
372
373 if !config_file.is_file() {
374 bail!(
375 "playground '{}' is missing {}",
376 directory.file_name().unwrap_or_default().to_string_lossy(),
377 PLAYGROUND_CONFIG_FILE_NAME
378 );
379 }
380
381 let raw_config: RawPlaygroundConfig = read_toml_file(&config_file)?;
382 let id = entry.file_name().to_string_lossy().into_owned();
383
384 playgrounds.insert(
385 id.clone(),
386 PlaygroundDefinition {
387 id,
388 description: raw_config.description,
389 directory,
390 config_file,
391 },
392 );
393 }
394
395 Ok(playgrounds)
396}
397
398fn read_toml_file<T>(path: &Path) -> Result<T>
399where
400 T: for<'de> Deserialize<'de>,
401{
402 let content =
403 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
404
405 toml::from_str(&content)
406 .with_context(|| format!("failed to parse TOML from {}", path.display()))
407}
408
409#[cfg(test)]
410mod tests {
411 use super::{APP_CONFIG_DIR, AppConfig, ConfigPaths, init_playground_at, user_config_base_dir};
412 use std::fs;
413 use tempfile::TempDir;
414
415 #[test]
416 fn init_creates_root_and_playground_configs_from_templates() {
417 let temp_dir = TempDir::new().expect("temp dir");
418 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
419
420 let result = init_playground_at(paths.clone(), "demo", &[]).expect("init should succeed");
421
422 assert!(result.root_config_created);
423 assert!(result.playground_config_created);
424 assert!(result.initialized_agent_templates.is_empty());
425 assert!(temp_dir.path().join("config.toml").is_file());
426 assert!(
427 temp_dir
428 .path()
429 .join("playgrounds")
430 .join("demo")
431 .join("apg.toml")
432 .is_file()
433 );
434 assert!(
435 !temp_dir
436 .path()
437 .join("playgrounds")
438 .join("demo")
439 .join(".claude")
440 .exists()
441 );
442
443 let config = AppConfig::load_from_paths(paths).expect("config should load");
444 assert_eq!(config.agents.get("claude"), Some(&"claude".to_string()));
445 assert_eq!(config.agents.get("opencode"), Some(&"opencode".to_string()));
446 assert_eq!(config.default_agent, "claude");
447 assert_eq!(
448 config.saved_playgrounds_dir,
449 temp_dir.path().join("saved-playgrounds")
450 );
451 assert_eq!(
452 config
453 .playgrounds
454 .get("demo")
455 .expect("demo playground")
456 .description,
457 "TODO: describe demo"
458 );
459 }
460
461 #[test]
462 fn merges_root_agents_and_loads_playgrounds() {
463 let temp_dir = TempDir::new().expect("temp dir");
464 let root = temp_dir.path();
465 fs::write(
466 root.join("config.toml"),
467 r#"default_agent = "codex"
468saved_playgrounds_dir = "archives"
469
470[agent]
471claude = "custom-claude"
472codex = "codex --fast"
473"#,
474 )
475 .expect("write root config");
476
477 let playground_dir = root.join("playgrounds").join("demo");
478 fs::create_dir_all(&playground_dir).expect("create playground dir");
479 fs::write(
480 playground_dir.join("apg.toml"),
481 r#"description = "Demo playground""#,
482 )
483 .expect("write playground config");
484
485 let config = AppConfig::load_from_paths(ConfigPaths::from_root_dir(root.to_path_buf()))
486 .expect("config should load");
487
488 assert_eq!(
489 config.agents.get("claude"),
490 Some(&"custom-claude".to_string())
491 );
492 assert_eq!(config.agents.get("opencode"), Some(&"opencode".to_string()));
493 assert_eq!(
494 config.agents.get("codex"),
495 Some(&"codex --fast".to_string())
496 );
497 assert_eq!(config.default_agent, "codex");
498 assert_eq!(config.saved_playgrounds_dir, root.join("archives"));
499
500 let playground = config.playgrounds.get("demo").expect("demo playground");
501 assert_eq!(playground.description, "Demo playground");
502 assert_eq!(playground.directory, playground_dir);
503 }
504
505 #[test]
506 fn load_auto_initializes_missing_root_config() {
507 let temp_dir = TempDir::new().expect("temp dir");
508 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
509
510 let config = AppConfig::load_from_paths(paths).expect("missing root config should init");
511
512 assert!(temp_dir.path().join("config.toml").is_file());
513 assert!(temp_dir.path().join("playgrounds").is_dir());
514 assert_eq!(config.agents.get("claude"), Some(&"claude".to_string()));
515 assert_eq!(config.default_agent, "claude");
516 assert_eq!(
517 config.saved_playgrounds_dir,
518 temp_dir.path().join("saved-playgrounds")
519 );
520 }
521
522 #[test]
523 fn respects_absolute_saved_playgrounds_dir() {
524 let temp_dir = TempDir::new().expect("temp dir");
525 let archive_dir = TempDir::new().expect("archive dir");
526 let archive_path = archive_dir.path().display().to_string();
527 fs::write(
528 temp_dir.path().join("config.toml"),
529 format!(
530 r#"saved_playgrounds_dir = "{}"
531
532[agent]
533claude = "claude"
534"#,
535 archive_path
536 ),
537 )
538 .expect("write root config");
539
540 let config =
541 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
542 .expect("config should load");
543
544 assert_eq!(config.saved_playgrounds_dir, archive_dir.path());
545 }
546
547 #[test]
548 fn errors_when_playground_config_is_missing() {
549 let temp_dir = TempDir::new().expect("temp dir");
550 fs::write(
551 temp_dir.path().join("config.toml"),
552 r#"[agent]
553claude = "claude"
554opencode = "opencode"
555"#,
556 )
557 .expect("write root config");
558 let playground_dir = temp_dir.path().join("playgrounds").join("broken");
559 fs::create_dir_all(&playground_dir).expect("create playground dir");
560
561 let error =
562 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
563 .expect_err("missing playground config should fail");
564
565 assert!(error.to_string().contains("missing apg.toml"));
566 }
567
568 #[test]
569 fn errors_when_default_agent_is_not_defined() {
570 let temp_dir = TempDir::new().expect("temp dir");
571 fs::write(
572 temp_dir.path().join("config.toml"),
573 r#"default_agent = "codex""#,
574 )
575 .expect("write root config");
576
577 let error =
578 AppConfig::load_from_paths(ConfigPaths::from_root_dir(temp_dir.path().to_path_buf()))
579 .expect_err("undefined default agent should fail");
580
581 assert!(
582 error
583 .to_string()
584 .contains("default agent 'codex' is not defined")
585 );
586 }
587
588 #[test]
589 fn init_errors_when_playground_already_exists() {
590 let temp_dir = TempDir::new().expect("temp dir");
591 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
592
593 init_playground_at(paths.clone(), "demo", &[]).expect("initial init should succeed");
594 let error = init_playground_at(paths, "demo", &[]).expect_err("duplicate init should fail");
595
596 assert!(
597 error
598 .to_string()
599 .contains("playground 'demo' already exists")
600 );
601 }
602
603 #[test]
604 fn init_copies_selected_agent_templates_into_playground() {
605 let temp_dir = TempDir::new().expect("temp dir");
606 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
607 let selected_agents = vec!["claude".to_string(), "codex".to_string()];
608
609 let result =
610 init_playground_at(paths, "demo", &selected_agents).expect("init should succeed");
611 let playground_dir = temp_dir.path().join("playgrounds").join("demo");
612
613 assert_eq!(
614 result.initialized_agent_templates,
615 vec!["claude".to_string(), "codex".to_string()]
616 );
617 assert!(
618 playground_dir
619 .join(".claude")
620 .join("settings.json")
621 .is_file()
622 );
623 assert!(playground_dir.join(".codex").join("config.toml").is_file());
624 assert!(!playground_dir.join(".opencode").exists());
625 }
626
627 #[test]
628 fn init_errors_for_unknown_agent_template_before_creating_playground() {
629 let temp_dir = TempDir::new().expect("temp dir");
630 let paths = ConfigPaths::from_root_dir(temp_dir.path().to_path_buf());
631 let selected_agents = vec!["missing".to_string()];
632
633 let error = init_playground_at(paths, "demo", &selected_agents)
634 .expect_err("unknown agent template should fail");
635
636 assert!(
637 error
638 .to_string()
639 .contains("unknown agent template 'missing'")
640 );
641 assert!(!temp_dir.path().join("playgrounds").join("demo").exists());
642 }
643
644 #[test]
645 fn user_config_dir_uses_dot_config_on_all_platforms() {
646 let base_dir = user_config_base_dir().expect("user config base dir");
647 let paths = ConfigPaths::from_user_config_dir().expect("user config paths");
648
649 assert!(base_dir.ends_with(".config"));
650 assert_eq!(paths.root_dir, base_dir.join(APP_CONFIG_DIR));
651 }
652}