1use std::collections::BTreeMap;
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value as JsonValue;
8
9use crate::error::{Result, SkillError};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum Category {
16 SelfBootstrap,
19 IssueTracking,
21 CodeReview,
24 SelfFeedback,
27 MeetingNotes,
30 Messenger,
32}
33
34impl Category {
35 pub fn as_str(&self) -> &'static str {
38 match self {
39 Self::SelfBootstrap => "self-bootstrap",
40 Self::IssueTracking => "issue-tracking",
41 Self::CodeReview => "code-review",
42 Self::SelfFeedback => "self-feedback",
43 Self::MeetingNotes => "meeting-notes",
44 Self::Messenger => "messenger",
45 }
46 }
47
48 pub fn all() -> &'static [Category] {
51 &[
52 Self::SelfBootstrap,
53 Self::IssueTracking,
54 Self::CodeReview,
55 Self::SelfFeedback,
56 Self::MeetingNotes,
57 Self::Messenger,
58 ]
59 }
60
61 pub fn parse(s: &str) -> Option<Self> {
65 let trimmed = s.trim_start_matches(|c: char| c.is_ascii_digit() || c == '-');
66 match trimmed {
67 "self-bootstrap" => Some(Self::SelfBootstrap),
68 "issue-tracking" => Some(Self::IssueTracking),
69 "code-review" => Some(Self::CodeReview),
70 "self-feedback" => Some(Self::SelfFeedback),
71 "meeting-notes" => Some(Self::MeetingNotes),
72 "messenger" => Some(Self::Messenger),
73 _ => None,
74 }
75 }
76}
77
78impl fmt::Display for Category {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 f.write_str(self.as_str())
81 }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Frontmatter {
91 pub name: String,
93 pub description: String,
95 pub category: Category,
97 pub version: u32,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub compatibility: Option<String>,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub activation: Vec<String>,
107 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub tools: Vec<String>,
111 #[serde(flatten)]
114 pub extra: BTreeMap<String, JsonValue>,
115}
116
117#[derive(Debug, Clone)]
123pub struct Skill {
124 pub frontmatter: Frontmatter,
126 pub body: String,
128}
129
130impl Skill {
131 pub fn parse(skill_id: &str, contents: &str) -> Result<Self> {
136 let (raw_yaml, body) = split_frontmatter(skill_id, contents)?;
137
138 let yaml: serde_yaml::Value =
142 serde_yaml::from_str(raw_yaml).map_err(|source| SkillError::InvalidYaml {
143 skill: skill_id.to_string(),
144 source,
145 })?;
146
147 let mapping = yaml
148 .as_mapping()
149 .ok_or_else(|| SkillError::InvalidFieldType {
150 skill: skill_id.to_string(),
151 field: "<root>",
152 reason: "frontmatter must be a YAML mapping".into(),
153 })?;
154
155 require_string(mapping, skill_id, "name")?;
156 require_string(mapping, skill_id, "description")?;
157 require_string(mapping, skill_id, "category")?;
158 require_u32(mapping, skill_id, "version")?;
159
160 let category_str = mapping
163 .get(serde_yaml::Value::String("category".into()))
164 .and_then(|v| v.as_str())
165 .unwrap_or_default();
166 if Category::parse(category_str).is_none() {
167 return Err(SkillError::UnknownCategory {
168 skill: skill_id.to_string(),
169 category: category_str.to_string(),
170 });
171 }
172
173 let frontmatter: Frontmatter =
174 serde_yaml::from_value(yaml).map_err(|source| SkillError::InvalidYaml {
175 skill: skill_id.to_string(),
176 source,
177 })?;
178
179 if frontmatter.name != skill_id {
183 return Err(SkillError::InvalidFieldType {
184 skill: skill_id.to_string(),
185 field: "name",
186 reason: format!(
187 "must match the containing directory `{skill_id}`, got `{}`",
188 frontmatter.name
189 ),
190 });
191 }
192
193 Ok(Self {
194 frontmatter,
195 body: body.to_string(),
196 })
197 }
198
199 pub fn name(&self) -> &str {
201 &self.frontmatter.name
202 }
203
204 pub fn category(&self) -> Category {
206 self.frontmatter.category
207 }
208
209 pub fn version(&self) -> u32 {
211 self.frontmatter.version
212 }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct SkillSummary {
219 pub name: String,
220 pub category: Category,
221 pub version: u32,
223 pub description: String,
225}
226
227impl From<&Skill> for SkillSummary {
228 fn from(skill: &Skill) -> Self {
229 Self {
230 name: skill.frontmatter.name.clone(),
231 category: skill.frontmatter.category,
232 version: skill.frontmatter.version,
233 description: skill.frontmatter.description.clone(),
234 }
235 }
236}
237
238fn split_frontmatter<'a>(skill_id: &str, contents: &'a str) -> Result<(&'a str, &'a str)> {
243 let contents = contents.strip_prefix('\u{FEFF}').unwrap_or(contents);
244
245 let rest = contents
246 .strip_prefix("---")
247 .ok_or_else(|| SkillError::MissingFrontmatter {
248 skill: skill_id.to_string(),
249 })?;
250 let rest = rest.trim_start_matches('\r');
252 let rest = rest.strip_prefix('\n').unwrap_or(rest);
253
254 let close_idx =
256 find_line_starting_with(rest, "---").ok_or_else(|| SkillError::MissingFrontmatter {
257 skill: skill_id.to_string(),
258 })?;
259
260 let yaml = &rest[..close_idx];
261 let after_close = &rest[close_idx..];
262 let body = match after_close.find('\n') {
264 Some(idx) => &after_close[idx + 1..],
265 None => "",
266 };
267
268 Ok((yaml, body))
269}
270
271fn find_line_starting_with(haystack: &str, needle: &str) -> Option<usize> {
272 let mut idx = 0usize;
273 for line in haystack.split_inclusive('\n') {
274 let trimmed = line.trim_end_matches(['\r', '\n']);
275 if trimmed == needle {
276 return Some(idx);
277 }
278 idx += line.len();
279 }
280 None
281}
282
283fn require_string(mapping: &serde_yaml::Mapping, skill: &str, field: &'static str) -> Result<()> {
284 let value = mapping.get(serde_yaml::Value::String(field.into()));
285 match value {
286 None | Some(serde_yaml::Value::Null) => Err(SkillError::MissingRequiredField {
287 skill: skill.to_string(),
288 field,
289 }),
290 Some(v) if v.as_str().is_some() => Ok(()),
291 Some(_) => Err(SkillError::InvalidFieldType {
292 skill: skill.to_string(),
293 field,
294 reason: "expected a string".into(),
295 }),
296 }
297}
298
299fn require_u32(mapping: &serde_yaml::Mapping, skill: &str, field: &'static str) -> Result<()> {
300 let value = mapping.get(serde_yaml::Value::String(field.into()));
301 match value {
302 None | Some(serde_yaml::Value::Null) => Err(SkillError::MissingRequiredField {
303 skill: skill.to_string(),
304 field,
305 }),
306 Some(v) => match v.as_u64() {
307 Some(n) if n <= u64::from(u32::MAX) => Ok(()),
308 _ => Err(SkillError::InvalidFieldType {
309 skill: skill.to_string(),
310 field,
311 reason: "expected a non-negative integer that fits in u32".into(),
312 }),
313 },
314 }
315}
316
317#[cfg(test)]
322mod tests {
323 use super::*;
324
325 const VALID: &str = r#"---
326name: setup
327description: Walk the user through initial devboy configuration.
328category: self-bootstrap
329version: 1
330compatibility: devboy-tools >= 0.18
331activation:
332 - "configure devboy"
333 - "setup devboy"
334tools:
335 - doctor
336 - config
337---
338
339# setup
340
341Body goes here.
342"#;
343
344 #[test]
345 fn parses_valid_skill() {
346 let skill = Skill::parse("setup", VALID).expect("valid skill parses");
347 assert_eq!(skill.name(), "setup");
348 assert_eq!(skill.category(), Category::SelfBootstrap);
349 assert_eq!(skill.version(), 1);
350 assert_eq!(skill.frontmatter.activation.len(), 2);
351 assert!(skill.body.contains("Body goes here"));
352 }
353
354 #[test]
355 fn rejects_missing_frontmatter() {
356 let input = "no frontmatter here\n";
357 let err = Skill::parse("foo", input).unwrap_err();
358 assert!(matches!(err, SkillError::MissingFrontmatter { .. }));
359 }
360
361 #[test]
362 fn rejects_missing_required_field() {
363 let input = r#"---
364name: setup
365description: test
366category: self-bootstrap
367---
368body
369"#;
370 let err = Skill::parse("setup", input).unwrap_err();
371 assert!(
372 matches!(
373 err,
374 SkillError::MissingRequiredField {
375 field: "version",
376 ..
377 }
378 ),
379 "expected MissingRequiredField(version), got {err:?}"
380 );
381 }
382
383 #[test]
384 fn rejects_wrong_field_type() {
385 let input = r#"---
386name: setup
387description: test
388category: self-bootstrap
389version: "not a number"
390---
391body
392"#;
393 let err = Skill::parse("setup", input).unwrap_err();
394 assert!(
395 matches!(
396 err,
397 SkillError::InvalidFieldType {
398 field: "version",
399 ..
400 }
401 ),
402 "expected InvalidFieldType(version), got {err:?}"
403 );
404 }
405
406 #[test]
407 fn rejects_unknown_category() {
408 let input = r#"---
409name: setup
410description: test
411category: not-a-real-category
412version: 1
413---
414body
415"#;
416 let err = Skill::parse("setup", input).unwrap_err();
417 assert!(
418 matches!(err, SkillError::UnknownCategory { ref category, .. } if category == "not-a-real-category"),
419 "expected UnknownCategory, got {err:?}"
420 );
421 }
422
423 #[test]
424 fn preserves_unknown_frontmatter_fields() {
425 let input = r#"---
426name: setup
427description: test
428category: self-bootstrap
429version: 1
430x-custom-vendor-field: hello
431---
432body
433"#;
434 let skill = Skill::parse("setup", input).unwrap();
435 assert!(
436 skill
437 .frontmatter
438 .extra
439 .contains_key("x-custom-vendor-field")
440 );
441 }
442
443 #[test]
444 fn category_round_trip() {
445 for cat in Category::all() {
446 let parsed = Category::parse(cat.as_str()).unwrap();
447 assert_eq!(parsed, *cat);
448 }
449 assert_eq!(
450 Category::parse("00-self-bootstrap"),
451 Some(Category::SelfBootstrap)
452 );
453 assert!(Category::parse("not-real").is_none());
454 }
455
456 #[test]
457 fn summary_from_skill() {
458 let skill = Skill::parse("setup", VALID).unwrap();
459 let sum = SkillSummary::from(&skill);
460 assert_eq!(sum.name, "setup");
461 assert_eq!(sum.category, Category::SelfBootstrap);
462 }
463
464 #[test]
465 fn category_as_str_matches_parse() {
466 for cat in Category::all() {
467 assert_eq!(cat.as_str(), format!("{}", cat));
469 assert_eq!(Category::parse(cat.as_str()), Some(*cat));
470 }
471 }
472
473 #[test]
474 fn parse_accepts_numeric_prefix_directory_form() {
475 assert_eq!(
479 Category::parse("01-issue-tracking"),
480 Some(Category::IssueTracking)
481 );
482 assert_eq!(Category::parse("05-messenger"), Some(Category::Messenger));
483 assert_eq!(
485 Category::parse("42-self-bootstrap"),
486 Some(Category::SelfBootstrap)
487 );
488 }
489
490 #[test]
491 fn parse_rejects_frontmatter_without_closing_fence() {
492 let input = "---\nname: setup\ndescription: incomplete\ncategory: self-bootstrap\nversion: 1\nbody starts here\n";
495 let err = Skill::parse("setup", input).unwrap_err();
496 assert!(
497 matches!(err, SkillError::MissingFrontmatter { .. }),
498 "expected MissingFrontmatter, got {err:?}"
499 );
500 }
501
502 #[test]
503 fn parse_strips_bom_if_present() {
504 let bom_input = format!("\u{FEFF}{VALID}");
507 let skill = Skill::parse("setup", &bom_input).expect("BOM-prefixed file parses");
508 assert_eq!(skill.name(), "setup");
509 }
510
511 #[test]
512 fn parse_rejects_non_mapping_frontmatter() {
513 let input = "---\n- list-not-mapping\n- another\n---\nbody\n";
516 let err = Skill::parse("whatever", input).unwrap_err();
517 assert!(
518 matches!(
519 err,
520 SkillError::InvalidFieldType {
521 field: "<root>",
522 ..
523 }
524 ),
525 "expected InvalidFieldType(<root>), got {err:?}"
526 );
527 }
528
529 #[test]
530 fn parse_rejects_mismatched_name_and_skill_id() {
531 let input = r#"---
535name: wrong-name
536description: test
537category: self-bootstrap
538version: 1
539---
540body
541"#;
542 let err = Skill::parse("setup", input).unwrap_err();
543 assert!(
544 matches!(err, SkillError::InvalidFieldType { field: "name", .. }),
545 "expected InvalidFieldType(name), got {err:?}"
546 );
547 }
548
549 #[test]
550 fn parse_rejects_negative_version() {
551 let input = r#"---
552name: setup
553description: test
554category: self-bootstrap
555version: -1
556---
557body
558"#;
559 let err = Skill::parse("setup", input).unwrap_err();
560 assert!(
563 matches!(
564 err,
565 SkillError::InvalidFieldType {
566 field: "version",
567 ..
568 }
569 ),
570 "expected InvalidFieldType(version), got {err:?}"
571 );
572 }
573}