deps_core/
version_matcher.rs1use crate::ConcreteVersion;
7use semver::Version;
8use std::borrow::Cow;
9
10pub trait VersionRequirementMatcher: Send + Sync {
15 fn is_latest_satisfying(&self, requirement: &ConcreteVersion, latest: &ConcreteVersion)
29 -> bool;
30}
31
32#[derive(Debug, Clone, Copy)]
37pub struct SemverMatcher;
38
39impl VersionRequirementMatcher for SemverMatcher {
40 fn is_latest_satisfying(
41 &self,
42 requirement: &ConcreteVersion,
43 latest: &ConcreteVersion,
44 ) -> bool {
45 use semver::VersionReq;
46
47 let requirement = requirement.as_str();
48 let latest = latest.as_str();
49
50 let latest_ver = match latest.parse::<Version>() {
52 Ok(v) => v,
53 Err(_) => return requirement == latest,
54 };
55
56 if let Ok(req) = requirement.parse::<VersionReq>() {
58 return req.matches(&latest_ver);
59 }
60
61 if let Ok(req) = format!("^{}", requirement).parse::<VersionReq>() {
64 return req.matches(&latest_ver);
65 }
66
67 requirement == latest
69 }
70}
71
72#[derive(Debug, Clone, Copy)]
81pub struct Pep440Matcher;
82
83impl VersionRequirementMatcher for Pep440Matcher {
84 fn is_latest_satisfying(
85 &self,
86 requirement: &ConcreteVersion,
87 latest: &ConcreteVersion,
88 ) -> bool {
89 let requirement = requirement.as_str();
90 let latest = latest.as_str();
91
92 let latest_ver = match normalize_and_parse_version(latest) {
94 Some(v) => v,
95 None => return requirement == latest,
96 };
97
98 let min_version = extract_pypi_min_version(requirement);
101
102 let min_ver = match min_version.and_then(|v| normalize_and_parse_version(&v)) {
103 Some(v) => v,
104 None => return requirement == latest,
105 };
106
107 if min_ver.major == 0 {
109 min_ver.major == latest_ver.major && min_ver.minor == latest_ver.minor
111 } else {
112 min_ver.major == latest_ver.major
114 }
115 }
116}
117
118pub fn normalize_and_parse_version(version: &str) -> Option<Version> {
131 if let Ok(v) = version.parse::<Version>() {
133 return Some(v);
134 }
135
136 let dot_count = version.chars().filter(|&c| c == '.').count();
138
139 let normalized = match dot_count {
140 0 => format!("{}.0.0", version), 1 => format!("{}.0", version), _ => version.to_string(),
143 };
144
145 normalized.parse::<Version>().ok()
146}
147
148pub fn extract_pypi_min_version(version_req: &str) -> Option<String> {
160 for part in version_req.split(',') {
162 let trimmed = part.trim();
163
164 if let Some(ver) = trimmed.strip_prefix(">=") {
166 return Some(ver.trim().to_string());
167 }
168 if let Some(ver) = trimmed.strip_prefix("~=") {
169 return Some(ver.trim().to_string());
170 }
171 if let Some(ver) = trimmed.strip_prefix("==") {
172 return Some(ver.trim().to_string());
173 }
174 if let Some(ver) = trimmed.strip_prefix('>') {
175 return Some(ver.trim().to_string());
177 }
178 }
179
180 let stripped = version_req.trim_start_matches('^').trim_start_matches('~');
183 if stripped.chars().next().is_some_and(|c| c.is_ascii_digit()) {
184 return Some(stripped.to_string());
185 }
186
187 None
188}
189
190pub fn normalize_operator_spacing(requirement: &str) -> Cow<'_, str> {
211 if !has_spaced_operator(requirement) {
212 return Cow::Borrowed(requirement);
213 }
214
215 let mut result = String::with_capacity(requirement.len());
216 let mut chars = requirement.chars().peekable();
217 while let Some(c) = chars.next() {
218 result.push(c);
219 if c == '>' || c == '<' {
220 if chars.peek() == Some(&'=') {
221 result.push('=');
222 chars.next();
223 }
224 while chars.peek().is_some_and(|ws| ws.is_whitespace()) {
225 chars.next();
226 }
227 }
228 }
229 Cow::Owned(result)
230}
231
232fn has_spaced_operator(requirement: &str) -> bool {
236 let mut chars = requirement.chars().peekable();
237 while let Some(c) = chars.next() {
238 if c == '>' || c == '<' {
239 if chars.peek() == Some(&'=') {
240 chars.next();
241 }
242 if chars.peek().is_some_and(|ws| ws.is_whitespace()) {
243 return true;
244 }
245 }
246 }
247 false
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 use std::assert_matches;
255
256 #[test]
257 fn test_semver_matcher_exact_match() {
258 let matcher = SemverMatcher;
259 assert!(matcher.is_latest_satisfying(
260 &ConcreteVersion::new("1.0.0"),
261 &ConcreteVersion::new("1.0.0")
262 ));
263 assert!(matcher.is_latest_satisfying(
264 &ConcreteVersion::new("^1.0.0"),
265 &ConcreteVersion::new("1.0.0")
266 ));
267 assert!(matcher.is_latest_satisfying(
268 &ConcreteVersion::new("~1.0.0"),
269 &ConcreteVersion::new("1.0.0")
270 ));
271 assert!(matcher.is_latest_satisfying(
272 &ConcreteVersion::new("=1.0.0"),
273 &ConcreteVersion::new("1.0.0")
274 ));
275 }
276
277 #[test]
278 fn test_semver_matcher_compatible_versions() {
279 let matcher = SemverMatcher;
280 assert!(matcher.is_latest_satisfying(
282 &ConcreteVersion::new("1.0.0"),
283 &ConcreteVersion::new("1.0.5")
284 )); assert!(matcher.is_latest_satisfying(
286 &ConcreteVersion::new("^1.0.0"),
287 &ConcreteVersion::new("1.5.0")
288 )); assert!(matcher.is_latest_satisfying(
290 &ConcreteVersion::new("0.1"),
291 &ConcreteVersion::new("0.1.83")
292 )); assert!(
294 matcher
295 .is_latest_satisfying(&ConcreteVersion::new("1"), &ConcreteVersion::new("1.5.0"))
296 ); }
298
299 #[test]
300 fn test_semver_matcher_incompatible_versions() {
301 let matcher = SemverMatcher;
302 assert!(!matcher.is_latest_satisfying(
304 &ConcreteVersion::new("1.0.0"),
305 &ConcreteVersion::new("2.0.0")
306 )); assert!(
308 !matcher
309 .is_latest_satisfying(&ConcreteVersion::new("0.1"), &ConcreteVersion::new("0.2.0"))
310 ); assert!(!matcher.is_latest_satisfying(
312 &ConcreteVersion::new("~1.0.0"),
313 &ConcreteVersion::new("1.1.0")
314 )); }
316
317 #[test]
318 fn test_pep440_matcher_same_major() {
319 let matcher = Pep440Matcher;
320 assert!(matcher.is_latest_satisfying(
322 &ConcreteVersion::new(">=8.0"),
323 &ConcreteVersion::new("8.3.5")
324 )); assert!(matcher.is_latest_satisfying(
326 &ConcreteVersion::new(">=1.0"),
327 &ConcreteVersion::new("1.5.0")
328 )); assert!(matcher.is_latest_satisfying(
330 &ConcreteVersion::new(">=1.0,<2.0"),
331 &ConcreteVersion::new("1.9.0")
332 )); }
334
335 #[test]
336 fn test_pep440_matcher_new_major() {
337 let matcher = Pep440Matcher;
338 assert!(!matcher.is_latest_satisfying(
340 &ConcreteVersion::new(">=8.0"),
341 &ConcreteVersion::new("9.0.2")
342 )); assert!(!matcher.is_latest_satisfying(
344 &ConcreteVersion::new(">=1.0"),
345 &ConcreteVersion::new("2.0.0")
346 )); assert!(!matcher.is_latest_satisfying(
348 &ConcreteVersion::new(">=4.0,<8.0"),
349 &ConcreteVersion::new("8.0.0")
350 )); }
352
353 #[test]
354 fn test_pep440_matcher_zero_version() {
355 let matcher = Pep440Matcher;
356 assert!(matcher.is_latest_satisfying(
358 &ConcreteVersion::new(">=0.8"),
359 &ConcreteVersion::new("0.8.5")
360 )); assert!(!matcher.is_latest_satisfying(
362 &ConcreteVersion::new(">=0.8"),
363 &ConcreteVersion::new("0.9.0")
364 )); }
366
367 #[test]
368 fn test_extract_pypi_min_version() {
369 assert_eq!(extract_pypi_min_version(">=8.0"), Some("8.0".to_string()));
370 assert_eq!(
371 extract_pypi_min_version(">=1.0,<2.0"),
372 Some("1.0".to_string())
373 );
374 assert_eq!(
375 extract_pypi_min_version("~=1.4.2"),
376 Some("1.4.2".to_string())
377 );
378 assert_eq!(
379 extract_pypi_min_version("==2.0.0"),
380 Some("2.0.0".to_string())
381 );
382 assert_eq!(extract_pypi_min_version("^1.0"), Some("1.0".to_string())); assert_eq!(extract_pypi_min_version(">1.0"), Some("1.0".to_string()));
384 }
385
386 #[test]
387 fn test_normalize_operator_spacing_collapses_spaced_operators() {
388 assert_eq!(normalize_operator_spacing(">= 1.0 < 2.0"), ">=1.0 <2.0");
389 assert_eq!(normalize_operator_spacing("> 1.0"), ">1.0");
390 assert_eq!(normalize_operator_spacing("<= 1.0"), "<=1.0");
391 }
392
393 #[test]
394 fn test_normalize_operator_spacing_borrows_when_no_spaced_operator() {
395 let input = ">=1.0 <2.0";
396 assert_matches!(
397 normalize_operator_spacing(input),
398 Cow::Borrowed(s) if s == input
399 );
400 }
401
402 #[test]
403 fn test_normalize_and_parse_version() {
404 assert_eq!(
405 normalize_and_parse_version("1.0.0").unwrap().to_string(),
406 "1.0.0"
407 );
408 assert_eq!(
409 normalize_and_parse_version("1.0").unwrap().to_string(),
410 "1.0.0"
411 );
412 assert_eq!(
413 normalize_and_parse_version("8").unwrap().to_string(),
414 "8.0.0"
415 );
416 assert!(normalize_and_parse_version("invalid").is_none());
417 }
418}