1use serde::Deserialize;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone)]
11pub struct SkillEntry {
12 pub name: String,
14 pub description: String,
16 pub content: String,
18 pub source: SkillSource,
20 pub hats: Vec<String>,
22 pub backends: Vec<String>,
24 pub tags: Vec<String>,
26 pub auto_inject: bool,
28}
29
30#[derive(Debug, Clone)]
32pub enum SkillSource {
33 BuiltIn,
35 File(PathBuf),
37}
38
39#[derive(Debug, Clone, Default, Deserialize)]
41pub struct SkillFrontmatter {
42 pub name: Option<String>,
43 pub description: Option<String>,
44 #[serde(default)]
45 pub hats: Vec<String>,
46 #[serde(default)]
47 pub backends: Vec<String>,
48 #[serde(default)]
49 pub tags: Vec<String>,
50}
51
52pub fn parse_frontmatter(raw: &str) -> (Option<SkillFrontmatter>, String) {
66 let trimmed = raw.trim_start();
67
68 if !trimmed.starts_with("---") {
70 return (None, raw.to_string());
71 }
72
73 let after_open = &trimmed[3..];
75 let closing_pos = after_open.find("\n---");
76
77 match closing_pos {
78 Some(pos) => {
79 let yaml_str = &after_open[..pos];
80 let body_start = pos + 4; let body = after_open[body_start..].trim_start_matches('\n');
82
83 match serde_yaml::from_str::<SkillFrontmatter>(yaml_str) {
84 Ok(fm) => (Some(fm), body.to_string()),
85 Err(_) => {
86 (None, body.to_string())
88 }
89 }
90 }
91 None => {
92 (None, raw.to_string())
94 }
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 #[test]
103 fn test_parse_valid_frontmatter_all_fields() {
104 let raw = r"---
105name: my-skill
106description: A useful skill
107hats: [builder, reviewer]
108backends: [claude, gemini]
109tags: [testing, tdd]
110---
111
112# My Skill
113
114Body content here.
115";
116 let (fm, body) = parse_frontmatter(raw);
117 let fm = fm.expect("should parse frontmatter");
118 assert_eq!(fm.name.as_deref(), Some("my-skill"));
119 assert_eq!(fm.description.as_deref(), Some("A useful skill"));
120 assert_eq!(fm.hats, vec!["builder", "reviewer"]);
121 assert_eq!(fm.backends, vec!["claude", "gemini"]);
122 assert_eq!(fm.tags, vec!["testing", "tdd"]);
123 assert!(body.contains("# My Skill"));
124 assert!(body.contains("Body content here."));
125 assert!(!body.contains("---"));
127 }
128
129 #[test]
130 fn test_parse_frontmatter_name_and_description_only() {
131 let raw = r"---
132name: memories
133description: Persistent learning across sessions
134---
135
136# Memories
137
138Content.
139";
140 let (fm, body) = parse_frontmatter(raw);
141 let fm = fm.expect("should parse frontmatter");
142 assert_eq!(fm.name.as_deref(), Some("memories"));
143 assert_eq!(
144 fm.description.as_deref(),
145 Some("Persistent learning across sessions")
146 );
147 assert!(fm.hats.is_empty());
148 assert!(fm.backends.is_empty());
149 assert!(fm.tags.is_empty());
150 assert!(body.starts_with("# Memories"));
151 }
152
153 #[test]
154 fn test_parse_no_frontmatter() {
155 let raw = "# Just Markdown\n\nNo frontmatter here.\n";
156 let (fm, body) = parse_frontmatter(raw);
157 assert!(fm.is_none());
158 assert_eq!(body, raw);
159 }
160
161 #[test]
162 fn test_parse_invalid_yaml_frontmatter() {
163 let raw = r"---
164this: is: not: valid: yaml: [[[
165---
166
167Body content.
168";
169 let (fm, body) = parse_frontmatter(raw);
170 assert!(fm.is_none());
171 assert!(body.contains("Body content."));
172 }
173
174 #[test]
175 fn test_parse_no_closing_delimiter() {
176 let raw = "---\nname: broken\nNo closing delimiter\n";
177 let (fm, body) = parse_frontmatter(raw);
178 assert!(fm.is_none());
179 assert_eq!(body, raw);
180 }
181
182 #[test]
183 fn test_content_body_strips_frontmatter_delimiters() {
184 let raw = "---\nname: test\n---\nFirst line of body.\nSecond line.\n";
185 let (fm, body) = parse_frontmatter(raw);
186 assert!(fm.is_some());
187 assert!(body.starts_with("First line of body."));
188 assert!(body.contains("Second line."));
189 assert!(!body.contains("---"));
190 assert!(!body.contains("name: test"));
191 }
192
193 #[test]
194 fn test_empty_frontmatter() {
195 let raw = "---\n---\nBody only.\n";
196 let (fm, body) = parse_frontmatter(raw);
197 let fm = fm.expect("empty frontmatter should parse");
199 assert!(fm.name.is_none());
200 assert!(fm.description.is_none());
201 assert!(body.contains("Body only."));
202 }
203}