devboy_skills/
embedded.rs1use 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#[derive(RustEmbed)]
28#[folder = "skills/"]
29#[include = "*/*/SKILL.md"]
30struct BaselineAssets;
31
32#[derive(Default, Clone)]
34pub struct EmbeddedSkillSource;
35
36impl EmbeddedSkillSource {
37 pub fn new() -> Self {
40 Self
41 }
42
43 fn iter() -> impl Iterator<Item = (String, Vec<u8>)> {
47 BaselineAssets::iter().filter_map(|path| {
48 let path_str = path.as_ref().to_string();
49 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 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 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 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 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 let all = EmbeddedSkillSource::all().unwrap();
182 let Some((name, _)) = all.iter().next() else {
183 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}