Skip to main content

fastskill_core/validation/
standard_validator.rs

1//! StandardValidator implements AI Skill standard validation
2//!
3//! This module provides comprehensive validation for AI skills according to
4//! AI Skill standard specification, covering name format, description length,
5//! directory structure, file references, and metadata field constraints.
6
7use 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
14// Compile regexes once at startup
15static 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/// Validation result containing outcome and detailed error reporting
26#[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/// Specific validation error types for precise error reporting
35#[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
47/// Core validator implementing AI Skill standard requirements
48pub struct StandardValidator;
49
50impl StandardValidator {
51    /// Validate skill name format according to standard
52    pub fn validate_name(name: &str) -> Result<(), ValidationError> {
53        // Name format: 1-64 chars, lowercase alphanumeric + hyphens, no leading/trailing/consecutive hyphens
54
55        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    /// Validate skill description length
75    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    /// Main validation method for skill directory
83    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        // Validate name
104        if let Err(e) = Self::validate_name(&frontmatter.name) {
105            errors.push(e);
106        }
107
108        // Validate description length
109        if let Err(e) = Self::validate_description(&frontmatter.description) {
110            errors.push(e);
111        }
112
113        // Validate compatibility
114        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        // Validate file references
125        Self::validate_file_references(skill_path, &frontmatter, &mut errors);
126
127        // Check directory structure
128        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    /// Parse YAML frontmatter from SKILL.md
139    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        // Simple frontmatter extraction (between --- markers)
143        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    /// Validate that referenced files exist
158    fn validate_file_references(
159        skill_path: &Path,
160        frontmatter: &SkillFrontmatter,
161        errors: &mut Vec<ValidationError>,
162    ) {
163        // Check for file references in description and other text fields
164        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                // Convert to relative path from skill root
171                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                // Check if referenced file exists
180                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                // Check if file is within allowed directories
188                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    /// Validate directory structure meets requirements
203    fn validate_directory_structure(
204        skill_path: &Path,
205        frontmatter: &SkillFrontmatter,
206        errors: &mut Vec<ValidationError>,
207    ) {
208        // Check if scripts directory exists if referenced
209        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        // Check if references directory exists if referenced
219        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        // Test with empty name (name is still required)
288        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}