Skip to main content

devboy_skills/
skill.rs

1//! Skill, frontmatter parsing, and category enumeration.
2
3use 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/// One of the six baseline skill categories. Keep in sync with the
12/// on-disk `skills/<NN-category>/` layout and with [`Self::parse`].
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum Category {
16    /// `skills/00-self-bootstrap/` — skills that configure or repair
17    /// devboy itself.
18    SelfBootstrap,
19    /// `skills/01-issue-tracking/` — skills that operate on issues.
20    IssueTracking,
21    /// `skills/02-code-review/` — skills that operate on merge / pull
22    /// requests.
23    CodeReview,
24    /// `skills/03-self-feedback/` — skills that read session traces to
25    /// produce retros, daily reports, knowledge extracts.
26    SelfFeedback,
27    /// `skills/04-meeting-notes/` — skills that work with meeting
28    /// transcripts and summaries.
29    MeetingNotes,
30    /// `skills/05-messenger/` — skills that interact with chats.
31    Messenger,
32}
33
34impl Category {
35    /// Return the canonical kebab-case identifier used on disk and in the
36    /// frontmatter.
37    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    /// Iterate over every known category in the order categories appear
49    /// in the on-disk directory tree.
50    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    /// Parse a string identifier into a category. Accepts both the
62    /// kebab-case canonical form (`self-bootstrap`) and the numeric-prefix
63    /// directory form (`00-self-bootstrap`).
64    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/// Metadata extracted from the YAML frontmatter of a `SKILL.md` file.
85///
86/// Required fields are modelled as strongly-typed values; unknown fields
87/// are preserved in [`Self::extra`] so agent-specific extensions (custom
88/// activation rules, capability declarations, …) are not silently dropped.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Frontmatter {
91    /// Skill name. Must match the containing directory.
92    pub name: String,
93    /// One-line description used by `devboy skills list`.
94    pub description: String,
95    /// Category that drives filtering and the install-layout path.
96    pub category: Category,
97    /// Integer version bumped on every change.
98    pub version: u32,
99    /// Optional semver range against the tool bundle (e.g.
100    /// `"devboy-tools >= 0.18"`).
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub compatibility: Option<String>,
103    /// Optional list of trigger phrases for agents that support
104    /// activation-based skill invocation.
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub activation: Vec<String>,
107    /// Optional list of tools the skill calls. Used by the future
108    /// compatibility checker described in ADR-014.
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub tools: Vec<String>,
111    /// Any frontmatter fields we did not explicitly model. Preserved so
112    /// agent-specific keys survive a round-trip.
113    #[serde(flatten)]
114    pub extra: BTreeMap<String, JsonValue>,
115}
116
117/// A fully-loaded skill: parsed frontmatter plus the Markdown body.
118///
119/// Constructing a `Skill` runs the validation in [`Skill::parse`]; the
120/// public constructors either produce a valid instance or a
121/// [`SkillError`].
122#[derive(Debug, Clone)]
123pub struct Skill {
124    /// Parsed frontmatter.
125    pub frontmatter: Frontmatter,
126    /// Markdown body (everything after the closing `---` delimiter).
127    pub body: String,
128}
129
130impl Skill {
131    /// Parse a full `SKILL.md` payload. Returns [`SkillError`] for any
132    /// frontmatter-level problem; callers should supply the skill's
133    /// directory name as `skill_id` so error messages can point at the
134    /// right place on disk.
135    pub fn parse(skill_id: &str, contents: &str) -> Result<Self> {
136        let (raw_yaml, body) = split_frontmatter(skill_id, contents)?;
137
138        // Parse into a raw serde_yaml::Value first so we can surface
139        // missing-required-field errors with the canonical name rather
140        // than whatever serde's derive happens to use.
141        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        // Validate the category explicitly so we produce a nice
161        // UnknownCategory error rather than a generic deserialise error.
162        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        // The frontmatter `name` field is documented as "must match the
180        // containing directory". Enforce that here so the expectation is
181        // actually checked rather than being a suggestion.
182        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    /// Convenience accessor for the skill's name.
200    pub fn name(&self) -> &str {
201        &self.frontmatter.name
202    }
203
204    /// Convenience accessor for the skill's category.
205    pub fn category(&self) -> Category {
206        self.frontmatter.category
207    }
208
209    /// Convenience accessor for the skill's version.
210    pub fn version(&self) -> u32 {
211        self.frontmatter.version
212    }
213}
214
215/// Lightweight summary used by `devboy skills list` — avoids loading the
216/// full body when a source only needs the names and descriptions.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct SkillSummary {
219    pub name: String,
220    pub category: Category,
221    /// Integer version.
222    pub version: u32,
223    /// One-line description.
224    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
238// ---------------------------------------------------------------------------
239// internals
240// ---------------------------------------------------------------------------
241
242fn 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    // The opening fence line can have trailing whitespace or a newline.
251    let rest = rest.trim_start_matches('\r');
252    let rest = rest.strip_prefix('\n').unwrap_or(rest);
253
254    // Find the closing fence — must sit on its own line.
255    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    // Skip the closing `---` line itself (and any `\r\n`).
263    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// ---------------------------------------------------------------------------
318// tests
319// ---------------------------------------------------------------------------
320
321#[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            // Display == as_str and both round-trip through parse.
468            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        // The filesystem layout uses `<NN>-<category>/` directory
476        // names (`01-issue-tracking`, `05-messenger`, …). The parser
477        // has to accept those too.
478        assert_eq!(
479            Category::parse("01-issue-tracking"),
480            Some(Category::IssueTracking)
481        );
482        assert_eq!(Category::parse("05-messenger"), Some(Category::Messenger));
483        // Double-digit-only prefix is also accepted (harmless).
484        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        // Missing closing `---` must yield MissingFrontmatter, not
493        // a YAML error on the half-parsed body.
494        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        // UTF-8 BOM at the very start must not confuse the fence
505        // detector.
506        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        // Frontmatter that is valid YAML but not a mapping (list,
514        // scalar, etc.) must error out.
515        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        // `frontmatter.name` is contractually "must match the
532        // containing directory"; enforce by constructing a payload
533        // where the two disagree.
534        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        // Negative values serialise as i64 in serde_yaml, so they
561        // fail the `as_u64()` guard in `require_u32`.
562        assert!(
563            matches!(
564                err,
565                SkillError::InvalidFieldType {
566                    field: "version",
567                    ..
568                }
569            ),
570            "expected InvalidFieldType(version), got {err:?}"
571        );
572    }
573}