fastskill_core/validation/
standard_validator.rs1use crate::core::metadata::SkillFrontmatter;
8use crate::core::service::ServiceError;
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12use std::sync::LazyLock;
13
14static NAME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
16 #[allow(clippy::expect_used)]
17 Regex::new(r"^[a-z0-9]+(-[a-z0-9]+)*$").expect("Invalid name regex")
18});
19static FILE_REF_REGEX: LazyLock<Regex> = LazyLock::new(|| {
20 #[allow(clippy::expect_used)]
21 Regex::new(r"\\.?(?:/|\\\\)?(?:scripts|references|assets)(?:/|\\\\)[^/\\s)]+")
22 .expect("Invalid file reference regex")
23});
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ValidationResult {
28 pub is_valid: bool,
29 pub errors: Vec<ValidationError>,
30 pub warnings: Vec<String>,
31 pub skill_path: PathBuf,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub enum ValidationError {
37 InvalidNameFormat(String),
38 NameMismatch { expected: String, actual: String },
39 InvalidDescriptionLength(usize),
40 InvalidCompatibilityLength(usize),
41 MissingRequiredField(String),
42 InvalidFileReference(String),
43 InvalidDirectoryStructure(String),
44 YamlParseError(String),
45}
46
47pub struct StandardValidator;
49
50impl StandardValidator {
51 pub fn validate_name(name: &str) -> Result<(), ValidationError> {
53 if name.is_empty() || name.len() > 64 {
56 return Err(ValidationError::InvalidNameFormat(format!(
57 "Skill name '{}' must be 1-64 characters long",
58 name
59 )));
60 }
61
62 if !NAME_REGEX.is_match(name) {
63 return Err(ValidationError::InvalidNameFormat(
64 format!(
65 "Skill name '{}' contains invalid characters. Use only lowercase alphanumeric and hyphens, no leading/trailing/consecutive hyphens",
66 name
67 )
68 ));
69 }
70
71 Ok(())
72 }
73
74 pub fn validate_description(description: &str) -> Result<(), ValidationError> {
76 if description.is_empty() || description.len() > 1024 {
77 return Err(ValidationError::InvalidDescriptionLength(description.len()));
78 }
79 Ok(())
80 }
81
82 pub fn validate_skill_directory(skill_path: &Path) -> Result<ValidationResult, ServiceError> {
84 let skill_md_path = skill_path.join("SKILL.md");
85
86 if !skill_md_path.exists() {
87 return Ok(ValidationResult {
88 is_valid: false,
89 errors: vec![ValidationError::InvalidDirectoryStructure(format!(
90 "SKILL.md not found in {}",
91 skill_path.display()
92 ))],
93 warnings: vec![],
94 skill_path: skill_path.to_path_buf(),
95 });
96 }
97
98 let frontmatter = Self::parse_frontmatter(&skill_md_path)?;
99
100 let mut errors = Vec::new();
101 let mut warnings = Vec::new();
102
103 if let Err(e) = Self::validate_name(&frontmatter.name) {
105 errors.push(e);
106 }
107
108 if let Err(e) = Self::validate_description(&frontmatter.description) {
110 errors.push(e);
111 }
112
113 if let Some(compatibility) = &frontmatter.compatibility {
115 if compatibility.len() > 256 {
116 errors.push(ValidationError::InvalidCompatibilityLength(
117 compatibility.len(),
118 ));
119 }
120 } else {
121 warnings.push("No compatibility field specified".to_string());
122 }
123
124 Self::validate_file_references(skill_path, &frontmatter, &mut errors);
126
127 Self::validate_directory_structure(skill_path, &frontmatter, &mut errors);
129
130 Ok(ValidationResult {
131 is_valid: errors.is_empty(),
132 errors,
133 warnings,
134 skill_path: skill_path.to_path_buf(),
135 })
136 }
137
138 fn parse_frontmatter(skill_md_path: &Path) -> Result<SkillFrontmatter, ServiceError> {
140 let content = std::fs::read_to_string(skill_md_path).map_err(ServiceError::Io)?;
141
142 let parts: Vec<&str> = content.split("---").collect();
144 if parts.len() < 3 {
145 return Err(ServiceError::Validation(
146 "No YAML frontmatter found in SKILL.md".to_string(),
147 ));
148 }
149
150 let yaml_content = parts[1];
151 let frontmatter: SkillFrontmatter = serde_yaml::from_str(yaml_content)
152 .map_err(|e| ServiceError::Custom(format!("Failed to parse SKILL.md: {}", e)))?;
153
154 Ok(frontmatter)
155 }
156
157 fn validate_file_references(
159 skill_path: &Path,
160 frontmatter: &SkillFrontmatter,
161 errors: &mut Vec<ValidationError>,
162 ) {
163 let text_fields = vec![("description", &frontmatter.description)];
165
166 for (field_name, content) in text_fields {
167 for capture in FILE_REF_REGEX.find_iter(content) {
168 let file_ref = capture.as_str();
169
170 let relative_path = if let Some(stripped) = file_ref.strip_prefix("./") {
172 stripped
173 } else {
174 file_ref
175 };
176
177 let full_path = skill_path.join(relative_path);
178
179 if !full_path.exists() {
181 errors.push(ValidationError::InvalidFileReference(format!(
182 "File reference '{}' in {} does not exist",
183 file_ref, field_name
184 )));
185 }
186
187 if let Ok(canonical_path) = full_path.canonicalize() {
189 if let Ok(canonical_skill_path) = skill_path.canonicalize() {
190 if !canonical_path.starts_with(&canonical_skill_path) {
191 errors.push(ValidationError::InvalidFileReference(format!(
192 "File reference '{}' in {} points outside skill directory",
193 file_ref, field_name
194 )));
195 }
196 }
197 }
198 }
199 }
200 }
201
202 fn validate_directory_structure(
204 skill_path: &Path,
205 frontmatter: &SkillFrontmatter,
206 errors: &mut Vec<ValidationError>,
207 ) {
208 if frontmatter.description.contains("scripts") {
210 let scripts_path = skill_path.join("scripts");
211 if !scripts_path.exists() {
212 errors.push(ValidationError::InvalidDirectoryStructure(
213 "scripts/ directory referenced but not found".to_string(),
214 ));
215 }
216 }
217
218 if frontmatter.description.contains("references") {
220 let references_path = skill_path.join("references");
221 if !references_path.exists() {
222 errors.push(ValidationError::InvalidDirectoryStructure(
223 "references/ directory referenced but not found".to_string(),
224 ));
225 }
226 }
227 }
228}
229
230#[cfg(test)]
231#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
232mod tests {
233 use super::*;
234 use tempfile::TempDir;
235
236 #[test]
237 fn test_validate_name_valid() {
238 assert!(StandardValidator::validate_name("my-skill").is_ok());
239 assert!(StandardValidator::validate_name("skill123").is_ok());
240 assert!(StandardValidator::validate_name("my-valid-skill-name").is_ok());
241 }
242
243 #[test]
244 fn test_validate_name_invalid() {
245 assert!(StandardValidator::validate_name("").is_err());
246 assert!(StandardValidator::validate_name("MY-SKILL").is_err());
247 assert!(StandardValidator::validate_name("-skill").is_err());
248 assert!(StandardValidator::validate_name("skill-").is_err());
249 assert!(StandardValidator::validate_name("skill--name").is_err());
250 }
251
252 #[test]
253 fn test_validate_description_valid() {
254 assert!(StandardValidator::validate_description("A valid description").is_ok());
255 assert!(StandardValidator::validate_description("x".repeat(100).as_str()).is_ok());
256 }
257
258 #[test]
259 fn test_validate_description_invalid() {
260 assert!(StandardValidator::validate_description("").is_err());
261 assert!(StandardValidator::validate_description("x".repeat(2000).as_str()).is_err());
262 }
263
264 #[test]
265 fn test_validate_skill_directory_valid() {
266 let temp_dir = TempDir::new().unwrap();
267 let skill_path = temp_dir.path();
268
269 let skill_md_content = r#"---
270name: test-skill
271version: "1.0.0"
272description: A test skill
273---
274"#;
275 std::fs::write(skill_path.join("SKILL.md"), skill_md_content).unwrap();
276
277 let result = StandardValidator::validate_skill_directory(skill_path).unwrap();
278 assert!(result.is_valid);
279 assert!(result.errors.is_empty());
280 }
281
282 #[test]
283 fn test_validate_skill_directory_missing_required() {
284 let temp_dir = TempDir::new().unwrap();
285 let skill_path = temp_dir.path();
286
287 let skill_md_content = r#"---
289name: ""
290description: A test skill
291---
292"#;
293 std::fs::write(skill_path.join("SKILL.md"), skill_md_content).unwrap();
294
295 let result = StandardValidator::validate_skill_directory(skill_path).unwrap();
296 assert!(!result.is_valid);
297 assert!(!result.errors.is_empty());
298 }
299
300 #[test]
301 fn test_parse_frontmatter_valid() {
302 let temp_dir = TempDir::new().unwrap();
303 let skill_path = temp_dir.path();
304
305 let skill_md_content = r#"---
306name: test-skill
307version: "1.0.0"
308description: A test skill
309---
310Content here
311"#;
312 std::fs::write(skill_path.join("SKILL.md"), skill_md_content).unwrap();
313
314 let frontmatter =
315 StandardValidator::parse_frontmatter(&skill_path.join("SKILL.md")).unwrap();
316 assert_eq!(frontmatter.name, "test-skill");
317 assert_eq!(frontmatter.version.as_deref(), Some("1.0.0"));
318 assert_eq!(frontmatter.description, "A test skill");
319 }
320
321 #[test]
322 fn test_validate_file_references_missing() {
323 let temp_dir = TempDir::new().unwrap();
324 let skill_path = temp_dir.path();
325
326 let skill_md_content = r#"---
327name: test-skill
328version: "1.0.0"
329description: See ./scripts/test.sh for details
330---
331"#;
332 std::fs::write(skill_path.join("SKILL.md"), skill_md_content).unwrap();
333
334 let result = StandardValidator::validate_skill_directory(skill_path).unwrap();
335 assert!(!result.is_valid);
336 assert!(!result.errors.is_empty());
337 }
338}