use std::collections::HashMap;
use serde::Deserialize;
#[derive(Deserialize, Debug, Clone)]
pub struct SkillFrontmatter {
pub name: String,
pub description: String,
pub license: Option<String>,
pub compatibility: Option<String>,
pub metadata: Option<HashMap<String, String>>,
#[serde(default, rename = "allowed-tools")]
pub allowed_tools: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
fn validate_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Skill name must not be empty".to_string());
}
if name.len() > 64 {
return Err(format!(
"Skill name must be at most 64 characters, got {}",
name.len()
));
}
if name.starts_with('-') || name.ends_with('-') {
return Err(format!(
"Skill name '{}' must not start or end with a hyphen",
name
));
}
if name.contains("--") {
return Err(format!(
"Skill name '{}' must not contain consecutive hyphens",
name
));
}
for ch in name.chars() {
if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '-' {
return Err(format!(
"Skill name '{}' contains invalid character '{}'. Only lowercase letters, numbers, and hyphens are allowed",
name, ch
));
}
}
Ok(())
}
fn validate_description(description: &str) -> Result<(), String> {
if description.is_empty() {
return Err("Skill description must not be empty".to_string());
}
if description.len() > 1024 {
return Err(format!(
"Skill description must be at most 1024 characters, got {}",
description.len()
));
}
Ok(())
}
fn validate_compatibility(compatibility: &Option<String>) -> Result<(), String> {
if let Some(compat) = compatibility {
if compat.is_empty() {
return Err("Skill compatibility field, if provided, must not be empty".to_string());
}
if compat.len() > 500 {
return Err(format!(
"Skill compatibility must be at most 500 characters, got {}",
compat.len()
));
}
}
Ok(())
}
pub fn validate_name_matches_directory(name: &str, dir_name: &str) -> Result<(), String> {
if name != dir_name {
return Err(format!(
"Skill name '{}' must match the parent directory name '{}'",
name, dir_name
));
}
Ok(())
}
pub fn parse_skill_md(content: &str) -> Result<(SkillFrontmatter, String), String> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return Err("SKILL.md must start with YAML frontmatter (---)".to_string());
}
let after_first = trimmed
.get(3..)
.ok_or_else(|| "SKILL.md frontmatter unexpectedly short".to_string())?;
let end_idx = after_first
.find("\n---")
.ok_or_else(|| "SKILL.md frontmatter missing closing ---".to_string())?;
let yaml_str = after_first
.get(..end_idx)
.ok_or_else(|| "SKILL.md frontmatter invalid boundary".to_string())?;
let body_start = end_idx + 4; let body = after_first
.get(body_start..)
.unwrap_or("")
.trim_start_matches('\n')
.to_string();
let frontmatter: SkillFrontmatter = serde_yaml::from_str(yaml_str)
.map_err(|e| format!("Failed to parse frontmatter: {}", e))?;
validate_name(&frontmatter.name)?;
validate_description(&frontmatter.description)?;
validate_compatibility(&frontmatter.compatibility)?;
Ok((frontmatter, body))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_skill_md() {
let content = r#"---
name: terraform-aws
description: Best practices for Terraform on AWS
tags: [terraform, aws, iac]
---
# Terraform AWS Instructions
Step-by-step guidance here...
"#;
let (fm, body) = parse_skill_md(content).unwrap();
assert_eq!(fm.name, "terraform-aws");
assert_eq!(fm.description, "Best practices for Terraform on AWS");
assert_eq!(fm.tags, vec!["terraform", "aws", "iac"]);
assert!(body.starts_with("# Terraform AWS Instructions"));
}
#[test]
fn test_parse_all_optional_fields() {
let content = r#"---
name: pdf-processing
description: Extract text and tables from PDF files, fill forms, merge documents.
license: Apache-2.0
compatibility: Requires poppler-utils and python3
metadata:
author: example-org
version: "1.0"
allowed-tools: Bash(git:*) Bash(jq:*) Read
tags: [pdf, extraction]
---
# PDF Processing
Instructions here.
"#;
let (fm, body) = parse_skill_md(content).unwrap();
assert_eq!(fm.name, "pdf-processing");
assert_eq!(fm.license, Some("Apache-2.0".to_string()));
assert_eq!(
fm.compatibility,
Some("Requires poppler-utils and python3".to_string())
);
let metadata = fm.metadata.as_ref().unwrap();
assert_eq!(metadata.get("author"), Some(&"example-org".to_string()));
assert_eq!(metadata.get("version"), Some(&"1.0".to_string()));
assert_eq!(
fm.allowed_tools,
Some("Bash(git:*) Bash(jq:*) Read".to_string())
);
assert_eq!(fm.tags, vec!["pdf", "extraction"]);
assert!(body.starts_with("# PDF Processing"));
}
#[test]
fn test_parse_no_tags() {
let content = "---\nname: simple\ndescription: A simple skill\n---\n\nBody here.\n";
let (fm, body) = parse_skill_md(content).unwrap();
assert_eq!(fm.name, "simple");
assert!(fm.tags.is_empty());
assert!(fm.license.is_none());
assert!(fm.compatibility.is_none());
assert!(fm.metadata.is_none());
assert!(fm.allowed_tools.is_none());
assert_eq!(body, "Body here.\n");
}
#[test]
fn test_parse_missing_frontmatter() {
let content = "# No frontmatter\n\nJust markdown.";
let result = parse_skill_md(content);
assert!(result.is_err());
}
#[test]
fn test_parse_missing_closing() {
let content = "---\nname: broken\ndescription: oops\n";
let result = parse_skill_md(content);
assert!(result.is_err());
}
#[test]
fn test_parse_empty_name() {
let content = "---\nname: \"\"\ndescription: has desc\n---\n\nBody";
let result = parse_skill_md(content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("must not be empty"));
}
#[test]
fn test_parse_empty_description() {
let content = "---\nname: test\ndescription: \"\"\n---\n\nBody";
let result = parse_skill_md(content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("must not be empty"));
}
#[test]
fn test_name_uppercase_rejected() {
let content = "---\nname: PDF-Processing\ndescription: A skill\n---\n\nBody";
let result = parse_skill_md(content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("invalid character"));
}
#[test]
fn test_name_starts_with_hyphen_rejected() {
let content = "---\nname: -pdf\ndescription: A skill\n---\n\nBody";
let result = parse_skill_md(content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("must not start or end"));
}
#[test]
fn test_name_ends_with_hyphen_rejected() {
let content = "---\nname: pdf-\ndescription: A skill\n---\n\nBody";
let result = parse_skill_md(content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("must not start or end"));
}
#[test]
fn test_name_consecutive_hyphens_rejected() {
let content = "---\nname: pdf--processing\ndescription: A skill\n---\n\nBody";
let result = parse_skill_md(content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("consecutive hyphens"));
}
#[test]
fn test_name_too_long_rejected() {
let long_name = "a".repeat(65);
let content = format!(
"---\nname: {}\ndescription: A skill\n---\n\nBody",
long_name
);
let result = parse_skill_md(&content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("at most 64 characters"));
}
#[test]
fn test_name_max_length_accepted() {
let max_name = "a".repeat(64);
let content = format!("---\nname: {}\ndescription: A skill\n---\n\nBody", max_name);
let result = parse_skill_md(&content);
assert!(result.is_ok());
}
#[test]
fn test_name_with_numbers_accepted() {
let content = "---\nname: skill-v2\ndescription: A skill\n---\n\nBody";
let result = parse_skill_md(content);
assert!(result.is_ok());
assert_eq!(result.unwrap().0.name, "skill-v2");
}
#[test]
fn test_name_with_spaces_rejected() {
let content = "---\nname: \"my skill\"\ndescription: A skill\n---\n\nBody";
let result = parse_skill_md(content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("invalid character"));
}
#[test]
fn test_name_with_underscores_rejected() {
let content = "---\nname: my_skill\ndescription: A skill\n---\n\nBody";
let result = parse_skill_md(content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("invalid character"));
}
#[test]
fn test_description_too_long_rejected() {
let long_desc = "a".repeat(1025);
let content = format!("---\nname: test\ndescription: {}\n---\n\nBody", long_desc);
let result = parse_skill_md(&content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("at most 1024 characters"));
}
#[test]
fn test_description_max_length_accepted() {
let max_desc = "a".repeat(1024);
let content = format!("---\nname: test\ndescription: {}\n---\n\nBody", max_desc);
let result = parse_skill_md(&content);
assert!(result.is_ok());
}
#[test]
fn test_compatibility_too_long_rejected() {
let long_compat = "a".repeat(501);
let content = format!(
"---\nname: test\ndescription: A skill\ncompatibility: {}\n---\n\nBody",
long_compat
);
let result = parse_skill_md(&content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("at most 500 characters"));
}
#[test]
fn test_compatibility_max_length_accepted() {
let max_compat = "a".repeat(500);
let content = format!(
"---\nname: test\ndescription: A skill\ncompatibility: {}\n---\n\nBody",
max_compat
);
let result = parse_skill_md(&content);
assert!(result.is_ok());
}
#[test]
fn test_compatibility_empty_rejected() {
let content = "---\nname: test\ndescription: A skill\ncompatibility: \"\"\n---\n\nBody";
let result = parse_skill_md(content);
assert!(result.is_err());
assert!(result.unwrap_err().contains("must not be empty"));
}
#[test]
fn test_name_matches_directory_ok() {
let result = validate_name_matches_directory("terraform", "terraform");
assert!(result.is_ok());
}
#[test]
fn test_name_mismatches_directory() {
let result = validate_name_matches_directory("terraform", "tf-skill");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("must match the parent directory name")
);
}
}