Skip to main content

fastskill_core/core/
version.rs

1//! Version constraint parsing and evaluation
2
3use semver::{Version, VersionReq};
4use thiserror::Error;
5
6/// Version constraint types
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum VersionConstraint {
9    /// Exact version match (e.g., "1.2.3")
10    Exact(String),
11    /// Caret constraint (e.g., "^1.2.3" matches >=1.2.3 <2.0.0)
12    Caret(String),
13    /// Tilde constraint (e.g., "~1.2.0" matches >=1.2.0 <1.3.0)
14    Tilde(String),
15    /// Greater than or equal (e.g., ">=1.0.0")
16    GreaterEqual(String),
17    /// Less than or equal (e.g., "<=2.0.0")
18    LessEqual(String),
19    /// Range constraint (e.g., ">=1.0.0,<2.0.0")
20    Range {
21        min: Option<String>,
22        max: Option<String>,
23    },
24    /// Any version (no constraint)
25    Any,
26}
27
28/// Errors related to version constraints
29#[derive(Debug, Error)]
30pub enum VersionError {
31    #[error("Invalid version format: {0}")]
32    InvalidVersion(String),
33
34    #[error("Invalid constraint format: {0}")]
35    InvalidConstraint(String),
36
37    #[error("Failed to parse version: {0}")]
38    ParseError(String),
39}
40
41impl VersionConstraint {
42    /// Parse a version constraint string
43    pub fn parse(constraint: &str) -> Result<Self, VersionError> {
44        let constraint = constraint.trim();
45
46        if constraint.is_empty() || constraint == "*" {
47            return Ok(VersionConstraint::Any);
48        }
49
50        // Exact version
51        if !constraint.starts_with('^')
52            && !constraint.starts_with('~')
53            && !constraint.starts_with('>')
54            && !constraint.starts_with('<')
55            && !constraint.contains(',')
56        {
57            // Try to parse as exact version
58            if Version::parse(constraint).is_ok() {
59                return Ok(VersionConstraint::Exact(constraint.to_string()));
60            }
61        }
62
63        // Caret constraint (^1.2.3)
64        if constraint.starts_with('^') {
65            let version = constraint.trim_start_matches('^').trim();
66            if Version::parse(version).is_ok() {
67                return Ok(VersionConstraint::Caret(version.to_string()));
68            }
69        }
70
71        // Tilde constraint (~1.2.0)
72        if constraint.starts_with('~') {
73            let version = constraint.trim_start_matches('~').trim();
74            if Version::parse(version).is_ok() {
75                return Ok(VersionConstraint::Tilde(version.to_string()));
76            }
77        }
78
79        // Greater than or equal (>=1.0.0)
80        if constraint.starts_with(">=") {
81            let version = constraint.trim_start_matches(">=").trim();
82            if Version::parse(version).is_ok() {
83                return Ok(VersionConstraint::GreaterEqual(version.to_string()));
84            }
85        }
86
87        // Less than or equal (<=2.0.0)
88        if constraint.starts_with("<=") {
89            let version = constraint.trim_start_matches("<=").trim();
90            if Version::parse(version).is_ok() {
91                return Ok(VersionConstraint::LessEqual(version.to_string()));
92            }
93        }
94
95        // Range constraint (>=1.0.0,<2.0.0)
96        if constraint.contains(',') {
97            let parts: Vec<&str> = constraint.split(',').map(|s| s.trim()).collect();
98            let mut min = None;
99            let mut max = None;
100
101            for part in parts {
102                if part.starts_with(">=") {
103                    let version = part.trim_start_matches(">=").trim();
104                    if Version::parse(version).is_ok() {
105                        min = Some(version.to_string());
106                    }
107                } else if part.starts_with("<=") {
108                    let version = part.trim_start_matches("<=").trim();
109                    if Version::parse(version).is_ok() {
110                        max = Some(version.to_string());
111                    }
112                } else if part.starts_with('<') {
113                    let version = part.trim_start_matches('<').trim();
114                    if Version::parse(version).is_ok() {
115                        max = Some(version.to_string());
116                    }
117                } else if part.starts_with('>') {
118                    let version = part.trim_start_matches('>').trim();
119                    if Version::parse(version).is_ok() {
120                        min = Some(version.to_string());
121                    }
122                }
123            }
124
125            return Ok(VersionConstraint::Range { min, max });
126        }
127
128        // Try to parse as semver requirement
129        if let Ok(req) = VersionReq::parse(constraint) {
130            // Convert to our constraint type
131            let req_str = req.to_string();
132            if req_str.starts_with('^') {
133                return Ok(VersionConstraint::Caret(
134                    req_str.trim_start_matches('^').to_string(),
135                ));
136            } else if req_str.starts_with('~') {
137                return Ok(VersionConstraint::Tilde(
138                    req_str.trim_start_matches('~').to_string(),
139                ));
140            } else if req_str.starts_with(">=") {
141                return Ok(VersionConstraint::GreaterEqual(
142                    req_str.trim_start_matches(">=").to_string(),
143                ));
144            }
145        }
146
147        Err(VersionError::InvalidConstraint(constraint.to_string()))
148    }
149
150    /// Check if a version satisfies this constraint
151    pub fn satisfies(&self, version: &str) -> Result<bool, VersionError> {
152        let ver = Version::parse(version).map_err(|e| {
153            VersionError::ParseError(format!("Failed to parse version '{}': {}", version, e))
154        })?;
155
156        match self {
157            VersionConstraint::Exact(exact) => {
158                let exact_ver = Version::parse(exact).map_err(|e| {
159                    VersionError::ParseError(format!("Invalid exact version '{}': {}", exact, e))
160                })?;
161                Ok(ver == exact_ver)
162            }
163            VersionConstraint::Caret(base) => {
164                let base_ver = Version::parse(base).map_err(|e| {
165                    VersionError::ParseError(format!("Invalid caret base '{}': {}", base, e))
166                })?;
167                // ^1.2.3 means >=1.2.3 <2.0.0
168                Ok(ver >= base_ver && ver.major == base_ver.major)
169            }
170            VersionConstraint::Tilde(base) => {
171                let base_ver = Version::parse(base).map_err(|e| {
172                    VersionError::ParseError(format!("Invalid tilde base '{}': {}", base, e))
173                })?;
174                // ~1.2.0 means >=1.2.0 <1.3.0
175                Ok(ver >= base_ver && ver.major == base_ver.major && ver.minor == base_ver.minor)
176            }
177            VersionConstraint::GreaterEqual(min) => {
178                let min_ver = Version::parse(min).map_err(|e| {
179                    VersionError::ParseError(format!("Invalid min version '{}': {}", min, e))
180                })?;
181                Ok(ver >= min_ver)
182            }
183            VersionConstraint::LessEqual(max) => {
184                let max_ver = Version::parse(max).map_err(|e| {
185                    VersionError::ParseError(format!("Invalid max version '{}': {}", max, e))
186                })?;
187                Ok(ver <= max_ver)
188            }
189            VersionConstraint::Range { min, max } => {
190                let mut satisfies = true;
191                if let Some(min_str) = min {
192                    let min_ver = Version::parse(min_str).map_err(|e| {
193                        VersionError::ParseError(format!(
194                            "Invalid min version '{}': {}",
195                            min_str, e
196                        ))
197                    })?;
198                    satisfies = satisfies && ver >= min_ver;
199                }
200                if let Some(max_str) = max {
201                    let max_ver = Version::parse(max_str).map_err(|e| {
202                        VersionError::ParseError(format!(
203                            "Invalid max version '{}': {}",
204                            max_str, e
205                        ))
206                    })?;
207                    satisfies = satisfies && ver <= max_ver;
208                }
209                Ok(satisfies)
210            }
211            VersionConstraint::Any => Ok(true),
212        }
213    }
214}
215
216/// Compare two version strings
217pub fn compare_versions(v1: &str, v2: &str) -> Result<std::cmp::Ordering, VersionError> {
218    let ver1 = Version::parse(v1).map_err(|e| {
219        VersionError::ParseError(format!("Failed to parse version '{}': {}", v1, e))
220    })?;
221    let ver2 = Version::parse(v2).map_err(|e| {
222        VersionError::ParseError(format!("Failed to parse version '{}': {}", v2, e))
223    })?;
224    Ok(ver1.cmp(&ver2))
225}
226
227/// Check if version1 is newer than version2
228pub fn is_newer(v1: &str, v2: &str) -> Result<bool, VersionError> {
229    Ok(compare_versions(v1, v2)? == std::cmp::Ordering::Greater)
230}
231
232#[cfg(test)]
233#[allow(clippy::unwrap_used)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn test_exact_constraint() {
239        let constraint = VersionConstraint::parse("1.2.3").unwrap();
240        assert!(constraint.satisfies("1.2.3").unwrap());
241        assert!(!constraint.satisfies("1.2.4").unwrap());
242    }
243
244    #[test]
245    fn test_caret_constraint() {
246        let constraint = VersionConstraint::parse("^1.2.3").unwrap();
247        assert!(constraint.satisfies("1.2.3").unwrap());
248        assert!(constraint.satisfies("1.3.0").unwrap());
249        assert!(!constraint.satisfies("2.0.0").unwrap());
250    }
251
252    #[test]
253    fn test_tilde_constraint() {
254        let constraint = VersionConstraint::parse("~1.2.0").unwrap();
255        assert!(constraint.satisfies("1.2.0").unwrap());
256        assert!(constraint.satisfies("1.2.5").unwrap());
257        assert!(!constraint.satisfies("1.3.0").unwrap());
258    }
259
260    #[test]
261    fn test_greater_equal_constraint() {
262        let constraint = VersionConstraint::parse(">=1.0.0").unwrap();
263        assert!(constraint.satisfies("1.0.0").unwrap());
264        assert!(constraint.satisfies("2.0.0").unwrap());
265        assert!(!constraint.satisfies("0.9.0").unwrap());
266    }
267
268    #[test]
269    fn test_compare_versions() {
270        assert_eq!(
271            compare_versions("1.2.3", "1.2.4").unwrap(),
272            std::cmp::Ordering::Less
273        );
274        assert_eq!(
275            compare_versions("2.0.0", "1.9.9").unwrap(),
276            std::cmp::Ordering::Greater
277        );
278        assert_eq!(
279            compare_versions("1.2.3", "1.2.3").unwrap(),
280            std::cmp::Ordering::Equal
281        );
282    }
283
284    #[test]
285    fn test_is_newer() {
286        assert!(is_newer("1.2.4", "1.2.3").unwrap());
287        assert!(!is_newer("1.2.3", "1.2.4").unwrap());
288    }
289}