use regex::Regex;
use serde_yaml::Value;
use std::path::Path;
const MAX_NAME_LENGTH: usize = 64;
const MAX_DESCRIPTION_LENGTH: usize = 1024;
pub struct SpecValidator {
skill_name_regex: Regex,
tool_name_regex: Regex,
}
impl SpecValidator {
pub fn new() -> Self {
Self {
skill_name_regex: Regex::new(r"^[a-z0-9-]{1,64}$").unwrap(),
tool_name_regex: Regex::new(r"^[a-z0-9_]{1,64}$").unwrap(),
}
}
pub fn validate_skill_directory(&self, skill_dir: &Path) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
if !skill_dir.exists() {
errors.push(format!("Skill directory does not exist: {:?}", skill_dir));
return Err(errors);
}
if !skill_dir.join("SKILL.md").exists() {
errors.push("Missing required file: SKILL.md".to_string());
}
if !skill_dir.join("TOOLS.md").exists() {
errors.push("Missing required file: TOOLS.md".to_string());
}
let skill_md = skill_dir.join("SKILL.md");
if skill_md.exists() {
if let Err(e) = self.validate_skill_md(&skill_md) {
errors.extend(e);
}
}
let scripts_dir = skill_dir.join("scripts");
if scripts_dir.exists() {
if let Err(e) = self.validate_scripts(&scripts_dir) {
errors.extend(e);
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn validate_skill_md(&self, skill_md: &Path) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
let content = match std::fs::read_to_string(skill_md) {
Ok(c) => c,
Err(e) => return Err(vec![format!("Cannot read SKILL.md: {}", e)]),
};
let yaml_frontmatter = match self.extract_yaml_frontmatter(&content) {
Some(y) => y,
None => {
errors.push("No YAML frontmatter found (must start with ---)".to_string());
return Err(errors);
}
};
let yaml: Value = match serde_yaml::from_str(&yaml_frontmatter) {
Ok(y) => y,
Err(e) => {
errors.push(format!("Invalid YAML frontmatter: {}", e));
return Err(errors);
}
};
match yaml["name"].as_str() {
None => errors.push("Missing required field 'name' in YAML frontmatter".to_string()),
Some(name) => {
if !self.skill_name_regex.is_match(name) {
errors.push(format!(
"Invalid skill name '{}': must be lowercase alphanumeric + hyphens, max {} chars",
name, MAX_NAME_LENGTH
));
}
}
}
match yaml["description"].as_str() {
None => errors.push("Missing required field 'description' in YAML frontmatter".to_string()),
Some(desc) => {
if desc.is_empty() {
errors.push("Description cannot be empty".to_string());
}
if desc.len() > MAX_DESCRIPTION_LENGTH {
errors.push(format!(
"Description too long: {} chars (max {})",
desc.len(),
MAX_DESCRIPTION_LENGTH
));
}
if desc.contains('<') || desc.contains('>') {
errors.push("Description contains XML/HTML tags (< or >)".to_string());
}
}
}
if !content.contains("## When to Use") && !content.contains("## Usage") {
errors.push("Missing required section: '## When to Use' or '## Usage'".to_string());
}
if !content.contains("## Available Tools") && !content.contains("## Tools") {
errors.push(
"Missing required section: '## Available Tools' or '## Tools'".to_string(),
);
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
fn extract_yaml_frontmatter(&self, content: &str) -> Option<String> {
let lines: Vec<&str> = content.lines().collect();
if lines.first() != Some(&"---") {
return None;
}
let end_idx = lines[1..].iter().position(|&l| l == "---")?;
Some(lines[1..=end_idx].join("\n"))
}
fn validate_scripts(&self, scripts_dir: &Path) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
let entries = match std::fs::read_dir(scripts_dir) {
Ok(e) => e,
Err(e) => return Err(vec![format!("Cannot read scripts directory: {}", e)]),
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(e) => {
errors.push(format!("Error reading directory entry: {}", e));
continue;
}
};
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("sh") {
continue;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = std::fs::metadata(&path).unwrap();
let mode = metadata.permissions().mode();
if mode & 0o111 == 0 {
errors.push(format!("Script not executable: {:?}", path.file_name()));
}
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
errors.push(format!("Cannot read script {:?}: {}", path.file_name(), e));
continue;
}
};
if !content.starts_with("#!/bin/bash") && !content.starts_with("#!/usr/bin/env bash")
{
errors.push(format!(
"Script {:?} missing bash shebang (#!/bin/bash or #!/usr/bin/env bash)",
path.file_name()
));
}
if !content.contains("skill run") {
errors.push(format!(
"Script {:?} doesn't call 'skill run' command",
path.file_name()
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub fn is_valid_skill_name(&self, name: &str) -> bool {
self.skill_name_regex.is_match(name)
}
pub fn is_valid_tool_name(&self, name: &str) -> bool {
self.tool_name_regex.is_match(name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_validate_valid_skill() {
let temp = TempDir::new().unwrap();
let skill_dir = temp.path().join("kubernetes");
std::fs::create_dir(&skill_dir).unwrap();
let skill_md_content = r#"---
name: kubernetes
description: Kubernetes cluster management and operations
---
# Kubernetes Skill
## When to Use
Use this skill for Kubernetes cluster operations.
## Available Tools
### get
Get Kubernetes resources.
"#;
std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
std::fs::write(skill_dir.join("TOOLS.md"), "# Tools\n").unwrap();
let validator = SpecValidator::new();
let result = validator.validate_skill_directory(&skill_dir);
assert!(result.is_ok(), "Valid skill should pass: {:?}", result);
}
#[test]
fn test_validate_invalid_skill_name() {
let validator = SpecValidator::new();
assert!(!validator.is_valid_skill_name("Kubernetes"));
assert!(!validator.is_valid_skill_name("kube_skill"));
assert!(!validator.is_valid_skill_name("kube@skill"));
assert!(!validator.is_valid_skill_name("kube.skill"));
let long_name = "a".repeat(65);
assert!(!validator.is_valid_skill_name(&long_name));
assert!(!validator.is_valid_skill_name(""));
assert!(validator.is_valid_skill_name("kubernetes"));
assert!(validator.is_valid_skill_name("kube-skill"));
assert!(validator.is_valid_skill_name("k8s"));
assert!(validator.is_valid_skill_name("my-skill-123"));
}
#[test]
fn test_validate_description_length() {
let temp = TempDir::new().unwrap();
let skill_dir = temp.path().join("test");
std::fs::create_dir(&skill_dir).unwrap();
let long_desc = "a".repeat(1025);
let skill_md_content = format!(
r#"---
name: test
description: {}
---
# Test
## When to Use
Test
## Available Tools
Test
"#,
long_desc
);
std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
std::fs::write(skill_dir.join("TOOLS.md"), "# Tools\n").unwrap();
let validator = SpecValidator::new();
let result = validator.validate_skill_directory(&skill_dir);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(
errors.iter().any(|e| e.contains("too long")),
"Should detect description length violation: {:?}",
errors
);
}
#[test]
fn test_validate_missing_yaml_frontmatter() {
let temp = TempDir::new().unwrap();
let skill_dir = temp.path().join("test");
std::fs::create_dir(&skill_dir).unwrap();
let skill_md_content = r#"# Test Skill
This skill has no YAML frontmatter.
"#;
std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
std::fs::write(skill_dir.join("TOOLS.md"), "# Tools\n").unwrap();
let validator = SpecValidator::new();
let result = validator.validate_skill_directory(&skill_dir);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(
errors.iter().any(|e| e.contains("frontmatter")),
"Should detect missing frontmatter: {:?}",
errors
);
}
#[test]
fn test_validate_missing_required_fields() {
let temp = TempDir::new().unwrap();
let skill_dir = temp.path().join("test");
std::fs::create_dir(&skill_dir).unwrap();
let skill_md_content = r#"---
name: test
---
# Test
## When to Use
Test
## Available Tools
Test
"#;
std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
std::fs::write(skill_dir.join("TOOLS.md"), "# Tools\n").unwrap();
let validator = SpecValidator::new();
let result = validator.validate_skill_directory(&skill_dir);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(
errors.iter().any(|e| e.contains("description")),
"Should detect missing description: {:?}",
errors
);
}
#[test]
fn test_validate_html_in_description() {
let temp = TempDir::new().unwrap();
let skill_dir = temp.path().join("test");
std::fs::create_dir(&skill_dir).unwrap();
let skill_md_content = r#"---
name: test
description: This has <html> tags in it
---
# Test
## When to Use
Test
## Available Tools
Test
"#;
std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
std::fs::write(skill_dir.join("TOOLS.md"), "# Tools\n").unwrap();
let validator = SpecValidator::new();
let result = validator.validate_skill_directory(&skill_dir);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(
errors.iter().any(|e| e.contains("XML/HTML")),
"Should detect HTML tags: {:?}",
errors
);
}
#[test]
fn test_validate_missing_sections() {
let temp = TempDir::new().unwrap();
let skill_dir = temp.path().join("test");
std::fs::create_dir(&skill_dir).unwrap();
let skill_md_content = r#"---
name: test
description: Test skill
---
# Test Skill
## When to Use
Use this for testing.
"#;
std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
std::fs::write(skill_dir.join("TOOLS.md"), "# Tools\n").unwrap();
let validator = SpecValidator::new();
let result = validator.validate_skill_directory(&skill_dir);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(
errors.iter().any(|e| e.contains("Available Tools")),
"Should detect missing section: {:?}",
errors
);
}
#[test]
fn test_validate_script_permissions() {
let temp = TempDir::new().unwrap();
let skill_dir = temp.path().join("test");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::create_dir(skill_dir.join("scripts")).unwrap();
let skill_md_content = r#"---
name: test
description: Test skill
---
# Test
## When to Use
Test
## Available Tools
Test
"#;
std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
std::fs::write(skill_dir.join("TOOLS.md"), "# Tools\n").unwrap();
let script_content = r#"#!/bin/bash
skill run test tool --arg value
"#;
let script_path = skill_dir.join("scripts/test.sh");
std::fs::write(&script_path, script_content).unwrap();
#[cfg(unix)]
{
let validator = SpecValidator::new();
let result = validator.validate_skill_directory(&skill_dir);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(
errors.iter().any(|e| e.contains("not executable")),
"Should detect non-executable script: {:?}",
errors
);
}
}
#[test]
fn test_validate_script_missing_shebang() {
let temp = TempDir::new().unwrap();
let skill_dir = temp.path().join("test");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::create_dir(skill_dir.join("scripts")).unwrap();
let skill_md_content = r#"---
name: test
description: Test skill
---
# Test
## When to Use
Test
## Available Tools
Test
"#;
std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
std::fs::write(skill_dir.join("TOOLS.md"), "# Tools\n").unwrap();
let script_content = r#"skill run test tool --arg value
"#;
let script_path = skill_dir.join("scripts/test.sh");
std::fs::write(&script_path, script_content).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&script_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script_path, perms).unwrap();
}
let validator = SpecValidator::new();
let result = validator.validate_skill_directory(&skill_dir);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(
errors.iter().any(|e| e.contains("shebang")),
"Should detect missing shebang: {:?}",
errors
);
}
}