use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct SkillEntry {
pub name: String,
pub description: String,
pub content: String,
pub source: SkillSource,
pub hats: Vec<String>,
pub backends: Vec<String>,
pub tags: Vec<String>,
pub auto_inject: bool,
}
#[derive(Debug, Clone)]
pub enum SkillSource {
BuiltIn,
File(PathBuf),
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SkillFrontmatter {
pub name: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub hats: Vec<String>,
#[serde(default)]
pub backends: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
}
pub fn parse_frontmatter(raw: &str) -> (Option<SkillFrontmatter>, String) {
let trimmed = raw.trim_start();
if !trimmed.starts_with("---") {
return (None, raw.to_string());
}
let after_open = &trimmed[3..];
let closing_pos = after_open.find("\n---");
match closing_pos {
Some(pos) => {
let yaml_str = &after_open[..pos];
let body_start = pos + 4; let body = after_open[body_start..].trim_start_matches('\n');
match serde_yaml::from_str::<SkillFrontmatter>(yaml_str) {
Ok(fm) => (Some(fm), body.to_string()),
Err(_) => {
(None, body.to_string())
}
}
}
None => {
(None, raw.to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_frontmatter_all_fields() {
let raw = r"---
name: my-skill
description: A useful skill
hats: [builder, reviewer]
backends: [claude, gemini]
tags: [testing, tdd]
---
# My Skill
Body content here.
";
let (fm, body) = parse_frontmatter(raw);
let fm = fm.expect("should parse frontmatter");
assert_eq!(fm.name.as_deref(), Some("my-skill"));
assert_eq!(fm.description.as_deref(), Some("A useful skill"));
assert_eq!(fm.hats, vec!["builder", "reviewer"]);
assert_eq!(fm.backends, vec!["claude", "gemini"]);
assert_eq!(fm.tags, vec!["testing", "tdd"]);
assert!(body.contains("# My Skill"));
assert!(body.contains("Body content here."));
assert!(!body.contains("---"));
}
#[test]
fn test_parse_frontmatter_name_and_description_only() {
let raw = r"---
name: memories
description: Persistent learning across sessions
---
# Memories
Content.
";
let (fm, body) = parse_frontmatter(raw);
let fm = fm.expect("should parse frontmatter");
assert_eq!(fm.name.as_deref(), Some("memories"));
assert_eq!(
fm.description.as_deref(),
Some("Persistent learning across sessions")
);
assert!(fm.hats.is_empty());
assert!(fm.backends.is_empty());
assert!(fm.tags.is_empty());
assert!(body.starts_with("# Memories"));
}
#[test]
fn test_parse_no_frontmatter() {
let raw = "# Just Markdown\n\nNo frontmatter here.\n";
let (fm, body) = parse_frontmatter(raw);
assert!(fm.is_none());
assert_eq!(body, raw);
}
#[test]
fn test_parse_invalid_yaml_frontmatter() {
let raw = r"---
this: is: not: valid: yaml: [[[
---
Body content.
";
let (fm, body) = parse_frontmatter(raw);
assert!(fm.is_none());
assert!(body.contains("Body content."));
}
#[test]
fn test_parse_no_closing_delimiter() {
let raw = "---\nname: broken\nNo closing delimiter\n";
let (fm, body) = parse_frontmatter(raw);
assert!(fm.is_none());
assert_eq!(body, raw);
}
#[test]
fn test_content_body_strips_frontmatter_delimiters() {
let raw = "---\nname: test\n---\nFirst line of body.\nSecond line.\n";
let (fm, body) = parse_frontmatter(raw);
assert!(fm.is_some());
assert!(body.starts_with("First line of body."));
assert!(body.contains("Second line."));
assert!(!body.contains("---"));
assert!(!body.contains("name: test"));
}
#[test]
fn test_empty_frontmatter() {
let raw = "---\n---\nBody only.\n";
let (fm, body) = parse_frontmatter(raw);
let fm = fm.expect("empty frontmatter should parse");
assert!(fm.name.is_none());
assert!(fm.description.is_none());
assert!(body.contains("Body only."));
}
}