Skip to main content

devboy_core/agents/
bundles.rs

1//! Skill bundle profiles for `devboy onboard`.
2//!
3//! A bundle is a TOML file under `bundles/<profile>.toml` (relative to this
4//! crate) listing skill ids that should be installed for a given persona
5//! (engineer, PM, on-call). The TOML is `include_str!`-ed at build time so
6//! bundles ship inside the binary — no extra files to look up at runtime.
7
8use anyhow::{Result, anyhow};
9use serde::Deserialize;
10
11const DEV: &str = include_str!("../../bundles/dev.toml");
12const PM: &str = include_str!("../../bundles/pm.toml");
13const ONCALL: &str = include_str!("../../bundles/oncall.toml");
14
15#[derive(Debug, Clone, Deserialize)]
16pub struct Bundle {
17    pub name: String,
18    pub description: String,
19    pub skills: Vec<String>,
20}
21
22/// All known bundle ids, in display order.
23pub const PROFILES: &[&str] = &["dev", "pm", "oncall"];
24
25/// Load a bundle by profile id.
26pub fn load(profile: &str) -> Result<Bundle> {
27    let raw = match profile {
28        "dev" => DEV,
29        "pm" => PM,
30        "oncall" => ONCALL,
31        other => {
32            return Err(anyhow!(
33                "unknown profile: {other} (known: {})",
34                PROFILES.join(", ")
35            ));
36        }
37    };
38    toml::from_str::<Bundle>(raw).map_err(|e| anyhow!("failed to parse {profile}.toml: {e}"))
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44
45    #[test]
46    fn all_profiles_load() {
47        for p in PROFILES {
48            let b = load(p).unwrap_or_else(|e| panic!("{p}: {e}"));
49            assert_eq!(b.name, *p);
50            assert!(!b.skills.is_empty(), "{p} has no skills");
51        }
52    }
53
54    #[test]
55    fn unknown_profile_errors() {
56        assert!(load("ceo").is_err());
57    }
58
59    #[test]
60    fn dev_includes_analyze_usage() {
61        let b = load("dev").unwrap();
62        assert!(b.skills.contains(&"analyze-usage".to_string()));
63    }
64
65    #[test]
66    fn each_profile_has_unique_skill_ids() {
67        for p in PROFILES {
68            let b = load(p).unwrap();
69            let mut deduped = b.skills.clone();
70            deduped.sort();
71            deduped.dedup();
72            assert_eq!(
73                deduped.len(),
74                b.skills.len(),
75                "profile '{p}' has duplicate skills"
76            );
77        }
78    }
79
80    #[test]
81    fn each_profile_starts_with_self_bootstrap() {
82        for p in PROFILES {
83            let b = load(p).unwrap();
84            assert!(
85                b.skills.iter().any(|s| s == "setup"),
86                "profile '{p}' missing setup"
87            );
88        }
89    }
90
91    #[test]
92    fn unknown_profile_error_lists_known_profiles() {
93        let err = load("ceo").unwrap_err().to_string();
94        for p in PROFILES {
95            assert!(
96                err.contains(p),
97                "error message missing profile '{p}': {err}"
98            );
99        }
100    }
101
102    #[test]
103    fn pm_profile_emphasises_meeting_skills() {
104        let b = load("pm").unwrap();
105        assert!(b.skills.iter().any(|s| s.starts_with("meeting-")));
106    }
107
108    #[test]
109    fn oncall_profile_includes_notify() {
110        let b = load("oncall").unwrap();
111        assert!(b.skills.iter().any(|s| s == "notify"));
112    }
113}