Skip to main content

devboy_skills/
embedded.rs

1//! [`EmbeddedSkillSource`] — baseline skills compiled into the binary.
2//!
3//! The on-disk layout is the `skills/` tree at the repository root. At
4//! build time [`rust_embed`] pulls every `SKILL.md` under that tree into
5//! the binary so installed users never need the repository itself.
6//!
7//! The matching install-time historical hash registry lives next to this
8//! module (see [`crate::manifest::HistoricalHashes`]) — the two are kept
9//! in lock-step so an upgrade can tell whether a file on disk is the
10//! current shipped version, a previous shipped version (safe to
11//! auto-upgrade), or a user modification.
12
13use std::collections::BTreeMap;
14
15use async_trait::async_trait;
16use rust_embed::RustEmbed;
17
18use crate::error::{Result, SkillError};
19use crate::skill::{Skill, SkillSummary};
20use crate::source::SkillSource;
21
22/// Embedder wrapper around the in-crate `skills/` directory.
23///
24/// The `RustEmbed` folder path is relative to the crate root. The tree
25/// lives inside the crate (at `crates/devboy-skills/skills/`) so that
26/// `cargo publish` can package it — see ADR-022 for the rationale.
27#[derive(RustEmbed)]
28#[folder = "skills/"]
29#[include = "*/*/SKILL.md"]
30struct BaselineAssets;
31
32/// Source that reads skills embedded into the binary at build time.
33#[derive(Default, Clone)]
34pub struct EmbeddedSkillSource;
35
36impl EmbeddedSkillSource {
37    /// Create a new embedded source. Construction is free — all work
38    /// happens on `list` / `load`.
39    pub fn new() -> Self {
40        Self
41    }
42
43    /// Iterate over the (skill-name, file-bytes) pairs produced by the
44    /// embedder, skipping anything that does not match the
45    /// `<category>/<skill>/SKILL.md` shape.
46    fn iter() -> impl Iterator<Item = (String, Vec<u8>)> {
47        BaselineAssets::iter().filter_map(|path| {
48            let path_str = path.as_ref().to_string();
49            // Expect exactly three path segments (category, skill, SKILL.md).
50            let parts: Vec<&str> = path_str.split('/').collect();
51            if parts.len() != 3 || parts[2] != "SKILL.md" {
52                return None;
53            }
54            let skill_dir = parts[1].to_string();
55            BaselineAssets::get(&path_str).map(|f| (skill_dir, f.data.into_owned()))
56        })
57    }
58
59    fn load_skill(name: &str) -> Result<Skill> {
60        let contents = Self::iter()
61            .find(|(n, _)| n == name)
62            .map(|(_, bytes)| bytes)
63            .ok_or_else(|| SkillError::NotFound {
64                name: name.to_string(),
65                source_name: "embedded",
66            })?;
67        let text = String::from_utf8(contents).map_err(|e| SkillError::InvalidFieldType {
68            skill: name.to_string(),
69            field: "<body>",
70            reason: format!("SKILL.md is not valid UTF-8: {e}"),
71        })?;
72        Skill::parse(name, &text)
73    }
74
75    /// Return every embedded skill, keyed by skill name. Useful for
76    /// callers that want to iterate without going through
77    /// [`SkillSource::list`].
78    pub fn all() -> Result<BTreeMap<String, Skill>> {
79        let mut out = BTreeMap::new();
80        for (name, bytes) in Self::iter() {
81            let text = String::from_utf8(bytes).map_err(|e| SkillError::InvalidFieldType {
82                skill: name.clone(),
83                field: "<body>",
84                reason: format!("SKILL.md is not valid UTF-8: {e}"),
85            })?;
86            out.insert(name.clone(), Skill::parse(&name, &text)?);
87        }
88        Ok(out)
89    }
90}
91
92#[async_trait]
93impl SkillSource for EmbeddedSkillSource {
94    fn name(&self) -> &'static str {
95        "embedded"
96    }
97
98    async fn list(&self) -> Result<Vec<SkillSummary>> {
99        let mut summaries = Vec::new();
100        for (name, bytes) in Self::iter() {
101            let text = String::from_utf8(bytes).map_err(|e| SkillError::InvalidFieldType {
102                skill: name.clone(),
103                field: "<body>",
104                reason: format!("SKILL.md is not valid UTF-8: {e}"),
105            })?;
106            let skill = Skill::parse(&name, &text)?;
107            summaries.push(SkillSummary::from(&skill));
108        }
109        summaries.sort_by(|a, b| (a.category, &a.name).cmp(&(b.category, &b.name)));
110        Ok(summaries)
111    }
112
113    async fn load(&self, name: &str) -> Result<Skill> {
114        Self::load_skill(name)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[tokio::test]
123    async fn list_returns_empty_or_valid_before_any_skills_ship() {
124        // Until the first baseline skill lands under `skills/`, the
125        // embedder has nothing to produce. Either way, `list` must not
126        // error — it just returns an empty vec or a set of valid
127        // summaries.
128        let source = EmbeddedSkillSource::new();
129        let summaries = source.list().await.expect("list should not fail");
130        for s in &summaries {
131            assert!(!s.name.is_empty());
132            assert!(!s.description.is_empty());
133        }
134    }
135
136    #[tokio::test]
137    async fn load_missing_skill_returns_not_found() {
138        let source = EmbeddedSkillSource::new();
139        let err = source.load("does-not-exist").await.unwrap_err();
140        assert!(
141            matches!(
142                err,
143                SkillError::NotFound {
144                    source_name: "embedded",
145                    ..
146                }
147            ),
148            "expected NotFound(embedded), got {err:?}"
149        );
150    }
151
152    #[test]
153    fn source_name_is_embedded() {
154        let source = EmbeddedSkillSource::new();
155        assert_eq!(source.name(), "embedded");
156    }
157
158    #[test]
159    fn all_returns_every_embedded_skill() {
160        // `all()` should match `iter()` in shape: every entry parses as
161        // a Skill with a non-empty name and a category consistent with
162        // the on-disk folder tree (`skills/<NN-category>/<skill>/SKILL.md`).
163        let all = EmbeddedSkillSource::all().expect("embedded skills parse");
164        for (name, skill) in &all {
165            assert_eq!(name, skill.name(), "map key matches skill name");
166            assert!(!skill.frontmatter.description.is_empty());
167            assert!(skill.version() > 0);
168        }
169        // `list()` covers every skill `all()` does, just as summaries.
170        let summaries = tokio::runtime::Runtime::new()
171            .unwrap()
172            .block_on(EmbeddedSkillSource::new().list())
173            .unwrap();
174        assert_eq!(summaries.len(), all.len());
175    }
176
177    #[tokio::test]
178    async fn load_returns_parsed_skill_for_known_name() {
179        // Pick whichever skill `all()` produces first — avoids tying the
180        // test to a specific skill that may be renamed later.
181        let all = EmbeddedSkillSource::all().unwrap();
182        let Some((name, _)) = all.iter().next() else {
183            // If the catalogue is empty for some build, the earlier
184            // `list_returns_empty_or_valid_before_any_skills_ship`
185            // test already covers that case.
186            return;
187        };
188        let source = EmbeddedSkillSource::new();
189        let skill = source.load(name).await.expect("known skill loads");
190        assert_eq!(skill.name(), name);
191    }
192
193    #[tokio::test]
194    async fn list_is_sorted_by_category_then_name() {
195        let source = EmbeddedSkillSource::new();
196        let summaries = source.list().await.unwrap();
197        for pair in summaries.windows(2) {
198            let (a, b) = (&pair[0], &pair[1]);
199            let ord = (a.category, a.name.as_str()).cmp(&(b.category, b.name.as_str()));
200            assert!(
201                ord == std::cmp::Ordering::Less || ord == std::cmp::Ordering::Equal,
202                "summaries not sorted: {} then {}",
203                a.name,
204                b.name
205            );
206        }
207    }
208}