use crate::core::service::ServiceError;
use crate::core::skill_manager::SkillDefinition;
use crate::validation::content_safety;
use crate::validation::dir_structure;
use crate::validation::extension_check::{self, ExtensionCheckConfig, ExtensionPreset};
use crate::validation::field_validation;
use crate::validation::file_structure;
use crate::validation::frontmatter;
use crate::validation::result::{ErrorSeverity, ValidationResult};
use std::path::Path;
use tokio::fs;
pub struct SkillValidator {
max_file_size_mb: usize,
max_description_length: usize,
required_fields: Vec<String>,
dangerous_patterns: Vec<String>,
}
impl Default for SkillValidator {
fn default() -> Self {
Self::new()
}
}
impl SkillValidator {
pub fn new() -> Self {
Self {
max_file_size_mb: 10,
max_description_length: 500,
required_fields: vec![
"name".to_string(),
"description".to_string(),
"version".to_string(),
],
dangerous_patterns: content_safety::default_dangerous_patterns(),
}
}
pub fn with_config(
max_file_size_mb: usize,
max_description_length: usize,
required_fields: Vec<String>,
) -> Self {
Self {
max_file_size_mb,
max_description_length,
required_fields,
dangerous_patterns: content_safety::default_dangerous_patterns(),
}
}
pub async fn validate_skill(
&self,
skill: &SkillDefinition,
) -> Result<ValidationResult, ServiceError> {
let mut result = ValidationResult::valid();
result = field_validation::validate_required_fields(
skill,
result,
&self.required_fields,
self.max_description_length,
);
result = field_validation::validate_field_formats(skill, result);
result =
file_structure::validate_file_structure(skill, result, self.max_file_size_mb).await?;
result = self.validate_content_safety(skill, result).await?;
result = self.validate_yaml_frontmatter(skill, result).await?;
result.calculate_score();
Ok(result)
}
async fn validate_content_safety(
&self,
skill: &SkillDefinition,
result: ValidationResult,
) -> Result<ValidationResult, ServiceError> {
let result = if skill.skill_file.exists() {
content_safety::validate_skill_file_content(
&skill.skill_file,
result,
&self.dangerous_patterns,
)
.await?
} else {
result
};
let script_files = skill.script_files.as_deref().unwrap_or(&[]);
let mut result = result;
for script_file in script_files {
if script_file.exists() {
result = content_safety::validate_script_file_content(
script_file,
result,
&self.dangerous_patterns,
)
.await?;
}
}
Ok(result)
}
async fn validate_yaml_frontmatter(
&self,
skill: &SkillDefinition,
mut result: ValidationResult,
) -> Result<ValidationResult, ServiceError> {
if !skill.skill_file.exists() {
return Ok(result);
}
let content = match fs::read_to_string(&skill.skill_file).await {
Ok(c) => c,
Err(e) => {
result = result.with_error(
"yaml_frontmatter",
&format!("Cannot read SKILL.md for frontmatter validation: {}", e),
ErrorSeverity::Error,
);
return Ok(result);
}
};
result = frontmatter::validate_content(&content, result);
Ok(result)
}
pub async fn validate_skill_directory(
&self,
skill_path: &Path,
) -> Result<ValidationResult, ServiceError> {
if !skill_path.exists() {
return Ok(ValidationResult::invalid("Skill directory does not exist"));
}
if !skill_path.is_dir() {
return Ok(ValidationResult::invalid("Skill path is not a directory"));
}
let result = dir_structure::ensure_skill_md_exists(skill_path, ValidationResult::valid());
let (has_scripts, has_references, has_assets, mut result) =
dir_structure::scan_skill_directory_entries(skill_path, result).await?;
if has_scripts {
result = self
.validate_scripts_directory(&skill_path.join("scripts"), result)
.await?;
}
if has_references {
result = self
.validate_references_directory(&skill_path.join("references"), result)
.await?;
}
if has_assets {
result = self
.validate_assets_directory(&skill_path.join("assets"), result)
.await?;
}
result.calculate_score();
Ok(result)
}
async fn validate_extension_directory(
&self,
dir_path: &Path,
mut result: ValidationResult,
config: ExtensionCheckConfig<'_>,
) -> Result<ValidationResult, ServiceError> {
if !dir_path.exists() {
return Ok(result);
}
let mut entries = fs::read_dir(dir_path).await?;
while let Some(entry) = entries.next_entry().await? {
extension_check::process_file_extension(&entry.path(), &mut result, &config);
}
Ok(result)
}
async fn validate_scripts_directory(
&self,
scripts_path: &Path,
result: ValidationResult,
) -> Result<ValidationResult, ServiceError> {
self.validate_extension_directory(
scripts_path,
result,
extension_check::extension_config(ExtensionPreset::Scripts),
)
.await
}
async fn validate_references_directory(
&self,
references_path: &Path,
result: ValidationResult,
) -> Result<ValidationResult, ServiceError> {
self.validate_extension_directory(
references_path,
result,
extension_check::extension_config(ExtensionPreset::References),
)
.await
}
async fn validate_assets_directory(
&self,
assets_path: &Path,
mut result: ValidationResult,
) -> Result<ValidationResult, ServiceError> {
if !assets_path.exists() {
return Ok(result);
}
let mut entries = fs::read_dir(assets_path).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_file() {
match fs::metadata(&path).await {
Ok(_) => {}
Err(e) => {
result = result.with_warning(
"assets",
&format!("Cannot access asset file {}: {}", path.display(), e),
);
}
}
}
}
Ok(result)
}
fn apply_content_quality_checks(
content: &str,
description: &str,
mut result: ValidationResult,
) -> ValidationResult {
const IMPERATIVE: &[&str] = &[
"To", "Use", "Run", "Execute", "Create", "Generate", "Process",
];
let imperative_count = IMPERATIVE
.iter()
.filter(|i| content.contains(&format!("{} ", i)))
.count();
if imperative_count < 2 {
result = result.with_warning(
"content_quality",
"Content may not follow imperative style (use verb-first instructions)",
);
}
if !content.contains("Example") && !content.contains("example") {
result = result.with_warning(
"content_quality",
"Consider adding examples to help users understand how to use the skill",
);
}
const TRIGGER_WORDS: &[&str] = &[
"extract", "process", "convert", "generate", "create", "send", "parse",
];
let trigger_count = TRIGGER_WORDS
.iter()
.filter(|w| description.to_lowercase().contains(*w))
.count();
if trigger_count == 0 {
result = result.with_warning(
"content_quality",
"Description should include clear trigger words indicating what the skill does",
);
}
result
}
pub async fn qualitatively_validate_skill(
&self,
skill: &SkillDefinition,
) -> Result<ValidationResult, ServiceError> {
let mut result = field_validation::validate_required_fields(
skill,
ValidationResult::valid(),
&self.required_fields,
self.max_description_length,
);
result =
file_structure::validate_file_structure(skill, result, self.max_file_size_mb).await?;
if !skill.skill_file.exists() {
result.calculate_score();
return Ok(result);
}
let content = match fs::read_to_string(&skill.skill_file).await {
Ok(c) => c,
Err(_) => {
result = result.with_error(
"content_quality",
"Cannot read skill content for quality validation",
ErrorSeverity::Error,
);
result.calculate_score();
return Ok(result);
}
};
result = Self::apply_content_quality_checks(&content, &skill.description, result);
result.calculate_score();
Ok(result)
}
}