fastskill_core/core/
version.rs1use semver::{Version, VersionReq};
4use thiserror::Error;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum VersionConstraint {
9 Exact(String),
11 Caret(String),
13 Tilde(String),
15 GreaterEqual(String),
17 LessEqual(String),
19 Range {
21 min: Option<String>,
22 max: Option<String>,
23 },
24 Any,
26}
27
28#[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 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 if !constraint.starts_with('^')
52 && !constraint.starts_with('~')
53 && !constraint.starts_with('>')
54 && !constraint.starts_with('<')
55 && !constraint.contains(',')
56 {
57 if Version::parse(constraint).is_ok() {
59 return Ok(VersionConstraint::Exact(constraint.to_string()));
60 }
61 }
62
63 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 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 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 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 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 if let Ok(req) = VersionReq::parse(constraint) {
130 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 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 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 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
216pub 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
227pub 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}