1use regex::Regex;
9use reqwest::Client;
10use serde::{Deserialize, Serialize};
11use std::sync::LazyLock;
12
13static DOWNLOAD_LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
15 Regex::new(r#"href="(https?://[^"]*(?:download|get|buy|release)[^"]*)""#).unwrap()
16});
17static PRODUCT_LINK_RE: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r#"href="(/product/[^"]+)""#).unwrap());
19static PLUGINS_LINK_RE: LazyLock<Regex> =
20 LazyLock::new(|| Regex::new(r#"href="(/plugins/[^"]+)""#).unwrap());
21static KVR_DDG_LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
22 Regex::new(r#"href="[^"]*?(https?://(?:www\.)?kvraudio\.com/product/[^"&]+)"#).unwrap()
23});
24static HTML_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
25static DATE_FILTER_RE: LazyLock<Regex> =
26 LazyLock::new(|| Regex::new(r"^20[0-2]\d\.|^\d{4}\.").unwrap());
27static VERSION_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
28 [
29 r"(?i)Version\s*[:]\s*(\d+\.\d+(?:\.\d+)?(?:\.\d+)?)",
30 r"(?i)(?:Latest\s+)?Version</(?:dt|th|span|div|label)>\s*<(?:dd|td|span|div)[^>]*>\s*(\d+\.\d+(?:\.\d+)?(?:\.\d+)?)",
31 r#"(?i)softwareVersion["\s:>]+(\d+\.\d+(?:\.\d+)?(?:\.\d+)?)"#,
32 r"(?i)(?:current|latest|release|version)[^<]{0,40}?v?(\d+\.\d+(?:\.\d+)?(?:\.\d+)?)",
33 r"(?i)Version\s*(?:<[^>]*>\s*)*(\d+\.\d+(?:\.\d+)?(?:\.\d+)?)",
34 ]
35 .iter()
36 .map(|p| Regex::new(p).unwrap())
37 .collect()
38});
39pub static URL_RE: LazyLock<Regex> =
40 LazyLock::new(|| Regex::new(r#"https?://[^\s)"',]+"#).unwrap());
41
42const KVR_INVALID_PAGES: &[&str] = &["/plugins/the-newest-plugins", "/plugins/newest", "/plugins"];
43
44const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct KvrResult {
48 #[serde(rename = "productUrl")]
49 pub product_url: String,
50 #[serde(rename = "downloadUrl")]
51 pub download_url: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct UpdateResult {
56 #[serde(rename = "latestVersion")]
57 pub latest_version: String,
58 #[serde(rename = "hasUpdate")]
59 pub has_update: bool,
60 pub source: String,
61 #[serde(rename = "updateUrl")]
62 pub update_url: Option<String>,
63 #[serde(rename = "kvrUrl")]
64 pub kvr_url: Option<String>,
65 #[serde(rename = "hasPlatformDownload")]
66 pub has_platform_download: bool,
67}
68
69fn platform_keywords() -> Vec<&'static str> {
70 if cfg!(target_os = "macos") {
71 vec!["mac", "macos", "osx", "os x", "apple"]
72 } else if cfg!(target_os = "windows") {
73 vec!["win", "windows", "pc"]
74 } else {
75 vec!["linux", "ubuntu", "debian"]
76 }
77}
78
79fn build_client() -> Client {
80 Client::builder()
81 .user_agent(USER_AGENT)
82 .timeout(std::time::Duration::from_secs(15))
83 .redirect(reqwest::redirect::Policy::limited(5))
84 .build()
85 .unwrap_or_default()
86}
87
88async fn fetch_with_validation(client: &Client, url: &str) -> Option<(String, String, bool)> {
89 let resp = client.get(url).send().await.ok()?;
90 let final_url = resp.url().to_string();
91 let final_path = resp
92 .url()
93 .path()
94 .split('?')
95 .next()
96 .unwrap_or("")
97 .split('#')
98 .next()
99 .unwrap_or("");
100 let is_invalid = KVR_INVALID_PAGES.iter().any(|p| final_path.starts_with(p));
101 let status = resp.status();
102 let html = resp.text().await.ok()?;
103 Some((html, final_url, !is_invalid && status.is_success()))
104}
105
106async fn fetch_html(client: &Client, url: &str) -> Option<String> {
107 let resp = client.get(url).send().await.ok()?;
108 resp.text().await.ok()
109}
110
111pub fn extract_download_url(html: &str) -> Option<(String, bool)> {
112 let all_links: Vec<String> = DOWNLOAD_LINK_RE
113 .captures_iter(html)
114 .map(|c| c[1].to_string())
115 .collect();
116
117 let keywords = platform_keywords();
118
119 for link in &all_links {
121 let lower = link.to_lowercase();
122 if keywords.iter().any(|kw| lower.contains(kw)) {
123 return Some((link.clone(), true));
124 }
125 }
126
127 for kw in &keywords {
129 let pattern = format!(
130 r#"(?i)(?:{})[^<]{{0,80}}?href="(https?://[^"]*(?:download|get)[^"]*)"|href="(https?://[^"]*(?:download|get)[^"]*)"[^<]{{0,80}}?(?:{})"#,
131 regex::escape(kw),
132 regex::escape(kw)
133 );
134 if let Ok(re) = Regex::new(&pattern) {
135 if let Some(caps) = re.captures(html) {
136 let url = caps
137 .get(1)
138 .or_else(|| caps.get(2))
139 .map(|m| m.as_str().to_string());
140 if let Some(u) = url {
141 return Some((u, true));
142 }
143 }
144 }
145 }
146
147 all_links.first().map(|l| (l.clone(), false))
149}
150
151pub fn extract_version(html: &str) -> Option<String> {
152 for re in VERSION_PATTERNS.iter() {
153 if let Some(caps) = re.captures(html) {
154 let ver = caps[1].to_string();
155 if !DATE_FILTER_RE.is_match(&ver) {
156 return Some(ver);
157 }
158 }
159 }
160
161 None
162}
163
164pub fn parse_version(ver: &str) -> Vec<i32> {
165 if ver.is_empty() || ver == "Unknown" {
166 return vec![0, 0, 0];
167 }
168 ver.split('.')
169 .map(|n| n.parse::<i32>().unwrap_or(0))
170 .collect()
171}
172
173pub fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
174 let pa = parse_version(a);
175 let pb = parse_version(b);
176 let len = pa.len().max(pb.len());
177 for i in 0..len {
178 let va = pa.get(i).copied().unwrap_or(0);
179 let vb = pb.get(i).copied().unwrap_or(0);
180 match va.cmp(&vb) {
181 std::cmp::Ordering::Equal => continue,
182 other => return other,
183 }
184 }
185 std::cmp::Ordering::Equal
186}
187
188pub async fn resolve_kvr(direct_url: &str, plugin_name: &str) -> KvrResult {
189 let client = build_client();
190
191 if let Some((html, final_url, valid)) = fetch_with_validation(&client, direct_url).await {
193 if valid {
194 let download_url = extract_download_url(&html).map(|(u, _)| u);
195 return KvrResult {
196 product_url: final_url,
197 download_url,
198 };
199 }
200 }
201
202 let search_url = format!(
204 "https://www.kvraudio.com/plugins/search?q={}",
205 urlencoding::encode(plugin_name)
206 );
207 if let Some(html) = fetch_html(&client, &search_url).await {
208 let mut seen = std::collections::HashSet::new();
209 let mut product_links = Vec::new();
210 for caps in PRODUCT_LINK_RE.captures_iter(&html) {
211 let href = caps[1].to_string();
212 if seen.insert(href.clone()) {
213 product_links.push(format!("https://www.kvraudio.com{}", href));
214 }
215 }
216
217 let name_lower = plugin_name.to_lowercase();
218 let name_slug = name_lower
219 .replace(|c: char| !c.is_alphanumeric(), "-")
220 .trim_matches('-')
221 .to_string();
222 let name_words: Vec<&str> = name_lower
223 .split(|c: char| !c.is_alphanumeric())
224 .filter(|s| !s.is_empty())
225 .collect();
226
227 for found_url in product_links.iter().take(5) {
228 let url_slug = found_url
229 .split("/product/")
230 .nth(1)
231 .unwrap_or("")
232 .to_string();
233 let matching_words = name_words
234 .iter()
235 .filter(|w| w.len() > 1 && url_slug.contains(*w))
236 .count();
237 let threshold = (name_words.len() as f64 * 0.5).ceil() as usize;
238
239 if url_slug.contains(&name_slug) || matching_words >= threshold {
240 if let Some(page_html) = fetch_html(&client, found_url).await {
241 let download_url = extract_download_url(&page_html).map(|(u, _)| u);
242 return KvrResult {
243 product_url: found_url.clone(),
244 download_url,
245 };
246 }
247 }
248 }
249
250 if let Some(first_url) = product_links.first() {
251 if let Some(page_html) = fetch_html(&client, first_url).await {
252 let download_url = extract_download_url(&page_html).map(|(u, _)| u);
253 return KvrResult {
254 product_url: first_url.clone(),
255 download_url,
256 };
257 }
258 }
259 }
260
261 KvrResult {
263 product_url: format!(
264 "https://www.kvraudio.com/plugins/search?q={}",
265 urlencoding::encode(plugin_name)
266 ),
267 download_url: None,
268 }
269}
270
271pub async fn find_latest_version(
272 name: &str,
273 manufacturer: &str,
274 current_version: &str,
275) -> Option<UpdateResult> {
276 let client = build_client();
277 let mfg = if manufacturer != "Unknown" {
278 manufacturer
279 } else {
280 ""
281 };
282
283 let query = format!("{} {}", mfg, name).trim().to_string();
285 let search_url = format!(
286 "https://www.kvraudio.com/plugins/search?q={}",
287 urlencoding::encode(&query)
288 );
289
290 if let Some(html) = fetch_html(&client, &search_url).await {
291 let mut product_links: Vec<String> = Vec::new();
292 let mut seen = std::collections::HashSet::new();
293 for caps in PRODUCT_LINK_RE.captures_iter(&html) {
294 let href = caps[1].to_string();
295 if seen.insert(href.clone()) {
296 product_links.push(format!("https://www.kvraudio.com{}", href));
297 }
298 }
299
300 for caps in PLUGINS_LINK_RE.captures_iter(&html) {
302 let href = caps[1].to_string();
303 if !href.contains("/search") && !href.contains("/category") && seen.insert(href.clone())
304 {
305 product_links.push(format!("https://www.kvraudio.com{}", href));
306 }
307 }
308
309 for product_url in product_links.iter().take(2) {
310 tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
311
312 if let Some(page_html) = fetch_html(&client, product_url).await {
313 let clean_name = name
314 .chars()
315 .filter(|c| c.is_alphanumeric())
316 .collect::<String>()
317 .to_lowercase();
318 let page_text = HTML_TAG_RE.replace_all(&page_html, "").to_lowercase();
319
320 if !page_text.contains(&clean_name) && !page_text.contains(&name.to_lowercase()) {
321 continue;
322 }
323
324 if let Some(version) = extract_version(&page_html) {
325 let (download_url, has_platform) =
326 extract_download_url(&page_html).unwrap_or((product_url.clone(), false));
327 let has_update =
328 compare_versions(&version, current_version) == std::cmp::Ordering::Greater;
329 return Some(UpdateResult {
330 latest_version: version,
331 has_update,
332 source: "kvr".into(),
333 update_url: Some(download_url),
334 kvr_url: Some(product_url.clone()),
335 has_platform_download: has_platform,
336 });
337 }
338 }
339 }
340 }
341
342 tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
344 let ddg_query = format!("site:kvraudio.com {} {} VST version", mfg, name)
345 .trim()
346 .to_string();
347 let ddg_url = format!(
348 "https://html.duckduckgo.com/html/?q={}",
349 urlencoding::encode(&ddg_query)
350 );
351
352 if let Some(ddg_html) = fetch_html(&client, &ddg_url).await {
353 let mut kvr_links: Vec<String> = Vec::new();
354 let mut seen = std::collections::HashSet::new();
355 for caps in KVR_DDG_LINK_RE.captures_iter(&ddg_html) {
356 let url = caps[1].to_string();
357 if seen.insert(url.clone()) {
358 kvr_links.push(url);
359 }
360 }
361
362 for kvr_url in kvr_links.iter().take(2) {
363 tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
364
365 if let Some(page_html) = fetch_html(&client, kvr_url).await {
366 if let Some(version) = extract_version(&page_html) {
367 let (download_url, has_platform) =
368 extract_download_url(&page_html).unwrap_or((kvr_url.clone(), false));
369 let has_update =
370 compare_versions(&version, current_version) == std::cmp::Ordering::Greater;
371 return Some(UpdateResult {
372 latest_version: version,
373 has_update,
374 source: "kvr-ddg".into(),
375 update_url: Some(download_url),
376 kvr_url: Some(kvr_url.clone()),
377 has_platform_download: has_platform,
378 });
379 }
380 }
381 }
382 }
383
384 None
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn test_parse_version_basic() {
393 assert_eq!(parse_version("1.2.3"), vec![1, 2, 3]);
394 assert_eq!(parse_version("10.0"), vec![10, 0]);
395 assert_eq!(parse_version("1.0.0.1"), vec![1, 0, 0, 1]);
396 }
397
398 #[test]
399 fn test_parse_version_non_numeric_segment_becomes_zero() {
400 assert_eq!(parse_version("v2.3.4"), vec![0, 3, 4]);
402 }
403
404 #[test]
405 fn test_parse_version_unknown() {
406 assert_eq!(parse_version("Unknown"), vec![0, 0, 0]);
407 assert_eq!(parse_version(""), vec![0, 0, 0]);
408 }
409
410 #[test]
411 fn test_compare_versions_equal() {
412 assert_eq!(
413 compare_versions("1.0.0", "1.0.0"),
414 std::cmp::Ordering::Equal
415 );
416 assert_eq!(compare_versions("2.1", "2.1.0"), std::cmp::Ordering::Equal);
417 }
418
419 #[test]
420 fn test_compare_versions_greater() {
421 assert_eq!(
422 compare_versions("2.0.0", "1.9.9"),
423 std::cmp::Ordering::Greater
424 );
425 assert_eq!(
426 compare_versions("1.1", "1.0.9"),
427 std::cmp::Ordering::Greater
428 );
429 assert_eq!(
430 compare_versions("1.0.1", "1.0.0"),
431 std::cmp::Ordering::Greater
432 );
433 }
434
435 #[test]
436 fn test_compare_versions_less() {
437 assert_eq!(compare_versions("1.0.0", "1.0.1"), std::cmp::Ordering::Less);
438 assert_eq!(compare_versions("0.9", "1.0"), std::cmp::Ordering::Less);
439 }
440
441 #[test]
442 fn test_compare_versions_different_lengths() {
443 assert_eq!(
444 compare_versions("1.0", "1.0.0.0"),
445 std::cmp::Ordering::Equal
446 );
447 assert_eq!(
448 compare_versions("1.0.0.1", "1.0"),
449 std::cmp::Ordering::Greater
450 );
451 }
452
453 #[test]
454 fn test_extract_version_basic() {
455 let html = r#"<div>Version: 3.5.2</div>"#;
456 assert_eq!(extract_version(html), Some("3.5.2".into()));
457 }
458
459 #[test]
460 fn test_extract_version_latest() {
461 let html = r#"<dt>Latest Version</dt><dd>2.1.0</dd>"#;
462 assert_eq!(extract_version(html), Some("2.1.0".into()));
463 }
464
465 #[test]
466 fn test_extract_version_software_version() {
467 let html = r#"{"softwareVersion": "1.4.7"}"#;
468 assert_eq!(extract_version(html), Some("1.4.7".into()));
469 }
470
471 #[test]
472 fn test_extract_version_filters_dates() {
473 let html = r#"<div>Version: 2024.01.15</div>"#;
474 assert_eq!(extract_version(html), None);
475 }
476
477 #[test]
478 fn test_extract_version_none() {
479 let html = r#"<div>No version info here</div>"#;
480 assert_eq!(extract_version(html), None);
481 }
482
483 #[test]
484 fn test_extract_version_four_part() {
485 let html = r#"<span>Version: 1.2.3.4</span>"#;
486 assert_eq!(extract_version(html), Some("1.2.3.4".into()));
487 }
488
489 #[test]
490 fn test_extract_download_url_basic() {
491 let html = r#"<a href="https://example.com/download/plugin-v1.zip">Download</a>"#;
492 let result = extract_download_url(html);
493 assert!(result.is_some());
494 let (url, _) = result.unwrap();
495 assert_eq!(url, "https://example.com/download/plugin-v1.zip");
496 }
497
498 #[test]
499 fn test_extract_download_url_none() {
500 let html = r#"<a href="https://example.com/about">About</a>"#;
501 assert!(extract_download_url(html).is_none());
502 }
503
504 #[test]
505 fn test_platform_keywords_not_empty() {
506 assert!(!platform_keywords().is_empty());
507 }
508
509 #[test]
510 fn test_extract_version_with_v_prefix() {
511 let html = "current version v2.3.1";
512 assert_eq!(extract_version(html), Some("2.3.1".into()));
513 }
514
515 #[test]
516 fn test_extract_version_release_context() {
517 let html = "latest release 4.0.2 available";
518 assert_eq!(extract_version(html), Some("4.0.2".into()));
519 }
520
521 #[test]
522 fn test_extract_download_url_platform_specific() {
523 let html = r#"
524 <a href="https://example.com/download/plugin-win.zip">Windows</a>
525 <a href="https://example.com/download/plugin-mac.dmg">Mac</a>
526 <a href="https://example.com/download/plugin-linux.tar.gz">Linux</a>
527 "#;
528 let result = extract_download_url(html);
529 assert!(result.is_some());
530 let (url, is_platform) = result.unwrap();
531 if cfg!(target_os = "macos") {
533 assert!(url.contains("mac"), "Expected mac URL, got: {}", url);
534 assert!(is_platform);
535 } else if cfg!(target_os = "windows") {
536 assert!(url.contains("win"), "Expected windows URL, got: {}", url);
537 assert!(is_platform);
538 } else {
539 assert!(url.contains("linux"), "Expected linux URL, got: {}", url);
540 assert!(is_platform);
541 }
542 }
543
544 #[test]
545 fn test_compare_versions_single_component() {
546 assert_eq!(compare_versions("3", "2"), std::cmp::Ordering::Greater);
547 }
548
549 #[test]
550 fn test_parse_version_non_numeric() {
551 assert_eq!(parse_version("abc"), vec![0]);
552 }
553
554 #[test]
555 fn test_extract_version_multiple_versions_picks_first() {
556 let html = r#"<div>Version: 1.0</div><div>Version: 2.0</div>"#;
557 assert_eq!(extract_version(html), Some("1.0".into()));
558 }
559
560 #[test]
561 fn test_extract_version_html_tags_between() {
562 let html = r#"<dt>Version</dt><dd>3.2.1</dd>"#;
563 assert_eq!(extract_version(html), Some("3.2.1".into()));
564 }
565
566 #[test]
567 fn test_compare_versions_zero_vs_zero() {
568 assert_eq!(
569 compare_versions("0.0.0", "0.0.0"),
570 std::cmp::Ordering::Equal
571 );
572 }
573
574 #[test]
575 fn test_compare_versions_leading_zeros() {
576 let a = parse_version("1.02.3");
578 let b = parse_version("1.2.3");
579 assert_eq!(a, b);
580 assert_eq!(
581 compare_versions("1.02.3", "1.2.3"),
582 std::cmp::Ordering::Equal
583 );
584 }
585
586 #[test]
587 fn test_extract_download_url_multiple_links() {
588 let html = r#"
589 <a href="https://example.com/download/a.zip">A</a>
590 <a href="https://example.com/download/b.zip">B</a>
591 <a href="https://example.com/download/c.zip">C</a>
592 "#;
593 let result = extract_download_url(html);
594 assert!(result.is_some(), "Should find at least one download link");
595 }
596
597 #[test]
598 fn test_extract_download_url_get_link() {
599 let html = r#"<a href="https://example.com/get/plugin">Get Plugin</a>"#;
600 let result = extract_download_url(html);
601 assert!(result.is_some(), "Should find 'get' link");
602 let (url, _) = result.unwrap();
603 assert_eq!(url, "https://example.com/get/plugin");
604 }
605
606 #[test]
607 fn test_extract_download_url_buy_link() {
608 let html = r#"<a href="https://example.com/buy/plugin">Buy Plugin</a>"#;
609 let result = extract_download_url(html);
610 assert!(result.is_some(), "Should find 'buy' link");
611 let (url, _) = result.unwrap();
612 assert_eq!(url, "https://example.com/buy/plugin");
613 }
614
615 #[test]
616 fn test_parse_version_non_numeric_segment_zero() {
617 assert_eq!(parse_version("1.x.3"), vec![1, 0, 3]);
618 }
619
620 #[test]
621 fn test_compare_versions_patch_vs_shorter() {
622 assert_eq!(
623 compare_versions("1.0.0.1", "1.0.0"),
624 std::cmp::Ordering::Greater
625 );
626 }
627
628 #[test]
629 fn test_parse_version_double_dot_empty_segment() {
630 assert_eq!(parse_version("1..2"), vec![1, 0, 2]);
631 }
632
633 #[test]
634 fn test_parse_version_trailing_dot_empty_segment() {
635 assert_eq!(parse_version("1."), vec![1, 0]);
636 }
637
638 #[test]
639 fn test_parse_version_whitespace_suffix_on_segment_becomes_zero() {
640 assert_eq!(parse_version("2.0 "), vec![2, 0]);
641 assert_eq!(parse_version("1.0.0\n"), vec![1, 0, 0]);
642 }
643
644 #[test]
645 fn test_compare_versions_unknown_vs_numeric() {
646 assert_eq!(compare_versions("Unknown", "2.0"), std::cmp::Ordering::Less);
647 assert_eq!(
648 compare_versions("2.0", "Unknown"),
649 std::cmp::Ordering::Greater
650 );
651 }
652
653 #[test]
654 fn test_compare_versions_transitive_major_minor() {
655 assert_eq!(compare_versions("3.0", "1.0"), std::cmp::Ordering::Greater);
656 assert_eq!(compare_versions("1.0", "0.5"), std::cmp::Ordering::Greater);
657 assert_eq!(compare_versions("3.0", "0.5"), std::cmp::Ordering::Greater);
658 }
659
660 #[test]
661 fn test_extract_version_rejects_year_like_semver() {
662 let html = r#"<div>Version: 2024.12.1</div>"#;
663 assert_eq!(extract_version(html), None);
664 }
665
666 #[test]
667 fn test_compare_versions_reflexive_handpicked() {
668 for s in ["1.0.0", "1.2.3.4", "0.0.1", "10", "0.0.0", "2.1.0-rc"] {
669 assert_eq!(
670 compare_versions(s, s),
671 std::cmp::Ordering::Equal,
672 "reflexive failed for {s}"
673 );
674 }
675 }
676
677 #[test]
678 fn test_compare_versions_antisymmetric() {
679 assert_eq!(
680 compare_versions("2.0", "1.0"),
681 compare_versions("1.0", "2.0").reverse()
682 );
683 assert_eq!(
684 compare_versions("1.0.1", "1.0.0"),
685 compare_versions("1.0.0", "1.0.1").reverse()
686 );
687 }
688
689 #[test]
690 fn test_compare_versions_transitive_three_way() {
691 use std::cmp::Ordering;
692 assert_eq!(compare_versions("1.0", "1.1"), Ordering::Less);
693 assert_eq!(compare_versions("1.1", "2.0"), Ordering::Less);
694 assert_eq!(compare_versions("1.0", "2.0"), Ordering::Less);
695 }
696
697 #[test]
698 fn test_parse_version_positive_sign_first_segment() {
699 assert_eq!(parse_version("+1.2.3"), vec![1, 2, 3]);
700 }
701
702 #[test]
703 fn test_parse_version_prerelease_suffix_yields_zero_segment() {
704 assert_eq!(parse_version("1.0.0-rc1"), vec![1, 0, 0]);
705 assert_eq!(
706 parse_version("2.1.0"),
707 parse_version("2.1.0-beta"),
708 "non-numeric tail segment parses as 0 — semver text not preserved"
709 );
710 }
711
712 #[test]
714 fn test_compare_versions_equal_when_prerelease_differs_only_in_suffix() {
715 assert_eq!(
716 compare_versions("1.0.0-rc1", "1.0.0"),
717 std::cmp::Ordering::Equal
718 );
719 assert_eq!(
720 compare_versions("2.1.0-beta", "2.1.0"),
721 std::cmp::Ordering::Equal
722 );
723 }
724
725 #[test]
726 fn test_compare_versions_multi_segment_numeric_not_string_order() {
727 assert_eq!(
728 compare_versions("10.0.0", "2.0.0"),
729 std::cmp::Ordering::Greater
730 );
731 assert_eq!(
732 compare_versions("2.0.0", "10.0.0"),
733 std::cmp::Ordering::Less
734 );
735 }
736
737 #[test]
739 fn test_parse_version_leading_space_first_segment_zero() {
740 assert_eq!(parse_version(" 1.2.3"), vec![0, 2, 3]);
741 }
742}