Skip to main content

agent_playground/
config.rs

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}