fastskill_core/validation/
skill_validator.rs1use 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
15pub struct SkillValidator {
17 max_file_size_mb: usize,
19
20 max_description_length: usize,
22
23 required_fields: Vec<String>,
25
26 dangerous_patterns: Vec<String>,
28}
29
30impl Default for SkillValidator {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36impl SkillValidator {
37 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 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 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 result = self.validate_content_safety(skill, result).await?;
84
85 result = self.validate_yaml_frontmatter(skill, result).await?;
87
88 result.calculate_score();
90
91 Ok(result)
92 }
93
94 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 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 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 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 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 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 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 let path = entry.path();
242
243 if path.is_file() {
244 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 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}