Skip to main content

deps_nuget/
version.rs

1//! NuGet version comparison, prerelease detection, and range/floating syntax.
2//!
3//! # Why this is hand-rolled
4//!
5//! No maintained Rust crate implements NuGet's versioning scheme. NuGet versions are
6//! `Major.Minor.Patch[.Revision]` (1–4 numeric components, `Major` required) with SemVer2
7//! prerelease precedence and case-insensitive prerelease label comparison; the workspace's
8//! `semver` crate rejects both `1.2.3.4` (too many components) and `1.2` (too few) outright, so
9//! it cannot parse the common NuGet forms. `deps-maven` set the precedent for a crate-local
10//! hand-rolled comparator under the same constraint (no maintained crate for Maven's scheme).
11
12use std::cmp::Ordering;
13
14/// A single dot-separated prerelease identifier, per SemVer2 precedence rules:
15/// numeric identifiers compare numerically and always sort below alphanumeric ones;
16/// alphanumeric identifiers compare lexically. NuGet's twist: alphanumeric identifiers
17/// compare case-insensitively (`1.0.0-Alpha == 1.0.0-alpha`), so they are lowercased at parse
18/// time.
19#[derive(Debug, Clone, PartialEq, Eq)]
20enum PrereleaseSegment {
21    Numeric(u64),
22    Alphanumeric(String),
23}
24
25impl PrereleaseSegment {
26    fn parse(s: &str) -> Self {
27        if !s.is_empty()
28            && s.bytes().all(|b| b.is_ascii_digit())
29            && let Ok(n) = s.parse::<u64>()
30        {
31            return Self::Numeric(n);
32        }
33        Self::Alphanumeric(s.to_lowercase())
34    }
35}
36
37impl Ord for PrereleaseSegment {
38    fn cmp(&self, other: &Self) -> Ordering {
39        match (self, other) {
40            (Self::Numeric(a), Self::Numeric(b)) => a.cmp(b),
41            (Self::Alphanumeric(a), Self::Alphanumeric(b)) => a.cmp(b),
42            (Self::Numeric(_), Self::Alphanumeric(_)) => Ordering::Less,
43            (Self::Alphanumeric(_), Self::Numeric(_)) => Ordering::Greater,
44        }
45    }
46}
47
48impl PartialOrd for PrereleaseSegment {
49    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
50        Some(self.cmp(other))
51    }
52}
53
54/// A parsed NuGet version: 4 numeric components (missing = 0) plus SemVer2 prerelease
55/// segments. Build metadata is stripped during parsing and never retained — it is never
56/// compared, and nothing in this crate round-trips a version string back from its parsed
57/// form.
58///
59/// Kept crate-private so it cannot collide with the registry-facing `NuGetVersion` in
60/// `types.rs` — this mirrors the `deps-maven` module split.
61#[derive(Debug, Clone)]
62pub(crate) struct ParsedVersion {
63    major: u64,
64    minor: u64,
65    patch: u64,
66    revision: u64,
67    pre: Vec<PrereleaseSegment>,
68}
69
70impl ParsedVersion {
71    pub(crate) fn parse(version: &str) -> Self {
72        let version = version.trim();
73        // Build metadata (after '+') is discarded — never compared, never retained.
74        let core_and_pre = version.split('+').next().unwrap_or(version);
75        let (core, pre) = match core_and_pre.split_once('-') {
76            Some((a, b)) => (a, Some(b)),
77            None => (core_and_pre, None),
78        };
79
80        let mut parts = core.split('.').map(|s| s.parse::<u64>().unwrap_or(0));
81        let major = parts.next().unwrap_or(0);
82        let minor = parts.next().unwrap_or(0);
83        let patch = parts.next().unwrap_or(0);
84        let revision = parts.next().unwrap_or(0);
85
86        let pre = pre
87            .map(|p| p.split('.').map(PrereleaseSegment::parse).collect())
88            .unwrap_or_default();
89
90        Self {
91            major,
92            minor,
93            patch,
94            revision,
95            pre,
96        }
97    }
98}
99
100fn compare_pre(a: &[PrereleaseSegment], b: &[PrereleaseSegment]) -> Ordering {
101    for (x, y) in a.iter().zip(b.iter()) {
102        let ord = x.cmp(y);
103        if ord != Ordering::Equal {
104            return ord;
105        }
106    }
107    // A larger set of prerelease fields has higher precedence when all preceding
108    // identifiers are equal (SemVer2 rule).
109    a.len().cmp(&b.len())
110}
111
112fn compare_parsed(a: &ParsedVersion, b: &ParsedVersion) -> Ordering {
113    a.major
114        .cmp(&b.major)
115        .then_with(|| a.minor.cmp(&b.minor))
116        .then_with(|| a.patch.cmp(&b.patch))
117        .then_with(|| a.revision.cmp(&b.revision))
118        .then_with(|| match (a.pre.is_empty(), b.pre.is_empty()) {
119            (true, true) => Ordering::Equal,
120            // No prerelease has higher precedence than any prerelease of the same core.
121            (true, false) => Ordering::Greater,
122            (false, true) => Ordering::Less,
123            (false, false) => compare_pre(&a.pre, &b.pre),
124        })
125}
126
127/// Compares two NuGet version strings.
128///
129/// 4 numeric components (missing components treated as 0), then SemVer2 prerelease
130/// precedence with case-insensitive prerelease label comparison. Build metadata is ignored.
131pub fn compare_versions(a: &str, b: &str) -> Ordering {
132    compare_parsed(&ParsedVersion::parse(a), &ParsedVersion::parse(b))
133}
134
135/// Detects whether a NuGet version string is a prerelease version.
136///
137/// NuGet's rule is **structural**, not keyword-based: any version with a `-` suffix after
138/// the numeric core is a prerelease, regardless of the label used. This must not be
139/// confused with `deps_core::registry::Version`'s defaulted keyword-sniff implementation,
140/// which recognizes only `-alpha|-beta|-rc|-dev|-pre|-snapshot|-canary|-nightly` — standard
141/// .NET labels like `-rtm`, `-servicing.23`, `-CI-*`, and `-final` all match none of those
142/// keywords and would be misreported as stable. See the `NuGetVersion` hand-written
143/// `Version` impl in `types.rs`, which delegates here instead of using `impl_version!`.
144pub fn is_prerelease(version: &str) -> bool {
145    let without_build = version.split('+').next().unwrap_or(version);
146    without_build.contains('-')
147}
148
149/// A parsed interval-notation version range (spec §2), produced once per dependency by
150/// [`parse_range`] and reused by [`crate::formatter::NuGetMatcher`] against every candidate
151/// version, instead of being re-parsed per candidate.
152pub(crate) enum VersionRange {
153    Exact(ParsedVersion),
154    Minimum {
155        version: ParsedVersion,
156        inclusive: bool,
157    },
158    Maximum {
159        version: ParsedVersion,
160        inclusive: bool,
161    },
162    Bounded {
163        min: ParsedVersion,
164        min_inclusive: bool,
165        max: ParsedVersion,
166        max_inclusive: bool,
167    },
168}
169
170pub(crate) fn parse_range(range: &str) -> Option<VersionRange> {
171    let range = range.trim();
172    if range.is_empty() {
173        return None;
174    }
175
176    let first = range.chars().next()?;
177    if first != '[' && first != '(' {
178        // Bare version is a floor (minimum, inclusive) under PackageReference.
179        return Some(VersionRange::Minimum {
180            version: ParsedVersion::parse(range),
181            inclusive: true,
182        });
183    }
184
185    let last = range.chars().next_back()?;
186    if last != ']' && last != ')' {
187        return None;
188    }
189
190    let min_inclusive = first == '[';
191    let max_inclusive = last == ']';
192    let inner = &range[first.len_utf8()..range.len() - last.len_utf8()];
193
194    if let Some((lo, hi)) = inner.split_once(',') {
195        let lo = lo.trim();
196        let hi = hi.trim();
197        let min = (!lo.is_empty()).then(|| ParsedVersion::parse(lo));
198        let max = (!hi.is_empty()).then(|| ParsedVersion::parse(hi));
199        match (min, max) {
200            (Some(min), Some(max)) => Some(VersionRange::Bounded {
201                min,
202                min_inclusive,
203                max,
204                max_inclusive,
205            }),
206            (Some(version), None) => Some(VersionRange::Minimum {
207                version,
208                inclusive: min_inclusive,
209            }),
210            (None, Some(version)) => Some(VersionRange::Maximum {
211                version,
212                inclusive: max_inclusive,
213            }),
214            (None, None) => None,
215        }
216    } else {
217        // No comma inside brackets: exact pin, e.g. "[1.0]" == 1.0.
218        Some(VersionRange::Exact(ParsedVersion::parse(inner.trim())))
219    }
220}
221
222fn satisfies_min(v: &ParsedVersion, min: &ParsedVersion, inclusive: bool) -> bool {
223    let ord = compare_parsed(v, min);
224    if inclusive {
225        ord != Ordering::Less
226    } else {
227        ord == Ordering::Greater
228    }
229}
230
231fn satisfies_max(v: &ParsedVersion, max: &ParsedVersion, inclusive: bool) -> bool {
232    let ord = compare_parsed(v, max);
233    if inclusive {
234        ord != Ordering::Greater
235    } else {
236        ord == Ordering::Less
237    }
238}
239
240/// Compares an "up to date" reference version against `range`'s floor, for range shapes
241/// that express a minimum with no upper bound — a bare floor (`1.0.0`) or an explicit
242/// open-ended minimum (`[1.0.0,)`, `(1.0.0,)`, `[1.0.0,]`). Both are the same
243/// `VersionRange::Minimum` shape once parsed, so this classifies on that shape rather than
244/// the range string's leading bracket character (a bare floor and `[1.0.0,)` must be
245/// treated identically — see `is_requirement_up_to_date` in `crate::formatter`).
246///
247/// Returns `None` for exact pins, maximums, bounded ranges, floating patterns (`1.1.*`,
248/// which `parse_range` cannot represent as `Minimum`), or unparseable input — those already
249/// express a genuine compatibility window rather than a simple pin, so the caller should
250/// fall back to `satisfies`.
251pub(crate) fn compare_minimum_floor(range: &str, other: &str) -> Option<Ordering> {
252    match parse_range(range)? {
253        VersionRange::Minimum { version, .. } => {
254            Some(compare_parsed(&version, &ParsedVersion::parse(other)))
255        }
256        VersionRange::Exact(_) | VersionRange::Maximum { .. } | VersionRange::Bounded { .. } => {
257            None
258        }
259    }
260}
261
262/// Whether `version` falls inside an already-parsed NuGet interval `range`.
263pub(crate) fn range_contains(version: &str, range: &VersionRange) -> bool {
264    let v = ParsedVersion::parse(version);
265
266    match range {
267        VersionRange::Exact(target) => compare_parsed(&v, target) == Ordering::Equal,
268        VersionRange::Minimum { version, inclusive } => satisfies_min(&v, version, *inclusive),
269        VersionRange::Maximum { version, inclusive } => satisfies_max(&v, version, *inclusive),
270        VersionRange::Bounded {
271            min,
272            min_inclusive,
273            max,
274            max_inclusive,
275        } => satisfies_min(&v, min, *min_inclusive) && satisfies_max(&v, max, *max_inclusive),
276    }
277}
278
279/// Checks whether `version` satisfies a NuGet interval-notation `range` (spec §2).
280///
281/// Supports bare floors (`1.0`), exact pins (`[1.0]`), open/closed minimums and maximums
282/// (`[1.0,)`, `(,1.0]`, ...), and bounded intervals (`[1.0,2.0)`, ...). Returns `false` for
283/// unparseable ranges rather than panicking. Convenience wrapper around `parse_range` +
284/// `range_contains` for callers that don't need to test more than one candidate against
285/// the same range.
286pub fn satisfies(version: &str, range: &str) -> bool {
287    match parse_range(range) {
288        Some(parsed_range) => range_contains(version, &parsed_range),
289        None => false,
290    }
291}
292
293/// A parsed floating-version pattern (spec §2), produced once per dependency by
294/// [`parse_float`] and reused by [`crate::formatter::NuGetMatcher`] against every candidate
295/// version, instead of being re-parsed per candidate.
296pub(crate) enum FloatPattern {
297    /// `*` (stable only) or `*-*` (including prerelease).
298    Any { include_prerelease: bool },
299    /// `1.1.*` (stable only) or `1.1.*-*` (including prerelease); `prefix` is the
300    /// dot-separated numeric prefix (e.g. `"1.1"`).
301    NumericPrefix {
302        prefix: String,
303        include_prerelease: bool,
304    },
305    /// `1.2.0-rc.*`: matches the stable version `1.2.0` itself, or any prerelease whose
306    /// numeric core is `1.2.0` and whose prerelease label starts with `rc`.
307    PrereleaseLabelPrefix {
308        stable_prefix: String,
309        label_prefix: String,
310    },
311}
312
313pub(crate) fn parse_float(pattern: &str) -> Option<FloatPattern> {
314    let pattern = pattern.trim();
315    if pattern == "*" {
316        return Some(FloatPattern::Any {
317            include_prerelease: false,
318        });
319    }
320    if pattern == "*-*" {
321        return Some(FloatPattern::Any {
322            include_prerelease: true,
323        });
324    }
325
326    if let Some(prefix) = pattern.strip_suffix(".*-*") {
327        return Some(FloatPattern::NumericPrefix {
328            prefix: prefix.to_string(),
329            include_prerelease: true,
330        });
331    }
332
333    if let Some(prefix) = pattern.strip_suffix(".*") {
334        if let Some((stable, label)) = prefix.split_once('-') {
335            return Some(FloatPattern::PrereleaseLabelPrefix {
336                stable_prefix: stable.to_string(),
337                label_prefix: label.to_string(),
338            });
339        }
340        return Some(FloatPattern::NumericPrefix {
341            prefix: prefix.to_string(),
342            include_prerelease: false,
343        });
344    }
345
346    None
347}
348
349/// Returns true if `version`'s numeric core has `prefix` as an exact dot-separated
350/// component prefix (e.g. `"1.1"` matches `"1.1.5"` but not `"1.10"`).
351fn numeric_prefix_matches(version: &str, prefix: &str) -> bool {
352    let core = version.split(['-', '+']).next().unwrap_or(version);
353    let core_parts: Vec<&str> = core.split('.').collect();
354    let prefix_parts: Vec<&str> = prefix.split('.').collect();
355    if prefix_parts.len() > core_parts.len() {
356        return false;
357    }
358    core_parts
359        .iter()
360        .zip(prefix_parts.iter())
361        .all(|(c, p)| c == p)
362}
363
364/// Returns true if `version`'s numeric core exactly equals `stable_prefix`'s numeric core.
365fn stable_core_matches(version: &str, stable_prefix: &str) -> bool {
366    let v = ParsedVersion::parse(version);
367    let s = ParsedVersion::parse(stable_prefix);
368    v.major == s.major && v.minor == s.minor && v.patch == s.patch && v.revision == s.revision
369}
370
371/// Returns true if `version`'s prerelease label starts with `label_prefix`, case-insensitively.
372fn prerelease_label_starts_with(version: &str, label_prefix: &str) -> bool {
373    let Some((_, pre)) = version.split_once('-') else {
374        return false;
375    };
376    let pre = pre.split('+').next().unwrap_or(pre);
377    pre.to_lowercase().starts_with(&label_prefix.to_lowercase())
378}
379
380/// Whether `version` (already parsed as `parsed`) matches an already-parsed floating-version
381/// `pattern`.
382pub(crate) fn float_matches(version: &str, parsed: &ParsedVersion, pattern: &FloatPattern) -> bool {
383    match pattern {
384        FloatPattern::Any { include_prerelease } => *include_prerelease || parsed.pre.is_empty(),
385        FloatPattern::NumericPrefix {
386            prefix,
387            include_prerelease,
388        } => {
389            (*include_prerelease || parsed.pre.is_empty())
390                && numeric_prefix_matches(version, prefix)
391        }
392        FloatPattern::PrereleaseLabelPrefix {
393            stable_prefix,
394            label_prefix,
395        } => {
396            stable_core_matches(version, stable_prefix)
397                && (parsed.pre.is_empty() || prerelease_label_starts_with(version, label_prefix))
398        }
399    }
400}
401
402/// Resolves a floating-version pattern (`*`, `1.1.*`, `*-*`, `1.2.0-rc.*`, ...) against a
403/// list of available versions, returning the highest matching version.
404///
405/// Prerelease versions are excluded unless the pattern is itself prerelease-bearing
406/// (`*-*`, `*.-*`) or the prerelease-label-prefix form is used. Returns `None` if the
407/// pattern cannot be parsed or no version matches.
408pub fn resolve_float<'a>(versions: &'a [String], pattern: &str) -> Option<&'a str> {
409    let float = parse_float(pattern)?;
410    let mut best: Option<(&'a str, ParsedVersion)> = None;
411
412    for v in versions {
413        let parsed = ParsedVersion::parse(v);
414        if !float_matches(v, &parsed, &float) {
415            continue;
416        }
417
418        let is_better = best.as_ref().is_none_or(|(_, best_parsed)| {
419            compare_parsed(&parsed, best_parsed) == Ordering::Greater
420        });
421        if is_better {
422            best = Some((v.as_str(), parsed));
423        }
424    }
425
426    best.map(|(v, _)| v)
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn test_parse_range_accepts_well_formed_ranges() {
435        assert!(parse_range("[1.0.0]").is_some());
436        assert!(parse_range("[1.0,2.0)").is_some());
437        assert!(parse_range("(1.0,2.0]").is_some());
438        assert!(parse_range("1.0.0").is_some());
439    }
440
441    #[test]
442    fn test_parse_float_accepts_well_formed_floats() {
443        assert!(parse_float("*").is_some());
444        assert!(parse_float("*-*").is_some());
445        assert!(parse_float("1.1.*").is_some());
446        assert!(parse_float("1.1.*-*").is_some());
447    }
448
449    #[test]
450    fn test_parse_range_rejects_malformed_range() {
451        assert!(parse_range("[1.0,2.0").is_none());
452    }
453
454    #[test]
455    fn test_parse_float_rejects_malformed_float() {
456        // Contains '*' but not in a recognized float shape (not a trailing `.*`/`*-*`).
457        assert!(parse_float("1.*.0").is_none());
458        assert!(parse_float("*.1").is_none());
459    }
460
461    #[test]
462    fn test_compare_versions_basic() {
463        assert_eq!(compare_versions("1.0.0", "1.0.0"), Ordering::Equal);
464        assert_eq!(compare_versions("1.0.1", "1.0.0"), Ordering::Greater);
465        assert_eq!(compare_versions("1.0.0", "1.0.1"), Ordering::Less);
466        assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater);
467        assert_eq!(compare_versions("10.0.0", "9.0.0"), Ordering::Greater);
468    }
469
470    #[test]
471    fn test_compare_versions_missing_components_are_zero() {
472        assert_eq!(compare_versions("1.0", "1.0.0"), Ordering::Equal);
473        assert_eq!(compare_versions("1", "1.0.0.0"), Ordering::Equal);
474        assert_eq!(compare_versions("1.2", "1.2.0.1"), Ordering::Less);
475    }
476
477    #[test]
478    fn test_compare_versions_four_component() {
479        assert_eq!(compare_versions("1.0.0.1", "1.0.0.0"), Ordering::Greater);
480        assert_eq!(compare_versions("1.0.0.10", "1.0.0.9"), Ordering::Greater);
481    }
482
483    #[test]
484    fn test_compare_versions_stable_beats_prerelease() {
485        assert_eq!(compare_versions("1.0.0", "1.0.0-rc"), Ordering::Greater);
486        assert_eq!(compare_versions("1.0.0-rc", "1.0.0"), Ordering::Less);
487    }
488
489    #[test]
490    fn test_compare_versions_prerelease_case_insensitive() {
491        assert_eq!(
492            compare_versions("1.0.0-Alpha", "1.0.0-alpha"),
493            Ordering::Equal
494        );
495        assert_eq!(
496            compare_versions("1.0.0-RC.1", "1.0.0-rc.1"),
497            Ordering::Equal
498        );
499    }
500
501    #[test]
502    fn test_compare_versions_prerelease_numeric_vs_alphanumeric() {
503        // Numeric identifiers always have lower precedence than alphanumeric ones.
504        assert_eq!(compare_versions("1.0.0-1", "1.0.0-alpha"), Ordering::Less);
505    }
506
507    #[test]
508    fn test_compare_versions_prerelease_more_fields_higher_precedence() {
509        assert_eq!(
510            compare_versions("1.0.0-alpha.1", "1.0.0-alpha"),
511            Ordering::Greater
512        );
513    }
514
515    #[test]
516    fn test_compare_versions_build_metadata_ignored() {
517        assert_eq!(
518            compare_versions("1.0.0+build1", "1.0.0+build2"),
519            Ordering::Equal
520        );
521        assert_eq!(
522            compare_versions("1.0.0-rc+build1", "1.0.0-rc+build2"),
523            Ordering::Equal
524        );
525    }
526
527    #[test]
528    fn test_is_prerelease_dotnet_labels() {
529        // Real .NET prerelease labels that the shared keyword sniff in
530        // deps_core::registry::Version::is_prerelease (-alpha|-beta|-rc|-dev|-pre|-snapshot|
531        // -canary|-nightly) would all misclassify as stable.
532        assert!(is_prerelease("13.0.0-rtm"));
533        assert!(is_prerelease("8.0.0-servicing.23"));
534        assert!(is_prerelease("1.0.0-CI-20240101"));
535        assert!(is_prerelease("6.0.0-final"));
536        assert!(is_prerelease("7.0.0-preview.1"));
537    }
538
539    #[test]
540    fn test_is_prerelease_stable() {
541        assert!(!is_prerelease("1.0.0"));
542        assert!(!is_prerelease("13.0.0"));
543        assert!(!is_prerelease("1.0.0.5"));
544    }
545
546    #[test]
547    fn test_is_prerelease_ignores_build_metadata() {
548        assert!(!is_prerelease("1.0.0+build-with-dash"));
549        assert!(is_prerelease("1.0.0-rc+build"));
550    }
551
552    #[test]
553    fn test_satisfies_bare_is_minimum_inclusive() {
554        assert!(satisfies("1.0.0", "1.0"));
555        assert!(satisfies("2.0.0", "1.0"));
556        assert!(!satisfies("0.9.0", "1.0"));
557    }
558
559    #[test]
560    fn test_satisfies_exact_pin() {
561        assert!(satisfies("1.0.0", "[1.0]"));
562        assert!(satisfies("1.0", "[1.0.0]"));
563        assert!(!satisfies("1.0.1", "[1.0]"));
564    }
565
566    #[test]
567    fn test_satisfies_open_minimum() {
568        assert!(satisfies("1.0.0", "[1.0,)"));
569        assert!(satisfies("1.5.0", "[1.0,)"));
570        assert!(!satisfies("0.9.0", "[1.0,)"));
571
572        assert!(satisfies("1.0.1", "(1.0,)"));
573        assert!(!satisfies("1.0.0", "(1.0,)"));
574    }
575
576    #[test]
577    fn test_satisfies_open_maximum() {
578        assert!(satisfies("1.0.0", "(,1.0]"));
579        assert!(!satisfies("1.0.1", "(,1.0]"));
580
581        assert!(satisfies("0.9.0", "(,1.0)"));
582        assert!(!satisfies("1.0.0", "(,1.0)"));
583    }
584
585    #[test]
586    fn test_satisfies_bounded() {
587        assert!(satisfies("1.5.0", "[1.0,2.0]"));
588        assert!(satisfies("2.0.0", "[1.0,2.0]"));
589        assert!(!satisfies("2.0.0", "[1.0,2.0)"));
590        assert!(!satisfies("1.0.0", "(1.0,2.0)"));
591        assert!(satisfies("1.0.1", "(1.0,2.0)"));
592        assert!(!satisfies("1.0.0", "(1.0,2.0]"));
593        assert!(satisfies("1.0.1", "(1.0,2.0]"));
594    }
595
596    #[test]
597    fn test_satisfies_malformed_range_returns_false() {
598        assert!(!satisfies("1.0.0", ""));
599        assert!(!satisfies("1.0.0", "[1.0"));
600        assert!(!satisfies("1.0.0", "(,)"));
601    }
602
603    #[test]
604    fn test_resolve_float_any_stable_only() {
605        let versions: Vec<String> = ["1.0.0", "2.0.0-rc", "1.5.0"]
606            .into_iter()
607            .map(String::from)
608            .collect();
609        assert_eq!(resolve_float(&versions, "*"), Some("1.5.0"));
610    }
611
612    #[test]
613    fn test_resolve_float_any_including_prerelease() {
614        let versions: Vec<String> = ["1.0.0", "2.0.0-rc", "1.5.0"]
615            .into_iter()
616            .map(String::from)
617            .collect();
618        assert_eq!(resolve_float(&versions, "*-*"), Some("2.0.0-rc"));
619    }
620
621    #[test]
622    fn test_resolve_float_numeric_prefix_stable() {
623        let versions: Vec<String> = ["1.1.0", "1.1.5", "1.2.0", "1.1.9-beta"]
624            .into_iter()
625            .map(String::from)
626            .collect();
627        assert_eq!(resolve_float(&versions, "1.1.*"), Some("1.1.5"));
628    }
629
630    #[test]
631    fn test_resolve_float_numeric_prefix_including_prerelease() {
632        let versions: Vec<String> = ["1.1.0", "1.1.5", "1.1.9-beta"]
633            .into_iter()
634            .map(String::from)
635            .collect();
636        assert_eq!(resolve_float(&versions, "1.1.*-*"), Some("1.1.9-beta"));
637    }
638
639    #[test]
640    fn test_resolve_float_prerelease_label_prefix_stable_wins() {
641        let versions: Vec<String> = ["1.2.0-rc.1", "1.2.0-rc.2", "1.2.0"]
642            .into_iter()
643            .map(String::from)
644            .collect();
645        assert_eq!(resolve_float(&versions, "1.2.0-rc.*"), Some("1.2.0"));
646    }
647
648    #[test]
649    fn test_resolve_float_prerelease_label_prefix_no_stable() {
650        let versions: Vec<String> = ["1.2.0-rc.1", "1.2.0-rc.2", "1.2.0-beta"]
651            .into_iter()
652            .map(String::from)
653            .collect();
654        assert_eq!(resolve_float(&versions, "1.2.0-rc.*"), Some("1.2.0-rc.2"));
655    }
656
657    #[test]
658    fn test_resolve_float_no_match_returns_none() {
659        let versions: Vec<String> = vec!["2.0.0".to_string()];
660        assert_eq!(resolve_float(&versions, "1.1.*"), None);
661    }
662
663    #[test]
664    fn test_resolve_float_invalid_pattern_returns_none() {
665        let versions: Vec<String> = vec!["1.0.0".to_string()];
666        assert_eq!(resolve_float(&versions, "not-a-pattern"), None);
667    }
668}