app_lib/
kvr.rs

1//! KVR Audio scraper and plugin version checker.
2//!
3//! Queries [KVR Audio](https://www.kvraudio.com) for plugin product pages,
4//! extracts version numbers and download URLs. Falls back to DuckDuckGo
5//! site-restricted search when direct URL construction fails. Rate-limited
6//! to avoid overloading KVR's servers.
7
8use regex::Regex;
9use reqwest::Client;
10use serde::{Deserialize, Serialize};
11use std::sync::LazyLock;
12
13// Pre-compiled regexes for hot paths
14static 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    // Prefer platform-specific link
120    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    // Check for platform text near download links
128    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    // Any download link
148    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    // Try direct URL first
192    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    // Fallback: search KVR
203    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    // Last resort
262    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    // Try KVR search
284    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        // Also try /plugins/ style links
301        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    // Fallback: DuckDuckGo site-restricted search
343    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        // "v2" does not parse as i32 — first component becomes 0
401        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        // On macOS, should prefer the mac link; on other platforms, the respective one
532        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        // parse_version("1.02.3") -> [1, 2, 3] since i32 parse drops leading zeros
577        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    /// `compare_versions` uses numeric dot segments only; prerelease text does not implement semver ordering.
713    #[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    /// `parse_version` splits on `.` only — leading spaces make the first segment non-numeric → 0.
738    #[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}