use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use crate::error::{Result, SkillError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Category {
SelfBootstrap,
IssueTracking,
CodeReview,
SelfFeedback,
MeetingNotes,
Messenger,
}
impl Category {
pub fn as_str(&self) -> &'static str {
match self {
Self::SelfBootstrap => "self-bootstrap",
Self::IssueTracking => "issue-tracking",
Self::CodeReview => "code-review",
Self::SelfFeedback => "self-feedback",
Self::MeetingNotes => "meeting-notes",
Self::Messenger => "messenger",
}
}
pub fn all() -> &'static [Category] {
&[
Self::SelfBootstrap,
Self::IssueTracking,
Self::CodeReview,
Self::SelfFeedback,
Self::MeetingNotes,
Self::Messenger,
]
}
pub fn parse(s: &str) -> Option<Self> {
let trimmed = s.trim_start_matches(|c: char| c.is_ascii_digit() || c == '-');
match trimmed {
"self-bootstrap" => Some(Self::SelfBootstrap),
"issue-tracking" => Some(Self::IssueTracking),
"code-review" => Some(Self::CodeReview),
"self-feedback" => Some(Self::SelfFeedback),
"meeting-notes" => Some(Self::MeetingNotes),
"messenger" => Some(Self::Messenger),
_ => None,
}
}
}
impl fmt::Display for Category {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Frontmatter {
pub name: String,
pub description: String,
pub category: Category,
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compatibility: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub activation: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, JsonValue>,
}
#[derive(Debug, Clone)]
pub struct Skill {
pub frontmatter: Frontmatter,
pub body: String,
}
impl Skill {
pub fn parse(skill_id: &str, contents: &str) -> Result<Self> {
let (raw_yaml, body) = split_frontmatter(skill_id, contents)?;
let yaml: serde_yaml::Value =
serde_yaml::from_str(raw_yaml).map_err(|source| SkillError::InvalidYaml {
skill: skill_id.to_string(),
source,
})?;
let mapping = yaml
.as_mapping()
.ok_or_else(|| SkillError::InvalidFieldType {
skill: skill_id.to_string(),
field: "<root>",
reason: "frontmatter must be a YAML mapping".into(),
})?;
require_string(mapping, skill_id, "name")?;
require_string(mapping, skill_id, "description")?;
require_string(mapping, skill_id, "category")?;
require_u32(mapping, skill_id, "version")?;
let category_str = mapping
.get(serde_yaml::Value::String("category".into()))
.and_then(|v| v.as_str())
.unwrap_or_default();
if Category::parse(category_str).is_none() {
return Err(SkillError::UnknownCategory {
skill: skill_id.to_string(),
category: category_str.to_string(),
});
}
let frontmatter: Frontmatter =
serde_yaml::from_value(yaml).map_err(|source| SkillError::InvalidYaml {
skill: skill_id.to_string(),
source,
})?;
if frontmatter.name != skill_id {
return Err(SkillError::InvalidFieldType {
skill: skill_id.to_string(),
field: "name",
reason: format!(
"must match the containing directory `{skill_id}`, got `{}`",
frontmatter.name
),
});
}
Ok(Self {
frontmatter,
body: body.to_string(),
})
}
pub fn name(&self) -> &str {
&self.frontmatter.name
}
pub fn category(&self) -> Category {
self.frontmatter.category
}
pub fn version(&self) -> u32 {
self.frontmatter.version
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillSummary {
pub name: String,
pub category: Category,
pub version: u32,
pub description: String,
}
impl From<&Skill> for SkillSummary {
fn from(skill: &Skill) -> Self {
Self {
name: skill.frontmatter.name.clone(),
category: skill.frontmatter.category,
version: skill.frontmatter.version,
description: skill.frontmatter.description.clone(),
}
}
}
fn split_frontmatter<'a>(skill_id: &str, contents: &'a str) -> Result<(&'a str, &'a str)> {
let contents = contents.strip_prefix('\u{FEFF}').unwrap_or(contents);
let rest = contents
.strip_prefix("---")
.ok_or_else(|| SkillError::MissingFrontmatter {
skill: skill_id.to_string(),
})?;
let rest = rest.trim_start_matches('\r');
let rest = rest.strip_prefix('\n').unwrap_or(rest);
let close_idx =
find_line_starting_with(rest, "---").ok_or_else(|| SkillError::MissingFrontmatter {
skill: skill_id.to_string(),
})?;
let yaml = &rest[..close_idx];
let after_close = &rest[close_idx..];
let body = match after_close.find('\n') {
Some(idx) => &after_close[idx + 1..],
None => "",
};
Ok((yaml, body))
}
fn find_line_starting_with(haystack: &str, needle: &str) -> Option<usize> {
let mut idx = 0usize;
for line in haystack.split_inclusive('\n') {
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed == needle {
return Some(idx);
}
idx += line.len();
}
None
}
fn require_string(mapping: &serde_yaml::Mapping, skill: &str, field: &'static str) -> Result<()> {
let value = mapping.get(serde_yaml::Value::String(field.into()));
match value {
None | Some(serde_yaml::Value::Null) => Err(SkillError::MissingRequiredField {
skill: skill.to_string(),
field,
}),
Some(v) if v.as_str().is_some() => Ok(()),
Some(_) => Err(SkillError::InvalidFieldType {
skill: skill.to_string(),
field,
reason: "expected a string".into(),
}),
}
}
fn require_u32(mapping: &serde_yaml::Mapping, skill: &str, field: &'static str) -> Result<()> {
let value = mapping.get(serde_yaml::Value::String(field.into()));
match value {
None | Some(serde_yaml::Value::Null) => Err(SkillError::MissingRequiredField {
skill: skill.to_string(),
field,
}),
Some(v) => match v.as_u64() {
Some(n) if n <= u64::from(u32::MAX) => Ok(()),
_ => Err(SkillError::InvalidFieldType {
skill: skill.to_string(),
field,
reason: "expected a non-negative integer that fits in u32".into(),
}),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
const VALID: &str = r#"---
name: setup
description: Walk the user through initial devboy configuration.
category: self-bootstrap
version: 1
compatibility: devboy-tools >= 0.18
activation:
- "configure devboy"
- "setup devboy"
tools:
- doctor
- config
---
# setup
Body goes here.
"#;
#[test]
fn parses_valid_skill() {
let skill = Skill::parse("setup", VALID).expect("valid skill parses");
assert_eq!(skill.name(), "setup");
assert_eq!(skill.category(), Category::SelfBootstrap);
assert_eq!(skill.version(), 1);
assert_eq!(skill.frontmatter.activation.len(), 2);
assert!(skill.body.contains("Body goes here"));
}
#[test]
fn rejects_missing_frontmatter() {
let input = "no frontmatter here\n";
let err = Skill::parse("foo", input).unwrap_err();
assert!(matches!(err, SkillError::MissingFrontmatter { .. }));
}
#[test]
fn rejects_missing_required_field() {
let input = r#"---
name: setup
description: test
category: self-bootstrap
---
body
"#;
let err = Skill::parse("setup", input).unwrap_err();
assert!(
matches!(
err,
SkillError::MissingRequiredField {
field: "version",
..
}
),
"expected MissingRequiredField(version), got {err:?}"
);
}
#[test]
fn rejects_wrong_field_type() {
let input = r#"---
name: setup
description: test
category: self-bootstrap
version: "not a number"
---
body
"#;
let err = Skill::parse("setup", input).unwrap_err();
assert!(
matches!(
err,
SkillError::InvalidFieldType {
field: "version",
..
}
),
"expected InvalidFieldType(version), got {err:?}"
);
}
#[test]
fn rejects_unknown_category() {
let input = r#"---
name: setup
description: test
category: not-a-real-category
version: 1
---
body
"#;
let err = Skill::parse("setup", input).unwrap_err();
assert!(
matches!(err, SkillError::UnknownCategory { ref category, .. } if category == "not-a-real-category"),
"expected UnknownCategory, got {err:?}"
);
}
#[test]
fn preserves_unknown_frontmatter_fields() {
let input = r#"---
name: setup
description: test
category: self-bootstrap
version: 1
x-custom-vendor-field: hello
---
body
"#;
let skill = Skill::parse("setup", input).unwrap();
assert!(
skill
.frontmatter
.extra
.contains_key("x-custom-vendor-field")
);
}
#[test]
fn category_round_trip() {
for cat in Category::all() {
let parsed = Category::parse(cat.as_str()).unwrap();
assert_eq!(parsed, *cat);
}
assert_eq!(
Category::parse("00-self-bootstrap"),
Some(Category::SelfBootstrap)
);
assert!(Category::parse("not-real").is_none());
}
#[test]
fn summary_from_skill() {
let skill = Skill::parse("setup", VALID).unwrap();
let sum = SkillSummary::from(&skill);
assert_eq!(sum.name, "setup");
assert_eq!(sum.category, Category::SelfBootstrap);
}
#[test]
fn category_as_str_matches_parse() {
for cat in Category::all() {
assert_eq!(cat.as_str(), format!("{}", cat));
assert_eq!(Category::parse(cat.as_str()), Some(*cat));
}
}
#[test]
fn parse_accepts_numeric_prefix_directory_form() {
assert_eq!(
Category::parse("01-issue-tracking"),
Some(Category::IssueTracking)
);
assert_eq!(Category::parse("05-messenger"), Some(Category::Messenger));
assert_eq!(
Category::parse("42-self-bootstrap"),
Some(Category::SelfBootstrap)
);
}
#[test]
fn parse_rejects_frontmatter_without_closing_fence() {
let input = "---\nname: setup\ndescription: incomplete\ncategory: self-bootstrap\nversion: 1\nbody starts here\n";
let err = Skill::parse("setup", input).unwrap_err();
assert!(
matches!(err, SkillError::MissingFrontmatter { .. }),
"expected MissingFrontmatter, got {err:?}"
);
}
#[test]
fn parse_strips_bom_if_present() {
let bom_input = format!("\u{FEFF}{VALID}");
let skill = Skill::parse("setup", &bom_input).expect("BOM-prefixed file parses");
assert_eq!(skill.name(), "setup");
}
#[test]
fn parse_rejects_non_mapping_frontmatter() {
let input = "---\n- list-not-mapping\n- another\n---\nbody\n";
let err = Skill::parse("whatever", input).unwrap_err();
assert!(
matches!(
err,
SkillError::InvalidFieldType {
field: "<root>",
..
}
),
"expected InvalidFieldType(<root>), got {err:?}"
);
}
#[test]
fn parse_rejects_mismatched_name_and_skill_id() {
let input = r#"---
name: wrong-name
description: test
category: self-bootstrap
version: 1
---
body
"#;
let err = Skill::parse("setup", input).unwrap_err();
assert!(
matches!(err, SkillError::InvalidFieldType { field: "name", .. }),
"expected InvalidFieldType(name), got {err:?}"
);
}
#[test]
fn parse_rejects_negative_version() {
let input = r#"---
name: setup
description: test
category: self-bootstrap
version: -1
---
body
"#;
let err = Skill::parse("setup", input).unwrap_err();
assert!(
matches!(
err,
SkillError::InvalidFieldType {
field: "version",
..
}
),
"expected InvalidFieldType(version), got {err:?}"
);
}
}