Skip to main content

fastskill_core/core/
validation.rs

1//! Validation functions for skill-project.toml and related structures
2
3use regex::Regex;
4use std::collections::HashSet;
5
6/// Validate semantic version format (MAJOR.MINOR.PATCH)
7pub fn validate_semver(version: &str) -> Result<(), ValidationError> {
8    let semver_regex = Regex::new(r"^\d+\.\d+\.\d+$")
9        .map_err(|e| ValidationError::Internal(format!("Failed to compile semver regex: {}", e)))?;
10
11    if !semver_regex.is_match(version) {
12        return Err(ValidationError::InvalidSemver(version.to_string()));
13    }
14
15    Ok(())
16}
17
18/// Validate identifier format (alphanumeric, hyphens, underscores)
19pub fn validate_identifier(identifier: &str) -> Result<(), ValidationError> {
20    if identifier.is_empty() {
21        return Err(ValidationError::EmptyIdentifier);
22    }
23
24    let identifier_regex = Regex::new(r"^[a-zA-Z0-9_-]+$").map_err(|e| {
25        ValidationError::Internal(format!("Failed to compile identifier regex: {}", e))
26    })?;
27
28    if !identifier_regex.is_match(identifier) {
29        return Err(ValidationError::InvalidIdentifier(identifier.to_string()));
30    }
31
32    Ok(())
33}
34
35/// Validate that skill-project.toml has at least one section
36pub fn validate_project_structure(
37    has_metadata: bool,
38    has_dependencies: bool,
39) -> Result<(), ValidationError> {
40    if !has_metadata && !has_dependencies {
41        return Err(ValidationError::MissingSections);
42    }
43
44    Ok(())
45}
46
47/// Validate name + version uniqueness
48/// Checks if a skill with the same name and version already exists
49pub fn validate_uniqueness(
50    name: &str,
51    version: &str,
52    existing_skills: &HashSet<(String, String)>,
53) -> Result<(), ValidationError> {
54    // First validate the inputs
55    validate_identifier(name)?;
56    validate_semver(version)?;
57
58    let key = (name.to_string(), version.to_string());
59    if existing_skills.contains(&key) {
60        return Err(ValidationError::DuplicateSkill {
61            name: name.to_string(),
62            version: version.to_string(),
63        });
64    }
65
66    Ok(())
67}
68
69/// Validation errors
70#[derive(Debug, thiserror::Error)]
71pub enum ValidationError {
72    #[error("Invalid semantic version format: {0}. Expected MAJOR.MINOR.PATCH")]
73    InvalidSemver(String),
74
75    #[error("Invalid identifier: {0}. Must contain only alphanumeric characters, hyphens, and underscores")]
76    InvalidIdentifier(String),
77
78    #[error("Empty identifier not allowed")]
79    EmptyIdentifier,
80
81    #[error("skill-project.toml must have at least one section ([metadata] or [dependencies])")]
82    MissingSections,
83
84    #[error("Duplicate skill: {name}@{version} already exists")]
85    DuplicateSkill { name: String, version: String },
86
87    #[error("Internal validation error: {0}")]
88    Internal(String),
89}
90
91#[cfg(test)]
92#[allow(clippy::unwrap_used, clippy::panic)]
93mod tests {
94    use super::*;
95
96    // Tests for validate_semver
97    #[test]
98    fn test_validate_semver_valid() {
99        assert!(validate_semver("1.0.0").is_ok());
100        assert!(validate_semver("0.1.0").is_ok());
101        assert!(validate_semver("10.20.30").is_ok());
102        assert!(validate_semver("999.999.999").is_ok());
103        assert!(validate_semver("0.0.1").is_ok());
104    }
105
106    #[test]
107    fn test_validate_semver_invalid() {
108        assert!(validate_semver("1.0").is_err());
109        assert!(validate_semver("1").is_err());
110        assert!(validate_semver("v1.0.0").is_err());
111        assert!(validate_semver("1.0.0-beta").is_err());
112        assert!(validate_semver("1.0.0+metadata").is_err());
113        assert!(validate_semver("invalid").is_err());
114        assert!(validate_semver("1.0.0.0").is_err());
115        assert!(validate_semver("1.0").is_err());
116    }
117
118    #[test]
119    fn test_validate_semver_edge_cases() {
120        // Empty string
121        assert!(validate_semver("").is_err());
122
123        // Very long version string (actually valid format, just large numbers)
124        let long_version = "1".repeat(100) + ".0.0";
125        // This is technically valid semver format, so we test it passes
126        assert!(validate_semver(&long_version).is_ok());
127
128        // Special characters (invalid)
129        assert!(validate_semver("1.0.0-alpha").is_err());
130        assert!(validate_semver("1.0.0_beta").is_err());
131        assert!(validate_semver("1.0.0.beta").is_err());
132
133        // Assert error type
134        if let Err(ValidationError::InvalidSemver(_)) = validate_semver("1.0") {
135            // Correct error type
136        } else {
137            panic!("Expected InvalidSemver error");
138        }
139    }
140
141    // Tests for validate_identifier
142    #[test]
143    fn test_validate_identifier_valid() {
144        assert!(validate_identifier("my-skill").is_ok());
145        assert!(validate_identifier("my_skill").is_ok());
146        assert!(validate_identifier("skill123").is_ok());
147        assert!(validate_identifier("skill-123_test").is_ok());
148        assert!(validate_identifier("a").is_ok());
149        assert!(validate_identifier("A").is_ok());
150        assert!(validate_identifier("123").is_ok());
151        assert!(validate_identifier(&"a".repeat(100)).is_ok());
152    }
153
154    #[test]
155    fn test_validate_identifier_invalid() {
156        assert!(validate_identifier("").is_err());
157        assert!(validate_identifier("my skill").is_err()); // space
158        assert!(validate_identifier("my.skill").is_err()); // dot
159        assert!(validate_identifier("my@skill").is_err()); // @ symbol
160        assert!(validate_identifier("my#skill").is_err()); // # symbol
161        assert!(validate_identifier("my!skill").is_err()); // ! symbol
162    }
163
164    #[test]
165    fn test_validate_identifier_edge_cases() {
166        // Empty string
167        assert!(validate_identifier("").is_err());
168        if let Err(ValidationError::EmptyIdentifier) = validate_identifier("") {
169            // Correct error type
170        } else {
171            panic!("Expected EmptyIdentifier error");
172        }
173
174        // Single character (valid)
175        assert!(validate_identifier("a").is_ok());
176        assert!(validate_identifier("_").is_ok());
177        assert!(validate_identifier("-").is_ok());
178
179        // Very long identifier
180        let long_id = "a".repeat(1000);
181        assert!(validate_identifier(&long_id).is_ok());
182
183        // Mixed case
184        assert!(validate_identifier("MySkill_123-Test").is_ok());
185    }
186
187    // Tests for validate_project_structure
188    #[test]
189    fn test_validate_project_structure_valid() {
190        // Has metadata only
191        assert!(validate_project_structure(true, false).is_ok());
192
193        // Has dependencies only
194        assert!(validate_project_structure(false, true).is_ok());
195
196        // Has both
197        assert!(validate_project_structure(true, true).is_ok());
198    }
199
200    #[test]
201    fn test_validate_project_structure_invalid() {
202        // Has neither
203        assert!(validate_project_structure(false, false).is_err());
204
205        if let Err(ValidationError::MissingSections) = validate_project_structure(false, false) {
206            // Correct error type
207        } else {
208            panic!("Expected MissingSections error");
209        }
210    }
211
212    // Tests for validate_uniqueness
213    #[test]
214    fn test_validate_uniqueness_unique() {
215        let mut existing_skills = HashSet::new();
216        existing_skills.insert(("other-skill".to_string(), "1.0.0".to_string()));
217
218        // Unique skill name and version
219        assert!(validate_uniqueness("my-skill", "1.0.0", &existing_skills).is_ok());
220
221        // Same name, different version
222        assert!(validate_uniqueness("other-skill", "2.0.0", &existing_skills).is_ok());
223
224        // Different name, same version
225        assert!(validate_uniqueness("new-skill", "1.0.0", &existing_skills).is_ok());
226    }
227
228    #[test]
229    fn test_validate_uniqueness_duplicate() {
230        let mut existing_skills = HashSet::new();
231        existing_skills.insert(("my-skill".to_string(), "1.0.0".to_string()));
232
233        // Duplicate name and version
234        assert!(validate_uniqueness("my-skill", "1.0.0", &existing_skills).is_err());
235
236        if let Err(ValidationError::DuplicateSkill { name, version }) =
237            validate_uniqueness("my-skill", "1.0.0", &existing_skills)
238        {
239            assert_eq!(name, "my-skill");
240            assert_eq!(version, "1.0.0");
241        } else {
242            panic!("Expected DuplicateSkill error");
243        }
244    }
245
246    #[test]
247    fn test_validate_uniqueness_invalid_inputs() {
248        let existing_skills = HashSet::new();
249
250        // Invalid identifier
251        assert!(validate_uniqueness("", "1.0.0", &existing_skills).is_err());
252        assert!(validate_uniqueness("my skill", "1.0.0", &existing_skills).is_err());
253
254        // Invalid version
255        assert!(validate_uniqueness("my-skill", "1.0", &existing_skills).is_err());
256        assert!(validate_uniqueness("my-skill", "invalid", &existing_skills).is_err());
257    }
258
259    #[test]
260    fn test_validate_uniqueness_edge_cases() {
261        let mut existing_skills = HashSet::new();
262        existing_skills.insert(("skill".to_string(), "0.0.1".to_string()));
263
264        // Empty existing skills set
265        let empty_set = HashSet::new();
266        assert!(validate_uniqueness("new-skill", "1.0.0", &empty_set).is_ok());
267
268        // Multiple existing skills
269        existing_skills.insert(("skill2".to_string(), "2.0.0".to_string()));
270        existing_skills.insert(("skill3".to_string(), "3.0.0".to_string()));
271        assert!(validate_uniqueness("skill4", "4.0.0", &existing_skills).is_ok());
272        assert!(validate_uniqueness("skill", "0.0.1", &existing_skills).is_err());
273    }
274}