agent_core_runtime/skills/
parser.rs1use crate::skills::types::{SkillDiscoveryError, SkillMetadata};
4use std::path::Path;
5
6const MAX_NAME_LENGTH: usize = 64;
8
9const MAX_DESCRIPTION_LENGTH: usize = 1024;
11
12pub 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
30fn 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
46fn validate_metadata(metadata: &SkillMetadata, path: &Path) -> Result<(), SkillDiscoveryError> {
48 validate_name(&metadata.name, path)?;
49 validate_description(&metadata.description, path)?;
50 Ok(())
51}
52
53fn 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
88fn 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}