Skip to main content

fastskill_core/validation/
skill_validator.rs

1//! Skill validation implementation
2
3use crate::core::service::ServiceError;
4use crate::core::skill_manager::SkillDefinition;
5use crate::validation::content_safety;
6use crate::validation::dir_structure;
7use crate::validation::extension_check::{self, ExtensionCheckConfig, ExtensionPreset};
8use crate::validation::field_validation;
9use crate::validation::file_structure;
10use crate::validation::frontmatter;
11use crate::validation::result::{ErrorSeverity, ValidationResult};
12use std::path::Path;
13use tokio::fs;
14
15/// Skill validator for comprehensive validation
16pub struct SkillValidator {
17    /// Maximum allowed file size (MB)
18    max_file_size_mb: usize,
19
20    /// Maximum allowed skill description length
21    max_description_length: usize,
22
23    /// Required fields that must be present
24    required_fields: Vec<String>,
25
26    /// Dangerous patterns to check for in content
27    dangerous_patterns: Vec<String>,
28}
29
30impl Default for SkillValidator {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl SkillValidator {
37    /// Create a new skill validator with default settings
38    pub fn new() -> Self {
39        Self {
40            max_file_size_mb: 10,
41            max_description_length: 500,
42            required_fields: vec![
43                "name".to_string(),
44                "description".to_string(),
45                "version".to_string(),
46            ],
47            dangerous_patterns: content_safety::default_dangerous_patterns(),
48        }
49    }
50
51    /// Create a new skill validator with custom settings
52    pub fn with_config(
53        max_file_size_mb: usize,
54        max_description_length: usize,
55        required_fields: Vec<String>,
56    ) -> Self {
57        Self {
58            max_file_size_mb,
59            max_description_length,
60            required_fields,
61            dangerous_patterns: content_safety::default_dangerous_patterns(),
62        }
63    }
64
65    /// Validate a skill definition comprehensively
66    pub async fn validate_skill(
67        &self,
68        skill: &SkillDefinition,
69    ) -> Result<ValidationResult, ServiceError> {
70        let mut result = ValidationResult::valid();
71
72        result = field_validation::validate_required_fields(
73            skill,
74            result,
75            &self.required_fields,
76            self.max_description_length,
77        );
78        result = field_validation::validate_field_formats(skill, result);
79        result =
80            file_structure::validate_file_structure(skill, result, self.max_file_size_mb).await?;
81
82        // Validate content safety
83        result = self.validate_content_safety(skill, result).await?;
84
85        // Validate YAML frontmatter if present
86        result = self.validate_yaml_frontmatter(skill, result).await?;
87
88        // Calculate final score
89        result.calculate_score();
90
91        Ok(result)
92    }
93
94    /// Validate content for safety issues
95    async fn validate_content_safety(
96        &self,
97        skill: &SkillDefinition,
98        result: ValidationResult,
99    ) -> Result<ValidationResult, ServiceError> {
100        let result = if skill.skill_file.exists() {
101            content_safety::validate_skill_file_content(
102                &skill.skill_file,
103                result,
104                &self.dangerous_patterns,
105            )
106            .await?
107        } else {
108            result
109        };
110        let script_files = skill.script_files.as_deref().unwrap_or(&[]);
111        let mut result = result;
112        for script_file in script_files {
113            if script_file.exists() {
114                result = content_safety::validate_script_file_content(
115                    script_file,
116                    result,
117                    &self.dangerous_patterns,
118                )
119                .await?;
120            }
121        }
122        Ok(result)
123    }
124
125    /// Validate YAML frontmatter format
126    async fn validate_yaml_frontmatter(
127        &self,
128        skill: &SkillDefinition,
129        mut result: ValidationResult,
130    ) -> Result<ValidationResult, ServiceError> {
131        if !skill.skill_file.exists() {
132            return Ok(result);
133        }
134        let content = match fs::read_to_string(&skill.skill_file).await {
135            Ok(c) => c,
136            Err(e) => {
137                result = result.with_error(
138                    "yaml_frontmatter",
139                    &format!("Cannot read SKILL.md for frontmatter validation: {}", e),
140                    ErrorSeverity::Error,
141                );
142                return Ok(result);
143            }
144        };
145        result = frontmatter::validate_content(&content, result);
146        Ok(result)
147    }
148
149    /// Validate a skill directory structure
150    pub async fn validate_skill_directory(
151        &self,
152        skill_path: &Path,
153    ) -> Result<ValidationResult, ServiceError> {
154        if !skill_path.exists() {
155            return Ok(ValidationResult::invalid("Skill directory does not exist"));
156        }
157        if !skill_path.is_dir() {
158            return Ok(ValidationResult::invalid("Skill path is not a directory"));
159        }
160        let result = dir_structure::ensure_skill_md_exists(skill_path, ValidationResult::valid());
161        let (has_scripts, has_references, has_assets, mut result) =
162            dir_structure::scan_skill_directory_entries(skill_path, result).await?;
163        if has_scripts {
164            result = self
165                .validate_scripts_directory(&skill_path.join("scripts"), result)
166                .await?;
167        }
168        if has_references {
169            result = self
170                .validate_references_directory(&skill_path.join("references"), result)
171                .await?;
172        }
173        if has_assets {
174            result = self
175                .validate_assets_directory(&skill_path.join("assets"), result)
176                .await?;
177        }
178        result.calculate_score();
179        Ok(result)
180    }
181
182    /// Validate a directory by checking file extensions against an allowed list.
183    async fn validate_extension_directory(
184        &self,
185        dir_path: &Path,
186        mut result: ValidationResult,
187        config: ExtensionCheckConfig<'_>,
188    ) -> Result<ValidationResult, ServiceError> {
189        if !dir_path.exists() {
190            return Ok(result);
191        }
192        let mut entries = fs::read_dir(dir_path).await?;
193        while let Some(entry) = entries.next_entry().await? {
194            extension_check::process_file_extension(&entry.path(), &mut result, &config);
195        }
196        Ok(result)
197    }
198
199    /// Validate scripts directory
200    async fn validate_scripts_directory(
201        &self,
202        scripts_path: &Path,
203        result: ValidationResult,
204    ) -> Result<ValidationResult, ServiceError> {
205        self.validate_extension_directory(
206            scripts_path,
207            result,
208            extension_check::extension_config(ExtensionPreset::Scripts),
209        )
210        .await
211    }
212
213    /// Validate references directory
214    async fn validate_references_directory(
215        &self,
216        references_path: &Path,
217        result: ValidationResult,
218    ) -> Result<ValidationResult, ServiceError> {
219        self.validate_extension_directory(
220            references_path,
221            result,
222            extension_check::extension_config(ExtensionPreset::References),
223        )
224        .await
225    }
226
227    /// Validate assets directory
228    async fn validate_assets_directory(
229        &self,
230        assets_path: &Path,
231        mut result: ValidationResult,
232    ) -> Result<ValidationResult, ServiceError> {
233        if !assets_path.exists() {
234            return Ok(result);
235        }
236
237        let mut entries = fs::read_dir(assets_path).await?;
238
239        while let Some(entry) = entries.next_entry().await? {
240            // Assets can be any type of file, just check if they exist and are readable
241            let path = entry.path();
242
243            if path.is_file() {
244                // Check if file is readable
245                match fs::metadata(&path).await {
246                    Ok(_) => {}
247                    Err(e) => {
248                        result = result.with_warning(
249                            "assets",
250                            &format!("Cannot access asset file {}: {}", path.display(), e),
251                        );
252                    }
253                }
254            }
255        }
256
257        Ok(result)
258    }
259
260    fn apply_content_quality_checks(
261        content: &str,
262        description: &str,
263        mut result: ValidationResult,
264    ) -> ValidationResult {
265        const IMPERATIVE: &[&str] = &[
266            "To", "Use", "Run", "Execute", "Create", "Generate", "Process",
267        ];
268        let imperative_count = IMPERATIVE
269            .iter()
270            .filter(|i| content.contains(&format!("{} ", i)))
271            .count();
272        if imperative_count < 2 {
273            result = result.with_warning(
274                "content_quality",
275                "Content may not follow imperative style (use verb-first instructions)",
276            );
277        }
278        if !content.contains("Example") && !content.contains("example") {
279            result = result.with_warning(
280                "content_quality",
281                "Consider adding examples to help users understand how to use the skill",
282            );
283        }
284        const TRIGGER_WORDS: &[&str] = &[
285            "extract", "process", "convert", "generate", "create", "send", "parse",
286        ];
287        let trigger_count = TRIGGER_WORDS
288            .iter()
289            .filter(|w| description.to_lowercase().contains(*w))
290            .count();
291        if trigger_count == 0 {
292            result = result.with_warning(
293                "content_quality",
294                "Description should include clear trigger words indicating what the skill does",
295            );
296        }
297        result
298    }
299
300    /// Qualitative validation (user-requested function)
301    pub async fn qualitatively_validate_skill(
302        &self,
303        skill: &SkillDefinition,
304    ) -> Result<ValidationResult, ServiceError> {
305        let mut result = field_validation::validate_required_fields(
306            skill,
307            ValidationResult::valid(),
308            &self.required_fields,
309            self.max_description_length,
310        );
311        result =
312            file_structure::validate_file_structure(skill, result, self.max_file_size_mb).await?;
313        if !skill.skill_file.exists() {
314            result.calculate_score();
315            return Ok(result);
316        }
317        let content = match fs::read_to_string(&skill.skill_file).await {
318            Ok(c) => c,
319            Err(_) => {
320                result = result.with_error(
321                    "content_quality",
322                    "Cannot read skill content for quality validation",
323                    ErrorSeverity::Error,
324                );
325                result.calculate_score();
326                return Ok(result);
327            }
328        };
329        result = Self::apply_content_quality_checks(&content, &skill.description, result);
330        result.calculate_score();
331        Ok(result)
332    }
333}