Skip to main content

dependency_check_updates_core/
version.rs

1//! Generic, ecosystem-agnostic version selection.
2//!
3//! npm, crates.io, and the GitHub Tags API all pick a target version from a
4//! sorted candidate list using the same algorithm — only the concrete version
5//! type and the fallback values differ. This module captures that algorithm
6//! once behind the [`SelectableVersion`] trait so the three registry clients
7//! no longer carry near-identical copies of it.
8
9use crate::types::TargetLevel;
10
11/// A version type the [`select_version`] algorithm can operate on.
12///
13/// Implemented in this crate for both `node_semver::Version` and
14/// `semver::Version`. The impls live here (rather than in the ecosystem
15/// crates) because both the Node and GitHub registries resolve
16/// `node_semver::Version`; a per-crate impl would violate the orphan rule.
17pub trait SelectableVersion: std::fmt::Display {
18    /// Whether this version carries a pre-release tag (e.g. `-rc.1`).
19    fn is_prerelease(&self) -> bool;
20    /// Major version component.
21    fn major(&self) -> u64;
22    /// Minor version component.
23    fn minor(&self) -> u64;
24    /// Patch version component.
25    fn patch(&self) -> u64;
26}
27
28impl SelectableVersion for node_semver::Version {
29    fn is_prerelease(&self) -> bool {
30        !self.pre_release.is_empty()
31    }
32    fn major(&self) -> u64 {
33        self.major
34    }
35    fn minor(&self) -> u64 {
36        self.minor
37    }
38    fn patch(&self) -> u64 {
39        self.patch
40    }
41}
42
43impl SelectableVersion for semver::Version {
44    fn is_prerelease(&self) -> bool {
45        !self.pre.is_empty()
46    }
47    fn major(&self) -> u64 {
48        self.major
49    }
50    fn minor(&self) -> u64 {
51        self.minor
52    }
53    fn patch(&self) -> u64 {
54        self.patch
55    }
56}
57
58impl SelectableVersion for pep440_rs::Version {
59    /// `any_prerelease` covers alpha/beta/rc *and* dev releases — all of which
60    /// are "not a final stable release" for selection purposes.
61    fn is_prerelease(&self) -> bool {
62        self.any_prerelease()
63    }
64    fn major(&self) -> u64 {
65        self.release().first().copied().unwrap_or(0)
66    }
67    fn minor(&self) -> u64 {
68        self.release().get(1).copied().unwrap_or(0)
69    }
70    fn patch(&self) -> u64 {
71        self.release().get(2).copied().unwrap_or(0)
72    }
73}
74
75/// Select the best candidate for `target` from a pre-sorted (ascending)
76/// `all_versions` list.
77///
78/// `current` is the user's currently-pinned version (already parsed), used for
79/// the "prerelease tail" policy and to constrain `Minor`/`Patch` to the same
80/// major(.minor).
81///
82/// Two fallbacks are split out so the registries can share one implementation
83/// despite differing edge-case behaviour:
84/// - `latest_for_stable` is returned for `Latest` when `current` is stable,
85///   and for an empty candidate list. npm/crates.io pass the registry's latest
86///   stable; GitHub passes its highest stable tag.
87/// - `unparseable_minor_patch` is returned for `Minor`/`Patch` when `current`
88///   could not be parsed. npm/crates.io fall back to the latest stable here;
89///   GitHub passes `None` (an unparseable ref gives no major to stay on).
90#[must_use]
91pub fn select_version<V: SelectableVersion>(
92    current: Option<&V>,
93    all_versions: &[V],
94    target: TargetLevel,
95    latest_for_stable: Option<String>,
96    unparseable_minor_patch: Option<String>,
97) -> Option<String> {
98    if all_versions.is_empty() {
99        return latest_for_stable;
100    }
101
102    let current_is_prerelease = current.is_some_and(SelectableVersion::is_prerelease);
103
104    // Accept any stable version; accept a pre-release only when the user is
105    // already on a pre-release of the *same* major.minor.patch train. Written
106    // without `unwrap`/`expect` so the function carries no panic path.
107    let accept = |v: &&V| -> bool {
108        if !v.is_prerelease() {
109            return true;
110        }
111        current.is_some_and(|cur| {
112            cur.is_prerelease()
113                && v.major() == cur.major()
114                && v.minor() == cur.minor()
115                && v.patch() == cur.patch()
116        })
117    };
118
119    // `match` is kept (for variant-exhaustiveness) but every arm body is an
120    // expression that begins on the arm line, so each branch is a single
121    // covered region.
122    match target {
123        TargetLevel::Latest if current_is_prerelease => all_versions
124            .iter()
125            .rev()
126            .find(accept)
127            .map(ToString::to_string),
128        TargetLevel::Latest => latest_for_stable,
129        TargetLevel::Greatest | TargetLevel::Newest => all_versions.last().map(ToString::to_string),
130        TargetLevel::Minor => match current {
131            None => unparseable_minor_patch,
132            Some(cur) => all_versions
133                .iter()
134                .rev()
135                .find(|v| v.major() == cur.major() && accept(v))
136                .map(ToString::to_string),
137        },
138        TargetLevel::Patch => match current {
139            None => unparseable_minor_patch,
140            Some(cur) => all_versions
141                .iter()
142                .rev()
143                .find(|v| v.major() == cur.major() && v.minor() == cur.minor() && accept(v))
144                .map(ToString::to_string),
145        },
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use rstest::rstest;
153
154    fn vers(specs: &[&str]) -> Vec<semver::Version> {
155        let mut v: Vec<_> = specs
156            .iter()
157            .filter_map(|s| semver::Version::parse(s).ok())
158            .collect();
159        v.sort();
160        v
161    }
162
163    fn parse(s: &str) -> Option<semver::Version> {
164        semver::Version::parse(s).ok()
165    }
166
167    /// Whole-algorithm coverage for [`select_version`] against
168    /// `semver::Version`. Every case captures the unique combination of
169    /// `(current, candidates, target, latest_for_stable, unparseable_minor_patch)`
170    /// → expected output that the three registry clients depend on.
171    ///
172    /// `current_str = None` simulates an unparseable current pin; the
173    /// `unparseable_*` case proves the dedicated fallback wins over
174    /// `latest_for_stable` for `Minor`/`Patch`.
175    #[rstest]
176    #[case::empty_returns_latest_for_stable(
177        None,
178        &[],
179        TargetLevel::Greatest,
180        Some("1.0.0"),
181        None,
182        Some("1.0.0"),
183    )]
184    #[case::latest_stable_returns_fallback(
185        Some("1.0.0"),
186        &["1.0.0", "1.5.0", "2.0.0"],
187        TargetLevel::Latest,
188        Some("2.0.0"),
189        None,
190        Some("2.0.0"),
191    )]
192    #[case::minor_stays_on_major(
193        Some("1.0.0"),
194        &["1.0.0", "1.5.0", "2.0.0"],
195        TargetLevel::Minor,
196        None,
197        None,
198        Some("1.5.0"),
199    )]
200    #[case::patch_stays_on_minor(
201        Some("1.0.0"),
202        &["1.0.0", "1.0.5", "1.1.0", "2.0.0"],
203        TargetLevel::Patch,
204        None,
205        None,
206        Some("1.0.5"),
207    )]
208    #[case::greatest_includes_prerelease(
209        Some("1.0.0"),
210        &["1.0.0", "2.0.0-rc.1"],
211        TargetLevel::Greatest,
212        None,
213        None,
214        Some("2.0.0-rc.1"),
215    )]
216    // Stable current + Latest → returns latest_for_stable, never a prerelease.
217    #[case::latest_stable_excludes_prerelease_via_fallback(
218        Some("1.0.0"),
219        &["1.0.0", "2.0.0-rc.1"],
220        TargetLevel::Latest,
221        Some("1.0.0"),
222        None,
223        Some("1.0.0"),
224    )]
225    // Current on 2.0.0-rc.1 → Latest may climb the same train.
226    #[case::prerelease_tail_same_train(
227        Some("2.0.0-rc.1"),
228        &["1.1.0", "2.0.0-rc.1", "2.0.0-rc.2"],
229        TargetLevel::Latest,
230        Some("1.1.0"),
231        None,
232        Some("2.0.0-rc.2"),
233    )]
234    // current None (unparseable) → returns the unparseable fallback, not latest_for_stable.
235    #[case::unparseable_minor_uses_dedicated_fallback(
236        None,
237        &["1.0.0", "2.0.0"],
238        TargetLevel::Minor,
239        Some("2.0.0"),
240        None,
241        None,
242    )]
243    // Patch with current None (unparseable) → returns the unparseable fallback.
244    // Exercises the early-return arm in `TargetLevel::Patch` (line 142).
245    #[case::unparseable_patch_uses_dedicated_fallback(
246        None,
247        &["1.0.0", "1.0.1", "2.0.0"],
248        TargetLevel::Patch,
249        Some("2.0.0"),
250        None,
251        None,
252    )]
253    // `Newest` is the second arm under `Greatest | Newest` (line 127); proves
254    // it picks the last entry just like `Greatest`.
255    #[case::newest_returns_last_candidate(
256        Some("1.0.0"),
257        &["1.0.0", "1.5.0", "2.0.0"],
258        TargetLevel::Newest,
259        None,
260        None,
261        Some("2.0.0"),
262    )]
263    fn select_version_cases(
264        #[case] current_str: Option<&str>,
265        #[case] version_strs: &[&str],
266        #[case] target: TargetLevel,
267        #[case] latest_for_stable: Option<&str>,
268        #[case] unparseable_minor_patch: Option<&str>,
269        #[case] expected: Option<&str>,
270    ) {
271        let cur = current_str.and_then(parse);
272        let candidates = vers(version_strs);
273        let selected = select_version(
274            cur.as_ref(),
275            &candidates,
276            target,
277            latest_for_stable.map(ToOwned::to_owned),
278            unparseable_minor_patch.map(ToOwned::to_owned),
279        );
280        assert_eq!(selected, expected.map(ToOwned::to_owned));
281    }
282
283    #[test]
284    fn test_selectable_version_trait_accessors() {
285        let v = semver::Version::parse("3.4.5-beta.1").unwrap();
286        assert!(v.is_prerelease());
287        assert_eq!(v.major(), 3);
288        assert_eq!(v.minor(), 4);
289        assert_eq!(v.patch(), 5);
290
291        let nv = node_semver::Version::parse("6.7.8").unwrap();
292        assert!(!nv.is_prerelease());
293        assert_eq!(nv.major(), 6);
294        assert_eq!(nv.minor(), 7);
295        assert_eq!(nv.patch(), 8);
296
297        let pv: pep440_rs::Version = "9.10.11".parse().unwrap();
298        assert!(!pv.is_prerelease());
299        assert_eq!(pv.major(), 9);
300        assert_eq!(pv.minor(), 10);
301        assert_eq!(pv.patch(), 11);
302
303        // PEP 440 pre/dev releases and short release tuples.
304        let pre: pep440_rs::Version = "2.0a1".parse().unwrap();
305        assert!(pre.is_prerelease());
306        assert_eq!(pre.major(), 2);
307        assert_eq!(pre.minor(), 0);
308        let dev: pep440_rs::Version = "1.0.dev0".parse().unwrap();
309        assert!(dev.is_prerelease());
310        let short: pep440_rs::Version = "5".parse().unwrap();
311        assert_eq!(short.major(), 5);
312        assert_eq!(short.minor(), 0);
313        assert_eq!(short.patch(), 0);
314    }
315}