use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use thiserror::Error;
use super::types::SkillPackage;
#[derive(Debug, Error)]
pub enum SkillMdError {
#[error("Failed to read SKILL.md: {0}")]
IoError(#[from] std::io::Error),
#[error("YAML parsing error: {0}")]
YamlError(String),
#[error("Missing required field: {0}")]
MissingField(String),
#[error("Invalid SKILL.md format: expected YAML frontmatter enclosed in ---")]
InvalidFormat,
#[error("Failed to parse skill package: {0}")]
PackageError(String),
#[error("Name exceeds maximum length of 64 characters (got {0} characters)")]
NameTooLong(usize),
#[error("Name must contain only lowercase letters, numbers, and hyphens")]
InvalidNameFormat,
#[error("Name cannot contain reserved words 'anthropic' or 'claude'")]
ReservedWord,
#[error("Name cannot contain XML tags")]
NameContainsXmlTags,
#[error("Description cannot be empty")]
DescriptionEmpty,
#[error("Description exceeds maximum length of 1024 characters (got {0} characters)")]
DescriptionTooLong(usize),
#[error("Description cannot contain XML tags")]
DescriptionContainsXmlTags,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillMdMetadata {
pub name: String,
pub description: String,
#[serde(default = "default_version")]
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_tools: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<SkillContext>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hooks: Option<SkillHooks>,
#[serde(default = "default_user_invocable")]
pub user_invocable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_model_invocation: Option<bool>,
}
impl SkillMdMetadata {
pub fn validate(&self) -> Result<(), SkillMdError> {
self.validate_name()?;
self.validate_description()?;
Ok(())
}
fn validate_name(&self) -> Result<(), SkillMdError> {
if self.name.len() > 64 {
return Err(SkillMdError::NameTooLong(self.name.len()));
}
if !self.name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
return Err(SkillMdError::InvalidNameFormat);
}
let lower_name = self.name.to_lowercase();
if lower_name.contains("anthropic") || lower_name.contains("claude") {
return Err(SkillMdError::ReservedWord);
}
if self.name.contains('<') || self.name.contains('>') {
return Err(SkillMdError::NameContainsXmlTags);
}
Ok(())
}
fn validate_description(&self) -> Result<(), SkillMdError> {
if self.description.trim().is_empty() {
return Err(SkillMdError::DescriptionEmpty);
}
if self.description.len() > 1024 {
return Err(SkillMdError::DescriptionTooLong(self.description.len()));
}
if self.description.contains('<') || self.description.contains('>') {
return Err(SkillMdError::DescriptionContainsXmlTags);
}
Ok(())
}
}
fn default_version() -> String {
"1.0.0".to_string()
}
fn default_user_invocable() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SkillContext {
Fork,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillHooks {
#[serde(skip_serializing_if = "Option::is_none")]
pub pre_tool_use: Option<Vec<HookConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub post_tool_use: Option<Vec<HookConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<HookConfig>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookConfig {
pub matcher: String,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub once: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub r#type: Option<HookType>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum HookType {
Command,
Script,
Function,
}
#[derive(Debug, Clone)]
pub struct SkillMdFile {
pub metadata: SkillMdMetadata,
pub content: String,
pub skill_dir: PathBuf,
pub scripts: Vec<PathBuf>,
pub resources: Vec<PathBuf>,
pub reference: Option<PathBuf>,
pub forms: Option<PathBuf>,
_resource_cache: Option<std::collections::HashMap<String, PathBuf>>,
}
impl SkillMdFile {
pub fn parse<P: AsRef<Path>>(skill_md_path: P) -> Result<Self, SkillMdError> {
let path = skill_md_path.as_ref();
let skill_dir = path
.parent()
.ok_or(SkillMdError::InvalidFormat)?;
let content = std::fs::read_to_string(path)?;
let (metadata, content) = Self::parse_frontmatter(&content)?;
let scripts = Self::discover_scripts(skill_dir);
let resources = Self::discover_resources(skill_dir);
let reference = Self::check_file_exists(skill_dir, "reference.md");
let forms = Self::check_file_exists(skill_dir, "forms.md");
let resource_cache = Self::build_resource_cache(&resources);
Ok(Self {
metadata,
content,
skill_dir: skill_dir.to_path_buf(),
scripts,
resources,
reference,
forms,
_resource_cache: Some(resource_cache),
})
}
fn parse_frontmatter(content: &str) -> Result<(SkillMdMetadata, String), SkillMdError> {
if !content.starts_with("---") {
return Err(SkillMdError::InvalidFormat);
}
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() < 3 {
return Err(SkillMdError::InvalidFormat);
}
let yaml_content = parts[1].trim();
let markdown_content = if parts.len() > 3 {
parts[2..].join("---")
} else {
parts[2].to_string()
};
let metadata: SkillMdMetadata = serde_yaml::from_str(yaml_content)
.map_err(|e| SkillMdError::YamlError(e.to_string()))?;
if metadata.name.is_empty() {
return Err(SkillMdError::MissingField("name".to_string()));
}
if metadata.description.is_empty() {
return Err(SkillMdError::MissingField("description".to_string()));
}
metadata.validate()?;
Ok((metadata, markdown_content))
}
fn discover_scripts(skill_dir: &Path) -> Vec<PathBuf> {
let scripts_dir = skill_dir.join("scripts");
if !scripts_dir.exists() {
return Vec::new();
}
std::fs::read_dir(&scripts_dir)
.ok()
.map(|entries| {
entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file())
.collect()
})
.unwrap_or_default()
}
fn discover_resources(skill_dir: &Path) -> Vec<PathBuf> {
let resources_dir = skill_dir.join("resources");
if !resources_dir.exists() {
return Vec::new();
}
let mut resources = Vec::new();
if let Ok(entries) = std::fs::read_dir(&resources_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
resources.push(path.clone());
} else if path.is_dir() {
if let Ok(sub_entries) = std::fs::read_dir(&path) {
for sub_entry in sub_entries.flatten() {
let sub_path = sub_entry.path();
if sub_path.is_file() {
resources.push(sub_path);
}
}
}
}
}
}
resources
}
fn build_resource_cache(resources: &[PathBuf]) -> std::collections::HashMap<String, PathBuf> {
let mut cache = std::collections::HashMap::new();
for resource_path in resources {
if let Some(file_name) = resource_path.file_name() {
if let Some(name_str) = file_name.to_str() {
cache.insert(name_str.to_string(), resource_path.clone());
}
}
}
cache
}
pub fn get_resource(&self, name: &str) -> Option<&PathBuf> {
self._resource_cache.as_ref()?.get(name)
}
pub fn get_resource_names(&self) -> Vec<String> {
self._resource_cache
.as_ref()
.map(|cache| cache.keys().cloned().collect())
.unwrap_or_default()
}
pub fn has_resource(&self, name: &str) -> bool {
self.get_resource(name).is_some()
}
fn check_file_exists(skill_dir: &Path, filename: &str) -> Option<PathBuf> {
let path = skill_dir.join(filename);
if path.exists() {
Some(path)
} else {
None
}
}
pub fn to_skill_package(&self) -> crate::skills::types::SkillPackage {
use crate::skills::types::{SkillMetadata, SkillResources};
let mut resource_folders = Vec::new();
if self.skill_dir.join("resources").exists() {
resource_folders.push(self.skill_dir.join("resources"));
}
SkillPackage {
metadata: SkillMetadata {
id: format!(
"skill.{}",
self.metadata.name.to_lowercase().replace(' ', "-")
),
name: self.metadata.name.clone(),
description: self.metadata.description.clone(),
version: self.metadata.version.clone(),
author: self.metadata.author.clone(),
dependencies: self.metadata.dependencies.clone(),
tags: self.metadata.tags.clone(),
},
instructions: self.content.clone(),
scripts: self.scripts.iter()
.filter_map(|p| p.to_str().map(|s| s.to_string()))
.collect(),
resources: SkillResources {
folders: resource_folders,
tools: vec![],
tests: vec![],
},
}
}
}
pub struct SkillsDirScanner {
base_dir: PathBuf,
}
impl SkillsDirScanner {
pub fn new<P: AsRef<Path>>(base_dir: P) -> Self {
Self {
base_dir: base_dir.as_ref().to_path_buf(),
}
}
pub fn from_project_dir<P: AsRef<Path>>(project_dir: P) -> Self {
Self {
base_dir: project_dir.as_ref().join(".claude").join("skills"),
}
}
pub fn from_user_dir() -> Result<Self, SkillMdError> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| SkillMdError::IoError(
std::io::Error::new(
std::io::ErrorKind::NotFound,
"Home directory not found"
)
))?;
Ok(Self {
base_dir: PathBuf::from(home)
.join(".config")
.join("claude")
.join("skills"),
})
}
pub fn scan(&self) -> Result<Vec<SkillMdFile>, SkillMdError> {
if !self.base_dir.exists() {
tracing::debug!(
"Skills directory does not exist: {:?}",
self.base_dir
);
return Ok(Vec::new());
}
let mut skills = Vec::new();
let entries = std::fs::read_dir(&self.base_dir)
.map_err(SkillMdError::IoError)?;
for entry in entries {
let entry = entry.map_err(SkillMdError::IoError)?;
let skill_dir = entry.path();
if !skill_dir.is_dir() {
continue;
}
let skill_md = skill_dir.join("SKILL.md");
if skill_md.exists() {
match SkillMdFile::parse(&skill_md) {
Ok(skill) => {
tracing::info!(
"Loaded skill '{}' from {:?}",
skill.metadata.name,
skill_md
);
skills.push(skill);
}
Err(e) => {
tracing::warn!(
"Failed to load skill from {:?}: {}",
skill_md, e
);
}
}
} else {
tracing::debug!(
"No SKILL.md found in {:?}",
skill_dir
);
}
}
Ok(skills)
}
pub async fn scan_parallel(&self) -> Result<Vec<SkillMdFile>, SkillMdError> {
if !self.base_dir.exists() {
tracing::debug!(
"Skills directory does not exist: {:?}",
self.base_dir
);
return Ok(Vec::new());
}
let entries = std::fs::read_dir(&self.base_dir)
.map_err(SkillMdError::IoError)?;
let skill_dirs: Vec<PathBuf> = entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect();
let parse_futures: Vec<_> = skill_dirs
.into_iter()
.filter_map(|skill_dir| {
let skill_md = skill_dir.join("SKILL.md");
if skill_md.exists() {
let skill_md_clone = skill_md.clone();
Some(async move {
tokio::task::spawn_blocking(move || {
SkillMdFile::parse(&skill_md)
})
.await
.unwrap_or_else(|e| {
tracing::error!(
"Task failed for {:?}: {}",
skill_md_clone, e
);
Err(SkillMdError::IoError(std::io::Error::other(
"Task execution failed"
)))
})
})
} else {
tracing::debug!("No SKILL.md found in {:?}", skill_dir);
None
}
})
.collect();
let results = futures::future::join_all(parse_futures).await;
let mut skills = Vec::new();
for result in results {
match result {
Ok(skill) => {
tracing::info!(
"Loaded skill '{}' from parallel scan",
skill.metadata.name
);
skills.push(skill);
}
Err(e) => {
tracing::warn!("Failed to load skill during parallel scan: {}", e);
}
}
}
tracing::info!(
"Parallel scan completed: {} skills loaded",
skills.len()
);
Ok(skills)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_skill_md() {
let content = r#"---
name: test-skill
description: A test skill
version: 2.0.0
author: Test Author
tags:
- test
- example
dependencies:
- other-skill
---
# Test Skill
This is a test skill with some content.
## Features
- Feature 1
- Feature 2
"#;
let (metadata, content) = SkillMdFile::parse_frontmatter(content).unwrap();
assert_eq!(metadata.name, "test-skill");
assert_eq!(metadata.description, "A test skill");
assert_eq!(metadata.version, "2.0.0");
assert_eq!(metadata.author, Some("Test Author".to_string()));
assert_eq!(metadata.tags, vec!["test", "example"]);
assert_eq!(metadata.dependencies, vec!["other-skill"]);
assert!(content.contains("This is a test skill"));
assert!(content.contains("Feature 1"));
}
#[test]
fn test_parse_minimal_skill_md() {
let content = r#"---
name: minimal-skill
description: Minimal skill
---
# Minimal
Content here.
"#;
let (metadata, content) = SkillMdFile::parse_frontmatter(content).unwrap();
assert_eq!(metadata.name, "minimal-skill");
assert_eq!(metadata.version, "1.0.0"); assert!(metadata.author.is_none());
assert!(metadata.tags.is_empty());
assert!(metadata.dependencies.is_empty());
assert!(content.contains("Content here"));
}
#[test]
fn test_parse_skill_md_with_content_containing_dashes() {
let content = r#"---
name: test-skill
description: Test
---
# Content with dashes
---
Another section.
"#;
let (metadata, content) = SkillMdFile::parse_frontmatter(content).unwrap();
assert_eq!(metadata.name, "test-skill");
assert!(content.contains("Content with dashes"));
assert!(content.contains("Another section"));
}
#[test]
fn test_parse_invalid_no_frontmatter() {
let content = r#"# Invalid
No frontmatter here.
"#;
let result = SkillMdFile::parse_frontmatter(content);
assert!(matches!(result, Err(SkillMdError::InvalidFormat)));
}
#[test]
fn test_parse_missing_required_fields() {
let content1 = r#"---
description: Test
---
# Content
"#;
let result1 = SkillMdFile::parse_frontmatter(content1);
assert!(result1.is_err(), "Should fail when name is missing");
let content2 = r#"---
name: test-skill
---
# Content
"#;
let result2 = SkillMdFile::parse_frontmatter(content2);
assert!(result2.is_err(), "Should fail when description is missing");
}
#[test]
fn test_parse_advanced_metadata_allowed_tools() {
let content = r#"---
name: test-skill
description: Test with tool restrictions
allowed_tools:
- Read
- Grep
- "Bash(python:*)"
---
# Content
"#;
let (metadata, _) = SkillMdFile::parse_frontmatter(content).unwrap();
assert_eq!(metadata.name, "test-skill");
assert!(metadata.allowed_tools.is_some());
let tools = metadata.allowed_tools.unwrap();
assert_eq!(tools, vec!["Read", "Grep", "Bash(python:*)"]);
}
#[test]
fn test_parse_advanced_metadata_model() {
let content = r#"---
name: test-skill
description: Test with specific model
model: claude-sonnet-4-20250514
---
# Content
"#;
let (metadata, _) = SkillMdFile::parse_frontmatter(content).unwrap();
assert_eq!(metadata.model, Some("claude-sonnet-4-20250514".to_string()));
}
#[test]
fn test_parse_advanced_metadata_context_fork() {
let content = r#"---
name: test-skill
description: Test with fork context
context: fork
agent: general-purpose
---
# Content
"#;
let (metadata, _) = SkillMdFile::parse_frontmatter(content).unwrap();
assert_eq!(metadata.context, Some(SkillContext::Fork));
assert_eq!(metadata.agent, Some("general-purpose".to_string()));
}
#[test]
fn test_parse_advanced_metadata_hooks() {
let content = r#"---
name: test-skill
description: Test with hooks
hooks:
pre_tool_use:
- matcher: "Bash"
command: "./scripts/security-check.sh $TOOL_INPUT"
once: true
type: command
---
# Content
"#;
let (metadata, _) = SkillMdFile::parse_frontmatter(content).unwrap();
assert!(metadata.hooks.is_some());
let hooks = metadata.hooks.unwrap();
assert!(hooks.pre_tool_use.is_some());
let pre_hooks = hooks.pre_tool_use.unwrap();
assert_eq!(pre_hooks.len(), 1);
assert_eq!(pre_hooks[0].matcher, "Bash");
assert_eq!(pre_hooks[0].command, "./scripts/security-check.sh $TOOL_INPUT");
assert_eq!(pre_hooks[0].once, Some(true));
assert_eq!(pre_hooks[0].r#type, Some(HookType::Command));
}
#[test]
fn test_parse_advanced_metadata_user_invocable() {
let content1 = r#"---
name: test-skill
description: Test hidden from menu
user_invocable: false
---
# Content
"#;
let (metadata1, _) = SkillMdFile::parse_frontmatter(content1).unwrap();
assert!(!metadata1.user_invocable);
let content2 = r#"---
name: test-skill-two
description: Test default user invocable
---
# Content
"#;
let (metadata2, _) = SkillMdFile::parse_frontmatter(content2).unwrap();
assert!(metadata2.user_invocable);
}
#[test]
fn test_parse_complete_advanced_metadata() {
let content = r#"---
name: advanced-test-skill
description: Test all advanced fields. Use when working with advanced testing scenarios.
version: 2.0.0
author: Test Author <test@example.com>
tags:
- advanced
- testing
dependencies:
- base-test
allowed_tools:
- Read
- Grep
- "Bash(python:*)"
model: claude-sonnet-4-20250514
context: fork
agent: general-purpose
hooks:
pre_tool_use:
- matcher: "Bash"
command: "./scripts/check.sh"
once: true
post_tool_use:
- matcher: "*"
command: "./scripts/notify.sh"
user_invocable: true
disable_model_invocation: false
---
# Advanced Test Skill
This is a comprehensive test of all metadata fields.
"#;
let (metadata, content) = SkillMdFile::parse_frontmatter(content).unwrap();
assert_eq!(metadata.name, "advanced-test-skill");
assert!(metadata.description.contains("advanced testing"));
assert_eq!(metadata.version, "2.0.0");
assert_eq!(metadata.author, Some("Test Author <test@example.com>".to_string()));
assert_eq!(metadata.tags, vec!["advanced", "testing"]);
assert_eq!(metadata.dependencies, vec!["base-test"]);
assert!(metadata.allowed_tools.is_some());
assert_eq!(metadata.model, Some("claude-sonnet-4-20250514".to_string()));
assert_eq!(metadata.context, Some(SkillContext::Fork));
assert_eq!(metadata.agent, Some("general-purpose".to_string()));
assert!(metadata.hooks.is_some());
assert!(metadata.user_invocable);
assert_eq!(metadata.disable_model_invocation, Some(false));
assert!(content.contains("comprehensive test"));
}
#[test]
fn test_validation_valid_skill() {
let content = r#"---
name: test-skill
description: A valid test skill
---
# Test Skill
Valid content.
"#;
let (metadata, _) = SkillMdFile::parse_frontmatter(content).unwrap();
assert_eq!(metadata.name, "test-skill");
metadata.validate().unwrap();
}
#[test]
fn test_validation_name_too_long() {
let name = "a".repeat(65); let content = format!(r#"---
name: {}
description: Test
---
# Content
"#, name);
let result = SkillMdFile::parse_frontmatter(&content);
assert!(matches!(result, Err(SkillMdError::NameTooLong(65))));
}
#[test]
fn test_validation_name_exactly_64_chars() {
let name = "a".repeat(64); let content = format!(r#"---
name: {}
description: Test
---
# Content
"#, name);
let (metadata, _) = SkillMdFile::parse_frontmatter(&content).unwrap();
metadata.validate().unwrap(); }
#[test]
fn test_validation_name_uppercase_invalid() {
let content = r#"---
name: TestSkill
description: Test
---
# Content
"#;
let result = SkillMdFile::parse_frontmatter(content);
assert!(matches!(result, Err(SkillMdError::InvalidNameFormat)));
}
#[test]
fn test_validation_name_with_spaces_invalid() {
let content = r#"---
name: test skill
description: Test
---
# Content
"#;
let result = SkillMdFile::parse_frontmatter(content);
assert!(matches!(result, Err(SkillMdError::InvalidNameFormat)));
}
#[test]
fn test_validation_name_with_special_chars_invalid() {
let content = r#"---
name: test_skill!
description: Test
---
# Content
"#;
let result = SkillMdFile::parse_frontmatter(content);
assert!(matches!(result, Err(SkillMdError::InvalidNameFormat)));
}
#[test]
fn test_validation_name_reserved_word_anthropic() {
let content = r#"---
name: my-anthropic-tool
description: Test
---
# Content
"#;
let result = SkillMdFile::parse_frontmatter(content);
assert!(matches!(result, Err(SkillMdError::ReservedWord)));
}
#[test]
fn test_validation_name_reserved_word_claude() {
let content = r#"---
name: claude-helper
description: Test
---
# Content
"#;
let result = SkillMdFile::parse_frontmatter(content);
assert!(matches!(result, Err(SkillMdError::ReservedWord)));
}
#[test]
fn test_validation_name_case_insensitive_reserved() {
let content = r#"---
name: my-anthropic-tool
description: Test
---
# Content
"#;
let result = SkillMdFile::parse_frontmatter(content);
assert!(matches!(result, Err(SkillMdError::ReservedWord)));
}
#[test]
fn test_validation_description_empty() {
let content = r#"---
name: test-skill
description: " "
---
# Content
"#;
let result = SkillMdFile::parse_frontmatter(content);
assert!(matches!(result, Err(SkillMdError::DescriptionEmpty)));
}
#[test]
fn test_validation_description_too_long() {
let description = "a".repeat(1025); let content = format!(r#"---
name: test-skill
description: {}
---
# Content
"#, description);
let result = SkillMdFile::parse_frontmatter(&content);
assert!(matches!(result, Err(SkillMdError::DescriptionTooLong(1025))));
}
#[test]
fn test_validation_description_exactly_1024_chars() {
let description = "a".repeat(1024); let content = format!(r#"---
name: test-skill
description: {}
---
# Content
"#, description);
let (metadata, _) = SkillMdFile::parse_frontmatter(&content).unwrap();
metadata.validate().unwrap(); }
#[test]
fn test_validation_description_with_xml_tags() {
let content = r#"---
name: test-skill
description: "A test <script>alert('xss')</script>"
---
# Content
"#;
let result = SkillMdFile::parse_frontmatter(content);
assert!(matches!(result, Err(SkillMdError::DescriptionContainsXmlTags)));
}
#[test]
fn test_validation_multiple_errors_first_returned() {
let content = r#"---
name: MyClaudeSkill<script>
description: " "
---
# Content
"#;
let result = SkillMdFile::parse_frontmatter(content);
assert!(result.is_err());
}
#[tokio::test]
async fn test_scan_parallel_nonexistent_directory() {
let scanner = SkillsDirScanner::new("/nonexistent/path/to/skills");
let result = scanner.scan_parallel().await;
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[tokio::test]
async fn test_scan_parallel_empty_directory() {
let temp_dir = tempfile::tempdir().unwrap();
let scanner = SkillsDirScanner::new(temp_dir.path());
let result = scanner.scan_parallel().await;
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[tokio::test]
async fn test_scan_parallel_with_skills() {
let temp_dir = tempfile::tempdir().unwrap();
let skills_dir = temp_dir.path();
for i in 1..=3 {
let skill_dir = skills_dir.join(format!("skill-{}", i));
std::fs::create_dir(&skill_dir).unwrap();
let skill_md_content = format!(
r#"---
name: test-skill-{}
description: Test skill {}
version: 1.0.0
author: Test Author
---
# Test Skill {}
This is a test skill.
"#,
i, i, i
);
std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
}
let scanner = SkillsDirScanner::new(skills_dir);
let result = scanner.scan_parallel().await;
assert!(result.is_ok());
let skills = result.unwrap();
assert_eq!(skills.len(), 3);
let skill_names: Vec<_> = skills.iter().map(|s| s.metadata.name.clone()).collect();
assert!(skill_names.contains(&"test-skill-1".to_string()));
assert!(skill_names.contains(&"test-skill-2".to_string()));
assert!(skill_names.contains(&"test-skill-3".to_string()));
}
#[tokio::test]
async fn test_scan_parallel_handles_invalid_skills() {
let temp_dir = tempfile::tempdir().unwrap();
let skills_dir = temp_dir.path();
let valid_skill_dir = skills_dir.join("valid-skill");
std::fs::create_dir(&valid_skill_dir).unwrap();
std::fs::write(
valid_skill_dir.join("SKILL.md"),
r#"---
name: valid-skill
description: A valid skill
version: 1.0.0
---
# Valid Skill
"#,
)
.unwrap();
let invalid_skill_dir = skills_dir.join("invalid-skill");
std::fs::create_dir(&invalid_skill_dir).unwrap();
std::fs::write(
invalid_skill_dir.join("SKILL.md"),
r#"---
name: ""
---
# Invalid Skill
"#,
)
.unwrap();
let scanner = SkillsDirScanner::new(skills_dir);
let result = scanner.scan_parallel().await;
assert!(result.is_ok());
let skills = result.unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].metadata.name, "valid-skill");
}
#[tokio::test]
async fn test_scan_parallel_vs_sync_consistency() {
let temp_dir = tempfile::tempdir().unwrap();
let skills_dir = temp_dir.path();
for i in 1..=5 {
let skill_dir = skills_dir.join(format!("skill-{}", i));
std::fs::create_dir(&skill_dir).unwrap();
let skill_md_content = format!(
r#"---
name: skill-{}
description: Skill number {}
version: 1.0.0
---
# Skill {}
"#,
i, i, i
);
std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
}
let scanner = SkillsDirScanner::new(skills_dir);
let sync_result = scanner.scan();
assert!(sync_result.is_ok());
let sync_skills = sync_result.unwrap();
let parallel_result = scanner.scan_parallel().await;
assert!(parallel_result.is_ok());
let parallel_skills = parallel_result.unwrap();
assert_eq!(sync_skills.len(), parallel_skills.len());
assert_eq!(sync_skills.len(), 5);
let sync_names: std::collections::HashSet<_> =
sync_skills.iter().map(|s| s.metadata.name.clone()).collect();
let parallel_names: std::collections::HashSet<_> =
parallel_skills.iter().map(|s| s.metadata.name.clone()).collect();
assert_eq!(sync_names, parallel_names);
}
#[test]
fn test_progressive_disclosure_resource_cache() {
let temp_dir = tempfile::tempdir().unwrap();
let skill_dir = temp_dir.path().join("test-skill");
std::fs::create_dir(&skill_dir).unwrap();
let resources_dir = skill_dir.join("resources");
std::fs::create_dir(&resources_dir).unwrap();
std::fs::write(resources_dir.join("config.json"), "{}").unwrap();
std::fs::write(resources_dir.join("data.txt"), "data").unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
r#"---
name: test-skill
description: Test skill
version: 1.0.0
---
# Test Skill
"#,
)
.unwrap();
let skill = SkillMdFile::parse(skill_dir.join("SKILL.md")).unwrap();
assert!(skill._resource_cache.is_some());
assert!(skill.get_resource("config.json").is_some());
assert!(skill.get_resource("data.txt").is_some());
assert!(skill.get_resource("nonexistent.json").is_none());
}
#[test]
fn test_get_resource_names() {
let temp_dir = tempfile::tempdir().unwrap();
let skill_dir = temp_dir.path().join("test-skill");
std::fs::create_dir(&skill_dir).unwrap();
let resources_dir = skill_dir.join("resources");
std::fs::create_dir(&resources_dir).unwrap();
std::fs::write(resources_dir.join("file1.txt"), "content1").unwrap();
std::fs::write(resources_dir.join("file2.txt"), "content2").unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
r#"---
name: test-skill
description: Test skill
version: 1.0.0
---
# Test Skill
"#,
)
.unwrap();
let skill = SkillMdFile::parse(skill_dir.join("SKILL.md")).unwrap();
let names = skill.get_resource_names();
assert_eq!(names.len(), 2);
assert!(names.contains(&"file1.txt".to_string()));
assert!(names.contains(&"file2.txt".to_string()));
}
#[test]
fn test_has_resource() {
let temp_dir = tempfile::tempdir().unwrap();
let skill_dir = temp_dir.path().join("test-skill");
std::fs::create_dir(&skill_dir).unwrap();
let resources_dir = skill_dir.join("resources");
std::fs::create_dir(&resources_dir).unwrap();
std::fs::write(resources_dir.join("exists.txt"), "content").unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
r#"---
name: test-skill
description: Test skill
version: 1.0.0
---
# Test Skill
"#,
)
.unwrap();
let skill = SkillMdFile::parse(skill_dir.join("SKILL.md")).unwrap();
assert!(skill.has_resource("exists.txt"));
assert!(!skill.has_resource("missing.txt"));
}
#[test]
fn test_progressive_disclosure_no_resources() {
let temp_dir = tempfile::tempdir().unwrap();
let skill_dir = temp_dir.path().join("test-skill");
std::fs::create_dir(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
r#"---
name: test-skill
description: Test skill
version: 1.0.0
---
# Test Skill
"#,
)
.unwrap();
let skill = SkillMdFile::parse(skill_dir.join("SKILL.md")).unwrap();
assert!(skill._resource_cache.is_some());
assert_eq!(skill.get_resource_names().len(), 0);
assert!(skill.get_resource("anything").is_none());
assert!(!skill.has_resource("anything"));
}
#[test]
fn test_backward_compatible_resources_list() {
let temp_dir = tempfile::tempdir().unwrap();
let skill_dir = temp_dir.path().join("test-skill");
std::fs::create_dir(&skill_dir).unwrap();
let resources_dir = skill_dir.join("resources");
std::fs::create_dir(&resources_dir).unwrap();
std::fs::write(resources_dir.join("res1.txt"), "content1").unwrap();
std::fs::write(resources_dir.join("res2.txt"), "content2").unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
r#"---
name: test-skill
description: Test skill
version: 1.0.0
---
# Test Skill
"#,
)
.unwrap();
let skill = SkillMdFile::parse(skill_dir.join("SKILL.md")).unwrap();
assert!(!skill.resources.is_empty());
assert_eq!(skill.resources.len(), 2);
assert_eq!(skill.get_resource_names().len(), 2);
}
}