1use std::collections::HashMap;
4
5use serde::Deserialize;
6
7use crate::allowed_tools::AllowedTools;
8use crate::compatibility::Compatibility;
9use crate::description::SkillDescription;
10use crate::error::ParseError;
11use crate::frontmatter::Frontmatter;
12use crate::metadata::Metadata;
13use crate::name::SkillName;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Skill {
41 frontmatter: Frontmatter,
42 body: String,
43}
44
45impl Skill {
46 #[must_use]
60 pub fn new(frontmatter: Frontmatter, body: impl Into<String>) -> Self {
61 Self {
62 frontmatter,
63 body: body.into(),
64 }
65 }
66
67 pub fn parse(content: &str) -> Result<Self, ParseError> {
98 let (yaml, body) = split_frontmatter_and_body(content)?;
99 let frontmatter = parse_frontmatter(yaml)?;
100 Ok(Self {
101 frontmatter,
102 body: body.to_string(),
103 })
104 }
105
106 #[must_use]
108 pub const fn name(&self) -> &SkillName {
109 self.frontmatter.name()
110 }
111
112 #[must_use]
114 pub const fn description(&self) -> &SkillDescription {
115 self.frontmatter.description()
116 }
117
118 #[must_use]
120 pub const fn frontmatter(&self) -> &Frontmatter {
121 &self.frontmatter
122 }
123
124 #[must_use]
126 pub fn body(&self) -> &str {
127 &self.body
128 }
129
130 #[must_use]
132 pub fn body_trimmed(&self) -> &str {
133 self.body.trim()
134 }
135}
136
137fn split_frontmatter_and_body(content: &str) -> Result<(&str, &str), ParseError> {
139 let content = content.trim_start();
141 if !content.starts_with("---") {
142 return Err(ParseError::MissingFrontmatter);
143 }
144
145 let after_opening = &content[3..];
147 let after_opening = after_opening.trim_start_matches(['\r', '\n']);
148
149 let closing_pos = find_closing_delimiter(after_opening);
151
152 closing_pos.map_or(Err(ParseError::UnterminatedFrontmatter), |pos| {
153 let yaml = &after_opening[..pos];
154 let body = &after_opening[pos + 3..];
155 let body = body.strip_prefix("\r\n").unwrap_or(body);
157 let body = body.strip_prefix('\n').unwrap_or(body);
158 Ok((yaml.trim(), body))
159 })
160}
161
162fn find_closing_delimiter(content: &str) -> Option<usize> {
164 let mut pos = 0;
165 for line in content.lines() {
166 if line == "---" {
167 return Some(pos);
168 }
169 pos += line.len() + 1;
171 }
172 None
173}
174
175#[derive(Deserialize)]
177#[serde(rename_all = "kebab-case")]
178struct RawFrontmatter {
179 name: Option<String>,
180 description: Option<String>,
181 license: Option<String>,
182 compatibility: Option<String>,
183 metadata: Option<HashMap<String, String>>,
184 allowed_tools: Option<String>,
185}
186
187fn parse_frontmatter(yaml: &str) -> Result<Frontmatter, ParseError> {
189 let raw: RawFrontmatter = serde_yaml::from_str(yaml).map_err(|e| ParseError::InvalidYaml {
190 message: e.to_string(),
191 })?;
192
193 let name_str = raw.name.ok_or(ParseError::MissingField { field: "name" })?;
195 let desc_str = raw.description.ok_or(ParseError::MissingField {
196 field: "description",
197 })?;
198
199 let name = SkillName::new(name_str).map_err(ParseError::InvalidName)?;
201 let description = SkillDescription::new(desc_str).map_err(ParseError::InvalidDescription)?;
202
203 let compatibility = raw
204 .compatibility
205 .map(Compatibility::new)
206 .transpose()
207 .map_err(ParseError::InvalidCompatibility)?;
208
209 let metadata = raw.metadata.map(Metadata::from_pairs);
210 let allowed_tools = raw.allowed_tools.map(|s| AllowedTools::new(&s));
211
212 let mut builder = Frontmatter::builder(name, description);
214
215 if let Some(license) = raw.license {
216 builder = builder.license(license);
217 }
218
219 if let Some(compat) = compatibility {
220 builder = builder.compatibility(compat);
221 }
222
223 if let Some(meta) = metadata {
224 builder = builder.metadata(meta);
225 }
226
227 if let Some(tools) = allowed_tools {
228 builder = builder.allowed_tools(tools);
229 }
230
231 Ok(builder.build())
232}
233
234#[cfg(test)]
235#[allow(clippy::unwrap_used, clippy::expect_used)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn parses_minimal_skill() {
241 let content = r#"---
242name: my-skill
243description: Does something useful.
244---
245# Instructions
246
247Follow these steps.
248"#;
249 let skill = Skill::parse(content);
250 assert!(skill.is_ok(), "Expected Ok, got: {:?}", skill);
251 let skill = skill.unwrap();
252 assert_eq!(skill.name().as_str(), "my-skill");
253 assert_eq!(skill.description().as_str(), "Does something useful.");
254 assert!(skill.body().contains("# Instructions"));
255 }
256
257 #[test]
258 fn parses_skill_with_all_fields() {
259 let content = r#"---
260name: pdf-processing
261description: Extracts text and tables from PDF files.
262license: Apache-2.0
263compatibility: Requires poppler-utils
264metadata:
265 author: example-org
266 version: "1.0"
267allowed-tools: Bash(git:*) Read Write
268---
269# PDF Processing
270
271Instructions here.
272"#;
273 let skill = Skill::parse(content).unwrap();
274 assert_eq!(skill.name().as_str(), "pdf-processing");
275 assert_eq!(skill.frontmatter().license(), Some("Apache-2.0"));
276 assert!(skill.frontmatter().compatibility().is_some());
277 assert!(skill.frontmatter().metadata().is_some());
278 let metadata = skill.frontmatter().metadata().unwrap();
279 assert_eq!(metadata.get("author"), Some("example-org"));
280 assert!(skill.frontmatter().allowed_tools().is_some());
281 }
282
283 #[test]
284 fn rejects_missing_frontmatter() {
285 let content = "# No frontmatter here";
286 let result = Skill::parse(content);
287 assert_eq!(result, Err(ParseError::MissingFrontmatter));
288 }
289
290 #[test]
291 fn rejects_unterminated_frontmatter() {
292 let content = r#"---
293name: my-skill
294description: Test.
295"#;
296 let result = Skill::parse(content);
297 assert_eq!(result, Err(ParseError::UnterminatedFrontmatter));
298 }
299
300 #[test]
301 fn rejects_missing_name() {
302 let content = r#"---
303description: Test.
304---
305Body
306"#;
307 let result = Skill::parse(content);
308 assert!(matches!(
309 result,
310 Err(ParseError::MissingField { field: "name" })
311 ));
312 }
313
314 #[test]
315 fn rejects_missing_description() {
316 let content = r#"---
317name: my-skill
318---
319Body
320"#;
321 let result = Skill::parse(content);
322 assert!(matches!(
323 result,
324 Err(ParseError::MissingField {
325 field: "description"
326 })
327 ));
328 }
329
330 #[test]
331 fn rejects_invalid_name() {
332 let content = r#"---
333name: Invalid-Name
334description: Test.
335---
336Body
337"#;
338 let result = Skill::parse(content);
339 assert!(matches!(result, Err(ParseError::InvalidName(_))));
340 }
341
342 #[test]
343 fn rejects_empty_description() {
344 let content = r#"---
345name: my-skill
346description: ""
347---
348Body
349"#;
350 let result = Skill::parse(content);
351 assert!(matches!(result, Err(ParseError::InvalidDescription(_))));
352 }
353
354 #[test]
355 fn rejects_invalid_yaml() {
356 let content = r#"---
357name: my-skill
358description [invalid yaml
359---
360Body
361"#;
362 let result = Skill::parse(content);
363 assert!(matches!(result, Err(ParseError::InvalidYaml { .. })));
364 }
365
366 #[test]
367 fn body_trimmed_removes_whitespace() {
368 let content = r#"---
369name: my-skill
370description: Test.
371---
372
373 Content here
374
375"#;
376 let skill = Skill::parse(content).unwrap();
377 assert_eq!(skill.body_trimmed(), "Content here");
378 }
379
380 #[test]
381 fn handles_empty_body() {
382 let content = r#"---
383name: my-skill
384description: Test.
385---
386"#;
387 let skill = Skill::parse(content).unwrap();
388 assert!(skill.body().is_empty() || skill.body().trim().is_empty());
389 }
390
391 #[test]
392 fn handles_leading_whitespace_before_frontmatter() {
393 let content = r#"
394---
395name: my-skill
396description: Test.
397---
398Body
399"#;
400 let skill = Skill::parse(content);
401 assert!(skill.is_ok());
402 }
403
404 #[test]
405 fn new_creates_skill_directly() {
406 let name = SkillName::new("my-skill").unwrap();
407 let desc = SkillDescription::new("Test description.").unwrap();
408 let frontmatter = Frontmatter::new(name, desc);
409 let skill = Skill::new(frontmatter, "# Body content");
410
411 assert_eq!(skill.name().as_str(), "my-skill");
412 assert_eq!(skill.body(), "# Body content");
413 }
414}