agent_skills/
skill.rs

1//! The main Skill type combining frontmatter and markdown body.
2
3use 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/// A complete Agent Skill, combining frontmatter and markdown body.
16///
17/// This is the main type for working with skills. It can be loaded from
18/// a SKILL.md file or constructed programmatically.
19///
20/// # Examples
21///
22/// ```
23/// use agent_skills::Skill;
24///
25/// // Parse from SKILL.md content
26/// let content = r#"---
27/// name: my-skill
28/// description: Does something useful.
29/// ---
30/// # Instructions
31///
32/// Follow these steps...
33/// "#;
34///
35/// let skill = Skill::parse(content).unwrap();
36/// assert_eq!(skill.name().as_str(), "my-skill");
37/// assert!(skill.body().contains("# Instructions"));
38/// ```
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Skill {
41    frontmatter: Frontmatter,
42    body: String,
43}
44
45impl Skill {
46    /// Creates a new skill with frontmatter and body.
47    ///
48    /// # Examples
49    ///
50    /// ```
51    /// use agent_skills::{Skill, Frontmatter, SkillName, SkillDescription};
52    ///
53    /// let name = SkillName::new("my-skill").unwrap();
54    /// let desc = SkillDescription::new("Does something.").unwrap();
55    /// let frontmatter = Frontmatter::new(name, desc);
56    ///
57    /// let skill = Skill::new(frontmatter, "# Instructions\n\nDo this.");
58    /// ```
59    #[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    /// Parses a skill from SKILL.md content.
68    ///
69    /// The content must start with `---` (the YAML frontmatter delimiter),
70    /// followed by valid YAML, another `---` delimiter, and then the
71    /// markdown body.
72    ///
73    /// # Errors
74    ///
75    /// Returns `ParseError` if:
76    /// - The content doesn't start with `---`
77    /// - The YAML frontmatter is invalid
78    /// - Required fields are missing or invalid
79    ///
80    /// # Examples
81    ///
82    /// ```
83    /// use agent_skills::Skill;
84    ///
85    /// let content = r#"---
86    /// name: my-skill
87    /// description: Does something useful.
88    /// ---
89    /// # Instructions
90    ///
91    /// Follow these steps...
92    /// "#;
93    ///
94    /// let skill = Skill::parse(content).unwrap();
95    /// assert_eq!(skill.name().as_str(), "my-skill");
96    /// ```
97    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    /// Returns the skill name.
107    #[must_use]
108    pub const fn name(&self) -> &SkillName {
109        self.frontmatter.name()
110    }
111
112    /// Returns the skill description.
113    #[must_use]
114    pub const fn description(&self) -> &SkillDescription {
115        self.frontmatter.description()
116    }
117
118    /// Returns the frontmatter.
119    #[must_use]
120    pub const fn frontmatter(&self) -> &Frontmatter {
121        &self.frontmatter
122    }
123
124    /// Returns the markdown body (instructions).
125    #[must_use]
126    pub fn body(&self) -> &str {
127        &self.body
128    }
129
130    /// Returns the body trimmed of leading/trailing whitespace.
131    #[must_use]
132    pub fn body_trimmed(&self) -> &str {
133        self.body.trim()
134    }
135}
136
137/// Splits SKILL.md content into frontmatter YAML and body.
138fn split_frontmatter_and_body(content: &str) -> Result<(&str, &str), ParseError> {
139    // Content must start with ---
140    let content = content.trim_start();
141    if !content.starts_with("---") {
142        return Err(ParseError::MissingFrontmatter);
143    }
144
145    // Find the closing ---
146    let after_opening = &content[3..];
147    let after_opening = after_opening.trim_start_matches(['\r', '\n']);
148
149    // Look for closing delimiter (must be at start of a line)
150    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        // Strip leading newline from body if present
156        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
162/// Finds the position of the closing `---` delimiter.
163fn 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        // +1 for the newline (this is approximate but works for finding start)
170        pos += line.len() + 1;
171    }
172    None
173}
174
175/// Internal struct for YAML deserialization.
176#[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
187/// Parses frontmatter from YAML content.
188fn 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    // Validate required fields
194    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    // Validate and create types
200    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    // Build frontmatter, conditionally adding optional fields
213    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}