Skip to main content

stow_types/
versioning.rs

1//! Semver reasoning for cache coverage: which upgrade a client may accept
2//! silently, and which breaking line a version belongs to.
3
4use semver::Version;
5use serde::{Deserialize, Serialize};
6
7/// The semver breaking line a version belongs to.
8///
9/// Cargo's `^` rules make versions within one line interchangeable: `1.x`
10/// shares a line across minor and patch, `0.x.y` shares only the patch for
11/// `0.0.x`, and `0.x` shares the minor line.
12#[derive(
13    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, utoipa::ToSchema,
14)]
15pub enum SemverBreakingLine {
16    /// `major >= 1`: the major version number.
17    StableMajor(u64),
18    /// `0.x.y` with `x >= 1`: the minor version number.
19    PreOneMinor(u64),
20    /// `0.0.y`: the patch version number.
21    PreZeroPatch(u64),
22}
23
24/// Whether `candidate` may replace `current` under cargo's `^` compatibility
25/// rules: strictly newer, on the same breaking line, and not a pre-release.
26#[must_use]
27pub fn is_semver_compatible_upgrade(current: &Version, candidate: &Version) -> bool {
28    if candidate <= current {
29        return false;
30    }
31    // Cargo's `^req` never resolves to a pre-release the user did not pin
32    // explicitly; serving one would inject code the user's own resolution
33    // could never produce.
34    if !candidate.pre.is_empty() {
35        return false;
36    }
37
38    if current.major != 0 {
39        return candidate.major == current.major;
40    }
41    if current.minor != 0 {
42        return candidate.major == 0 && candidate.minor == current.minor;
43    }
44
45    candidate.major == 0 && candidate.minor == 0 && candidate.patch == current.patch
46}
47
48/// The breaking line `version` belongs to.
49#[must_use]
50pub const fn breaking_line(version: &Version) -> SemverBreakingLine {
51    if version.major != 0 {
52        return SemverBreakingLine::StableMajor(version.major);
53    }
54    if version.minor != 0 {
55        return SemverBreakingLine::PreOneMinor(version.minor);
56    }
57
58    SemverBreakingLine::PreZeroPatch(version.patch)
59}
60
61/// Whether `candidate`'s breaking line is among the `limit` most recent
62/// distinct breaking lines in `known_versions` (ordered by the newest version
63/// in each line).
64///
65/// Stow only prebuilds the most recent breaking lines; a candidate outside
66/// the window is a miss the scheduler does not chase.
67pub fn is_within_recent_breaking_lines<'a>(
68    candidate: &Version,
69    known_versions: impl IntoIterator<Item = &'a Version>,
70    limit: usize,
71) -> bool {
72    if limit == 0 {
73        return false;
74    }
75
76    let mut versions = known_versions.into_iter().cloned().collect::<Vec<_>>();
77    versions.sort_by(|left, right| right.cmp(left));
78
79    let candidate_line = breaking_line(candidate);
80    let mut lines = Vec::<SemverBreakingLine>::new();
81    for version in versions {
82        let line = breaking_line(&version);
83        if lines.iter().any(|known| known == &line) {
84            continue;
85        }
86        lines.push(line);
87        if lines.len() == limit {
88            break;
89        }
90    }
91
92    lines.iter().any(|line| line == &candidate_line)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::{
98        SemverBreakingLine, breaking_line, is_semver_compatible_upgrade,
99        is_within_recent_breaking_lines,
100    };
101
102    fn version(raw: &str) -> semver::Version {
103        semver::Version::parse(raw).unwrap()
104    }
105
106    #[test]
107    fn semver_compatibility_matches_cargo_major_rules() {
108        assert!(is_semver_compatible_upgrade(
109            &version("1.2.3"),
110            &version("1.9.0")
111        ));
112        assert!(!is_semver_compatible_upgrade(
113            &version("1.2.3"),
114            &version("2.0.0")
115        ));
116        assert!(is_semver_compatible_upgrade(
117            &version("0.9.1"),
118            &version("0.9.7")
119        ));
120        assert!(!is_semver_compatible_upgrade(
121            &version("0.9.1"),
122            &version("0.10.0")
123        ));
124        assert!(!is_semver_compatible_upgrade(
125            &version("0.0.5"),
126            &version("0.0.6")
127        ));
128    }
129
130    #[test]
131    fn pre_release_candidates_are_never_compatible_upgrades() {
132        assert!(!is_semver_compatible_upgrade(
133            &version("1.4.3"),
134            &version("1.5.0-rc.1")
135        ));
136        assert!(!is_semver_compatible_upgrade(
137            &version("0.9.1"),
138            &version("0.9.7-beta.2")
139        ));
140    }
141
142    #[test]
143    fn breaking_lines_follow_semver_boundaries() {
144        assert_eq!(
145            breaking_line(&version("3.2.1")),
146            SemverBreakingLine::StableMajor(3)
147        );
148        assert_eq!(
149            breaking_line(&version("0.9.4")),
150            SemverBreakingLine::PreOneMinor(9)
151        );
152        assert_eq!(
153            breaking_line(&version("0.0.7")),
154            SemverBreakingLine::PreZeroPatch(7)
155        );
156    }
157
158    #[test]
159    fn recent_breaking_line_window_ignores_older_lines() {
160        let known = [
161            version("3.0.2"),
162            version("2.4.1"),
163            version("1.9.9"),
164            version("0.8.7"),
165        ];
166        assert!(is_within_recent_breaking_lines(
167            &version("3.0.2"),
168            known.iter(),
169            3
170        ));
171        assert!(is_within_recent_breaking_lines(
172            &version("2.4.1"),
173            known.iter(),
174            3
175        ));
176        assert!(is_within_recent_breaking_lines(
177            &version("1.9.9"),
178            known.iter(),
179            3
180        ));
181        assert!(!is_within_recent_breaking_lines(
182            &version("0.8.7"),
183            known.iter(),
184            3
185        ));
186    }
187}