Skip to main content

agent_core_runtime/skills/
parser.rs

1//! Parser for SKILL.md files with YAML frontmatter.
2
3use crate::skills::types::{SkillDiscoveryError, SkillMetadata};
4use std::path::Path;
5
6/// Maximum allowed length for skill name.
7const MAX_NAME_LENGTH: usize = 64;
8
9/// Maximum allowed length for skill description.
10const MAX_DESCRIPTION_LENGTH: usize = 1024;
11
12/// Parse a SKILL.md file and extract the metadata from its YAML frontmatter.
13///
14/// The file must start with `---` followed by YAML content and another `---`.
15pub fn parse_skill_md(path: &Path) -> Result<SkillMetadata, SkillDiscoveryError> {
16    let content = std::fs::read_to_string(path)
17        .map_err(|e| SkillDiscoveryError::new(path.to_path_buf(), format!("Failed to read file: {}", e)))?;
18
19    let frontmatter = extract_frontmatter(&content)
20        .ok_or_else(|| SkillDiscoveryError::new(path.to_path_buf(), "Missing or invalid YAML frontmatter"))?;
21
22    let metadata: SkillMetadata = serde_yaml::from_str(frontmatter)
23        .map_err(|e| SkillDiscoveryError::new(path.to_path_buf(), format!("Invalid YAML: {}", e)))?;
24
25    validate_metadata(&metadata, path)?;
26
27    Ok(metadata)
28}
29
30/// Extract YAML frontmatter from content.
31///
32/// Frontmatter must be delimited by `---` at the start and end.
33fn extract_frontmatter(content: &str) -> Option<&str> {
34    let content = content.trim_start();
35
36    if !content.starts_with("---") {
37        return None;
38    }
39
40    let after_first_delim = &content[3..];
41    let end_pos = after_first_delim.find("\n---")?;
42
43    Some(&after_first_delim[..end_pos])
44}
45
46/// Validate skill metadata according to the spec.
47fn validate_metadata(metadata: &SkillMetadata, path: &Path) -> Result<(), SkillDiscoveryError> {
48    validate_name(&metadata.name, path)?;
49    validate_description(&metadata.description, path)?;
50    Ok(())
51}
52
53/// Validate skill name format.
54///
55/// Name must be 1-64 characters, lowercase letters, numbers, and hyphens only.
56/// Cannot start or end with a hyphen.
57fn validate_name(name: &str, path: &Path) -> Result<(), SkillDiscoveryError> {
58    if name.is_empty() {
59        return Err(SkillDiscoveryError::new(path.to_path_buf(), "Skill name cannot be empty"));
60    }
61
62    if name.len() > MAX_NAME_LENGTH {
63        return Err(SkillDiscoveryError::new(
64            path.to_path_buf(),
65            format!("Skill name exceeds {} characters", MAX_NAME_LENGTH),
66        ));
67    }
68
69    if name.starts_with('-') || name.ends_with('-') {
70        return Err(SkillDiscoveryError::new(
71            path.to_path_buf(),
72            "Skill name cannot start or end with a hyphen",
73        ));
74    }
75
76    for c in name.chars() {
77        if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
78            return Err(SkillDiscoveryError::new(
79                path.to_path_buf(),
80                format!("Skill name contains invalid character '{}'. Only lowercase letters, numbers, and hyphens allowed", c),
81            ));
82        }
83    }
84
85    Ok(())
86}
87
88/// Validate skill description.
89fn validate_description(description: &str, path: &Path) -> Result<(), SkillDiscoveryError> {
90    if description.is_empty() {
91        return Err(SkillDiscoveryError::new(path.to_path_buf(), "Skill description cannot be empty"));
92    }
93
94    if description.len() > MAX_DESCRIPTION_LENGTH {
95        return Err(SkillDiscoveryError::new(
96            path.to_path_buf(),
97            format!("Skill description exceeds {} characters", MAX_DESCRIPTION_LENGTH),
98        ));
99    }
100
101    Ok(())
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use std::io::Write;
108    use tempfile::NamedTempFile;
109
110    fn create_temp_skill_md(content: &str) -> NamedTempFile {
111        let mut file = NamedTempFile::new().unwrap();
112        file.write_all(content.as_bytes()).unwrap();
113        file.flush().unwrap();
114        file
115    }
116
117    #[test]
118    fn test_parse_valid_skill_md() {
119        let content = r#"---
120name: test-skill
121description: A test skill for unit testing.
122license: MIT
123---
124
125# Test Skill
126
127Instructions here.
128"#;
129        let file = create_temp_skill_md(content);
130        let metadata = parse_skill_md(file.path()).unwrap();
131
132        assert_eq!(metadata.name, "test-skill");
133        assert_eq!(metadata.description, "A test skill for unit testing.");
134        assert_eq!(metadata.license, Some("MIT".to_string()));
135    }
136
137    #[test]
138    fn test_parse_minimal_skill_md() {
139        let content = r#"---
140name: minimal
141description: Minimal skill.
142---
143"#;
144        let file = create_temp_skill_md(content);
145        let metadata = parse_skill_md(file.path()).unwrap();
146
147        assert_eq!(metadata.name, "minimal");
148        assert_eq!(metadata.description, "Minimal skill.");
149        assert!(metadata.license.is_none());
150        assert!(metadata.compatibility.is_none());
151    }
152
153    #[test]
154    fn test_parse_with_metadata() {
155        let content = r#"---
156name: with-metadata
157description: Skill with extra metadata.
158metadata:
159  author: test-org
160  version: "1.0"
161---
162"#;
163        let file = create_temp_skill_md(content);
164        let metadata = parse_skill_md(file.path()).unwrap();
165
166        let extra = metadata.metadata.unwrap();
167        assert_eq!(extra.get("author"), Some(&"test-org".to_string()));
168        assert_eq!(extra.get("version"), Some(&"1.0".to_string()));
169    }
170
171    #[test]
172    fn test_missing_frontmatter() {
173        let content = "# No frontmatter here";
174        let file = create_temp_skill_md(content);
175        let result = parse_skill_md(file.path());
176
177        assert!(result.is_err());
178        assert!(result.unwrap_err().message.contains("frontmatter"));
179    }
180
181    #[test]
182    fn test_invalid_name_uppercase() {
183        let content = r#"---
184name: TestSkill
185description: Invalid name.
186---
187"#;
188        let file = create_temp_skill_md(content);
189        let result = parse_skill_md(file.path());
190
191        assert!(result.is_err());
192        assert!(result.unwrap_err().message.contains("invalid character"));
193    }
194
195    #[test]
196    fn test_invalid_name_starts_with_hyphen() {
197        let content = r#"---
198name: -invalid
199description: Invalid name.
200---
201"#;
202        let file = create_temp_skill_md(content);
203        let result = parse_skill_md(file.path());
204
205        assert!(result.is_err());
206        assert!(result.unwrap_err().message.contains("hyphen"));
207    }
208
209    #[test]
210    fn test_empty_description() {
211        let content = r#"---
212name: valid-name
213description: ""
214---
215"#;
216        let file = create_temp_skill_md(content);
217        let result = parse_skill_md(file.path());
218
219        assert!(result.is_err());
220        assert!(result.unwrap_err().message.contains("description"));
221    }
222
223    #[test]
224    fn test_extract_frontmatter() {
225        let content = "---\nname: test\n---\nBody";
226        let fm = extract_frontmatter(content).unwrap();
227        assert_eq!(fm.trim(), "name: test");
228    }
229
230    #[test]
231    fn test_extract_frontmatter_with_leading_whitespace() {
232        let content = "  \n---\nname: test\n---\nBody";
233        let fm = extract_frontmatter(content).unwrap();
234        assert_eq!(fm.trim(), "name: test");
235    }
236}