stakpak_api/local/skills/
parser.rs1use std::collections::HashMap;
2
3use serde::Deserialize;
4
5#[derive(Deserialize, Debug, Clone)]
6pub struct SkillFrontmatter {
7 pub name: String,
8 pub description: String,
10 pub license: Option<String>,
12 pub compatibility: Option<String>,
14 pub metadata: Option<HashMap<String, String>>,
16 #[serde(default, rename = "allowed-tools")]
18 pub allowed_tools: Option<String>,
19 #[serde(default)]
20 pub tags: Vec<String>,
21}
22
23fn validate_name(name: &str) -> Result<(), String> {
29 if name.is_empty() {
30 return Err("Skill name must not be empty".to_string());
31 }
32 if name.len() > 64 {
33 return Err(format!(
34 "Skill name must be at most 64 characters, got {}",
35 name.len()
36 ));
37 }
38 if name.starts_with('-') || name.ends_with('-') {
39 return Err(format!(
40 "Skill name '{}' must not start or end with a hyphen",
41 name
42 ));
43 }
44 if name.contains("--") {
45 return Err(format!(
46 "Skill name '{}' must not contain consecutive hyphens",
47 name
48 ));
49 }
50 for ch in name.chars() {
51 if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '-' {
52 return Err(format!(
53 "Skill name '{}' contains invalid character '{}'. Only lowercase letters, numbers, and hyphens are allowed",
54 name, ch
55 ));
56 }
57 }
58 Ok(())
59}
60
61fn validate_description(description: &str) -> Result<(), String> {
63 if description.is_empty() {
64 return Err("Skill description must not be empty".to_string());
65 }
66 if description.len() > 1024 {
67 return Err(format!(
68 "Skill description must be at most 1024 characters, got {}",
69 description.len()
70 ));
71 }
72 Ok(())
73}
74
75fn validate_compatibility(compatibility: &Option<String>) -> Result<(), String> {
77 if let Some(compat) = compatibility {
78 if compat.is_empty() {
79 return Err("Skill compatibility field, if provided, must not be empty".to_string());
80 }
81 if compat.len() > 500 {
82 return Err(format!(
83 "Skill compatibility must be at most 500 characters, got {}",
84 compat.len()
85 ));
86 }
87 }
88 Ok(())
89}
90
91pub fn validate_name_matches_directory(name: &str, dir_name: &str) -> Result<(), String> {
93 if name != dir_name {
94 return Err(format!(
95 "Skill name '{}' must match the parent directory name '{}'",
96 name, dir_name
97 ));
98 }
99 Ok(())
100}
101
102pub fn parse_skill_md(content: &str) -> Result<(SkillFrontmatter, String), String> {
103 let trimmed = content.trim_start();
104 if !trimmed.starts_with("---") {
105 return Err("SKILL.md must start with YAML frontmatter (---)".to_string());
106 }
107
108 let after_first = trimmed
111 .get(3..)
112 .ok_or_else(|| "SKILL.md frontmatter unexpectedly short".to_string())?;
113 let end_idx = after_first
114 .find("\n---")
115 .ok_or_else(|| "SKILL.md frontmatter missing closing ---".to_string())?;
116
117 let yaml_str = after_first
119 .get(..end_idx)
120 .ok_or_else(|| "SKILL.md frontmatter invalid boundary".to_string())?;
121 let body_start = end_idx + 4; let body = after_first
123 .get(body_start..)
124 .unwrap_or("")
125 .trim_start_matches('\n')
126 .to_string();
127
128 let frontmatter: SkillFrontmatter = serde_yaml::from_str(yaml_str)
129 .map_err(|e| format!("Failed to parse frontmatter: {}", e))?;
130
131 validate_name(&frontmatter.name)?;
133 validate_description(&frontmatter.description)?;
134
135 validate_compatibility(&frontmatter.compatibility)?;
137
138 Ok((frontmatter, body))
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn test_parse_valid_skill_md() {
147 let content = r#"---
148name: terraform-aws
149description: Best practices for Terraform on AWS
150tags: [terraform, aws, iac]
151---
152
153# Terraform AWS Instructions
154
155Step-by-step guidance here...
156"#;
157 let (fm, body) = parse_skill_md(content).unwrap();
158 assert_eq!(fm.name, "terraform-aws");
159 assert_eq!(fm.description, "Best practices for Terraform on AWS");
160 assert_eq!(fm.tags, vec!["terraform", "aws", "iac"]);
161 assert!(body.starts_with("# Terraform AWS Instructions"));
162 }
163
164 #[test]
165 fn test_parse_all_optional_fields() {
166 let content = r#"---
167name: pdf-processing
168description: Extract text and tables from PDF files, fill forms, merge documents.
169license: Apache-2.0
170compatibility: Requires poppler-utils and python3
171metadata:
172 author: example-org
173 version: "1.0"
174allowed-tools: Bash(git:*) Bash(jq:*) Read
175tags: [pdf, extraction]
176---
177
178# PDF Processing
179
180Instructions here.
181"#;
182 let (fm, body) = parse_skill_md(content).unwrap();
183 assert_eq!(fm.name, "pdf-processing");
184 assert_eq!(fm.license, Some("Apache-2.0".to_string()));
185 assert_eq!(
186 fm.compatibility,
187 Some("Requires poppler-utils and python3".to_string())
188 );
189 let metadata = fm.metadata.as_ref().unwrap();
190 assert_eq!(metadata.get("author"), Some(&"example-org".to_string()));
191 assert_eq!(metadata.get("version"), Some(&"1.0".to_string()));
192 assert_eq!(
193 fm.allowed_tools,
194 Some("Bash(git:*) Bash(jq:*) Read".to_string())
195 );
196 assert_eq!(fm.tags, vec!["pdf", "extraction"]);
197 assert!(body.starts_with("# PDF Processing"));
198 }
199
200 #[test]
201 fn test_parse_no_tags() {
202 let content = "---\nname: simple\ndescription: A simple skill\n---\n\nBody here.\n";
203 let (fm, body) = parse_skill_md(content).unwrap();
204 assert_eq!(fm.name, "simple");
205 assert!(fm.tags.is_empty());
206 assert!(fm.license.is_none());
207 assert!(fm.compatibility.is_none());
208 assert!(fm.metadata.is_none());
209 assert!(fm.allowed_tools.is_none());
210 assert_eq!(body, "Body here.\n");
211 }
212
213 #[test]
214 fn test_parse_missing_frontmatter() {
215 let content = "# No frontmatter\n\nJust markdown.";
216 let result = parse_skill_md(content);
217 assert!(result.is_err());
218 }
219
220 #[test]
221 fn test_parse_missing_closing() {
222 let content = "---\nname: broken\ndescription: oops\n";
223 let result = parse_skill_md(content);
224 assert!(result.is_err());
225 }
226
227 #[test]
228 fn test_parse_empty_name() {
229 let content = "---\nname: \"\"\ndescription: has desc\n---\n\nBody";
230 let result = parse_skill_md(content);
231 assert!(result.is_err());
232 assert!(result.unwrap_err().contains("must not be empty"));
233 }
234
235 #[test]
236 fn test_parse_empty_description() {
237 let content = "---\nname: test\ndescription: \"\"\n---\n\nBody";
238 let result = parse_skill_md(content);
239 assert!(result.is_err());
240 assert!(result.unwrap_err().contains("must not be empty"));
241 }
242
243 #[test]
246 fn test_name_uppercase_rejected() {
247 let content = "---\nname: PDF-Processing\ndescription: A skill\n---\n\nBody";
248 let result = parse_skill_md(content);
249 assert!(result.is_err());
250 assert!(result.unwrap_err().contains("invalid character"));
251 }
252
253 #[test]
254 fn test_name_starts_with_hyphen_rejected() {
255 let content = "---\nname: -pdf\ndescription: A skill\n---\n\nBody";
256 let result = parse_skill_md(content);
257 assert!(result.is_err());
258 assert!(result.unwrap_err().contains("must not start or end"));
259 }
260
261 #[test]
262 fn test_name_ends_with_hyphen_rejected() {
263 let content = "---\nname: pdf-\ndescription: A skill\n---\n\nBody";
264 let result = parse_skill_md(content);
265 assert!(result.is_err());
266 assert!(result.unwrap_err().contains("must not start or end"));
267 }
268
269 #[test]
270 fn test_name_consecutive_hyphens_rejected() {
271 let content = "---\nname: pdf--processing\ndescription: A skill\n---\n\nBody";
272 let result = parse_skill_md(content);
273 assert!(result.is_err());
274 assert!(result.unwrap_err().contains("consecutive hyphens"));
275 }
276
277 #[test]
278 fn test_name_too_long_rejected() {
279 let long_name = "a".repeat(65);
280 let content = format!(
281 "---\nname: {}\ndescription: A skill\n---\n\nBody",
282 long_name
283 );
284 let result = parse_skill_md(&content);
285 assert!(result.is_err());
286 assert!(result.unwrap_err().contains("at most 64 characters"));
287 }
288
289 #[test]
290 fn test_name_max_length_accepted() {
291 let max_name = "a".repeat(64);
292 let content = format!("---\nname: {}\ndescription: A skill\n---\n\nBody", max_name);
293 let result = parse_skill_md(&content);
294 assert!(result.is_ok());
295 }
296
297 #[test]
298 fn test_name_with_numbers_accepted() {
299 let content = "---\nname: skill-v2\ndescription: A skill\n---\n\nBody";
300 let result = parse_skill_md(content);
301 assert!(result.is_ok());
302 assert_eq!(result.unwrap().0.name, "skill-v2");
303 }
304
305 #[test]
306 fn test_name_with_spaces_rejected() {
307 let content = "---\nname: \"my skill\"\ndescription: A skill\n---\n\nBody";
308 let result = parse_skill_md(content);
309 assert!(result.is_err());
310 assert!(result.unwrap_err().contains("invalid character"));
311 }
312
313 #[test]
314 fn test_name_with_underscores_rejected() {
315 let content = "---\nname: my_skill\ndescription: A skill\n---\n\nBody";
316 let result = parse_skill_md(content);
317 assert!(result.is_err());
318 assert!(result.unwrap_err().contains("invalid character"));
319 }
320
321 #[test]
324 fn test_description_too_long_rejected() {
325 let long_desc = "a".repeat(1025);
326 let content = format!("---\nname: test\ndescription: {}\n---\n\nBody", long_desc);
327 let result = parse_skill_md(&content);
328 assert!(result.is_err());
329 assert!(result.unwrap_err().contains("at most 1024 characters"));
330 }
331
332 #[test]
333 fn test_description_max_length_accepted() {
334 let max_desc = "a".repeat(1024);
335 let content = format!("---\nname: test\ndescription: {}\n---\n\nBody", max_desc);
336 let result = parse_skill_md(&content);
337 assert!(result.is_ok());
338 }
339
340 #[test]
343 fn test_compatibility_too_long_rejected() {
344 let long_compat = "a".repeat(501);
345 let content = format!(
346 "---\nname: test\ndescription: A skill\ncompatibility: {}\n---\n\nBody",
347 long_compat
348 );
349 let result = parse_skill_md(&content);
350 assert!(result.is_err());
351 assert!(result.unwrap_err().contains("at most 500 characters"));
352 }
353
354 #[test]
355 fn test_compatibility_max_length_accepted() {
356 let max_compat = "a".repeat(500);
357 let content = format!(
358 "---\nname: test\ndescription: A skill\ncompatibility: {}\n---\n\nBody",
359 max_compat
360 );
361 let result = parse_skill_md(&content);
362 assert!(result.is_ok());
363 }
364
365 #[test]
366 fn test_compatibility_empty_rejected() {
367 let content = "---\nname: test\ndescription: A skill\ncompatibility: \"\"\n---\n\nBody";
368 let result = parse_skill_md(content);
369 assert!(result.is_err());
370 assert!(result.unwrap_err().contains("must not be empty"));
371 }
372
373 #[test]
376 fn test_name_matches_directory_ok() {
377 let result = validate_name_matches_directory("terraform", "terraform");
378 assert!(result.is_ok());
379 }
380
381 #[test]
382 fn test_name_mismatches_directory() {
383 let result = validate_name_matches_directory("terraform", "tf-skill");
384 assert!(result.is_err());
385 assert!(
386 result
387 .unwrap_err()
388 .contains("must match the parent directory name")
389 );
390 }
391}