Skip to main content

ephemeris_release/
version.rs

1//! Semantic versions shared by release planning, analysis, and publication.
2
3use std::cmp::Ordering;
4use std::fmt;
5use std::str::FromStr;
6
7/// A validated semantic version used throughout release policy.
8#[derive(Clone, Debug, Eq, Hash, PartialEq)]
9pub(crate) struct Version(semver::Version);
10
11/// A version a proposal sets for a unit. A bump must be greater than the
12/// current version. A first release, which has no tag yet, may equal it.
13#[derive(Clone, Debug, Eq, PartialEq)]
14pub(crate) enum Proposed {
15    Bump(Version),
16    First(Version),
17}
18
19impl Proposed {
20    pub(crate) const fn version(&self) -> &Version {
21        match self {
22            Self::Bump(version) | Self::First(version) => version,
23        }
24    }
25
26    /// The same kind of proposal at another version.
27    pub(crate) const fn at(&self, version: Version) -> Self {
28        match self {
29            Self::Bump(_) => Self::Bump(version),
30            Self::First(_) => Self::First(version),
31        }
32    }
33}
34
35/// How far a release moves a version, ordered from patch to major.
36#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
37pub(crate) enum Bump {
38    Patch,
39    Minor,
40    Major,
41}
42
43/// The version and optional revision a release tag names, ordered by version
44/// precedence and then revision, so `3.1.1 > 3.1.0-3 > 3.1.0-2 > 3.1.0`.
45#[derive(Clone, Debug)]
46pub(crate) struct TagVersion {
47    version: Version,
48    revision: Option<u64>,
49}
50
51impl Version {
52    /// Parse strict semver, which rejects partial versions such as `1.2` and
53    /// leading zeros.
54    ///
55    /// # Errors
56    /// Returns an error when `value` is not a strict semantic version.
57    pub(crate) fn parse(value: &str) -> Result<Self, semver::Error> {
58        semver::Version::parse(value).map(Self)
59    }
60
61    /// Return the next patch release, clearing prerelease and build metadata.
62    /// A prerelease advances the patch component before becoming stable.
63    #[must_use]
64    pub(crate) fn next_patch(&self) -> Option<Self> {
65        let mut version = self.0.clone();
66        version.patch = version.patch.checked_add(1)?;
67        version.pre = semver::Prerelease::EMPTY;
68        version.build = semver::BuildMetadata::EMPTY;
69        Some(Self(version))
70    }
71
72    /// Return the next minor release, clearing prerelease and build metadata.
73    #[must_use]
74    pub(crate) fn next_minor(&self) -> Option<Self> {
75        let mut version = self.0.clone();
76        version.minor = version.minor.checked_add(1)?;
77        version.patch = 0;
78        version.pre = semver::Prerelease::EMPTY;
79        version.build = semver::BuildMetadata::EMPTY;
80        Some(Self(version))
81    }
82
83    /// Return the next major release, clearing prerelease and build metadata.
84    #[must_use]
85    pub(crate) fn next_major(&self) -> Option<Self> {
86        let mut version = self.0.clone();
87        version.major = version.major.checked_add(1)?;
88        version.minor = 0;
89        version.patch = 0;
90        version.pre = semver::Prerelease::EMPTY;
91        version.build = semver::BuildMetadata::EMPTY;
92        Some(Self(version))
93    }
94
95    /// Whether this is 0.0.0, the version of a unit with no release yet.
96    pub(crate) fn is_unversioned(&self) -> bool {
97        self.0 == semver::Version::new(0, 0, 0)
98    }
99
100    /// Whether this version is below 1.0.0, ignoring prerelease.
101    #[must_use]
102    pub(crate) const fn is_initial_development(&self) -> bool {
103        self.0.major == 0
104    }
105
106    /// Compare semantic precedence, ignoring build metadata.
107    #[must_use]
108    pub(crate) fn precedence_cmp(&self, other: &Self) -> Ordering {
109        self.0.cmp_precedence(&other.0)
110    }
111
112    /// Check a Cargo-style semantic version requirement.
113    ///
114    /// # Errors
115    /// Returns an error when `requirement` is not a valid requirement.
116    pub(crate) fn satisfies(&self, requirement: &str) -> Result<bool, semver::Error> {
117        semver::VersionReq::parse(requirement).map(|requirement| requirement.matches(&self.0))
118    }
119}
120
121impl Bump {
122    /// The version after this bump, or `None` when a component overflows.
123    #[must_use]
124    pub(crate) fn apply(self, version: &Version) -> Option<Version> {
125        match self {
126            Self::Patch => version.next_patch(),
127            Self::Minor => version.next_minor(),
128            Self::Major => version.next_major(),
129        }
130    }
131}
132
133impl TagVersion {
134    /// Pair a version with an optional packaging revision.
135    pub(crate) const fn new(version: Version, revision: Option<u64>) -> Self {
136        Self { version, revision }
137    }
138
139    /// Split a tag into its version and optional revision. A purely numeric
140    /// suffix such as `3.1.0-3` is revision 3 of 3.1.0, not a prerelease.
141    pub(crate) fn parse(tag: &str, prefix: &str) -> Option<Self> {
142        let rest = tag.strip_prefix(prefix)?;
143        if let Some((base, revision)) = rest.rsplit_once('-')
144            && !base.contains(['-', '+'])
145            && !revision.is_empty()
146            && revision.bytes().all(|byte| byte.is_ascii_digit())
147        {
148            return Some(Self {
149                version: Version::parse(base).ok()?,
150                revision: Some(revision.parse().ok()?),
151            });
152        }
153        Some(Self {
154            version: Version::parse(rest).ok()?,
155            revision: None,
156        })
157    }
158}
159
160impl TagVersion {
161    /// The newest tag under `prefix` that names a version, with that version.
162    pub(crate) fn latest<'t>(tags: &'t [String], prefix: &str) -> Option<(Self, &'t str)> {
163        tags.iter()
164            .filter_map(|tag| Some((Self::parse(tag, prefix)?, tag.as_str())))
165            .max_by(|(left, _), (right, _)| left.cmp(right))
166    }
167
168    /// Whether this is exactly `version`, build metadata included, at
169    /// `revision`.
170    pub(crate) fn names(&self, version: &Version, revision: Option<u64>) -> bool {
171        self.revision == revision && self.version == *version
172    }
173}
174
175impl PartialEq for TagVersion {
176    fn eq(&self, other: &Self) -> bool {
177        self.cmp(other).is_eq()
178    }
179}
180
181impl Eq for TagVersion {}
182
183impl PartialOrd for TagVersion {
184    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
185        Some(self.cmp(other))
186    }
187}
188
189impl Ord for TagVersion {
190    fn cmp(&self, other: &Self) -> Ordering {
191        self.version
192            .precedence_cmp(&other.version)
193            .then_with(|| self.revision.cmp(&other.revision))
194    }
195}
196
197impl fmt::Display for Bump {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        f.write_str(match self {
200            Self::Patch => "patch",
201            Self::Minor => "minor",
202            Self::Major => "major",
203        })
204    }
205}
206
207impl FromStr for Version {
208    type Err = semver::Error;
209
210    fn from_str(value: &str) -> Result<Self, Self::Err> {
211        Self::parse(value)
212    }
213}
214
215impl fmt::Display for Version {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        self.0.fmt(f)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use std::cmp::Ordering;
224
225    use super::{Bump, TagVersion, Version};
226
227    #[test]
228    fn comparison_uses_semantic_precedence_and_ignores_build_metadata() {
229        let stable = Version::parse("1.2.3").unwrap();
230        let built = Version::parse("1.2.3+build.7").unwrap();
231        let prerelease = Version::parse("1.2.3-rc.1").unwrap();
232
233        assert_eq!(stable.precedence_cmp(&built), Ordering::Equal);
234        assert_eq!(stable.precedence_cmp(&prerelease), Ordering::Greater);
235    }
236
237    #[test]
238    fn patch_bump_makes_prerelease_stable_and_clears_build_metadata() {
239        let prerelease = Version::parse("1.2.3-rc.1+build.7").unwrap();
240        assert_eq!(prerelease.next_patch().unwrap().to_string(), "1.2.4");
241    }
242
243    #[test]
244    fn minor_bump_resets_patch_and_clears_suffixes() {
245        let version = Version::parse("1.2.3-rc.1+build.7").unwrap();
246        assert_eq!(version.next_minor().unwrap().to_string(), "1.3.0");
247    }
248
249    #[test]
250    fn major_bump_resets_minor_and_patch_and_clears_suffixes() {
251        let version = Version::parse("1.2.3-rc.1+build.7").unwrap();
252        assert_eq!(version.next_major().unwrap().to_string(), "2.0.0");
253    }
254
255    #[test]
256    fn bump_apply_selects_the_component() {
257        let version = Version::parse("1.2.3").unwrap();
258        assert_eq!(Bump::Patch.apply(&version).unwrap().to_string(), "1.2.4");
259        assert_eq!(Bump::Minor.apply(&version).unwrap().to_string(), "1.3.0");
260        assert_eq!(Bump::Major.apply(&version).unwrap().to_string(), "2.0.0");
261    }
262
263    #[test]
264    fn initial_development_is_below_one() {
265        assert!(Version::parse("0.9.9").unwrap().is_initial_development());
266        assert!(!Version::parse("1.0.0").unwrap().is_initial_development());
267    }
268
269    #[test]
270    fn minor_and_major_bumps_report_overflow() {
271        let minor = Version::parse("1.18446744073709551615.0").unwrap();
272        assert!(minor.next_minor().is_none());
273        let major = Version::parse("18446744073709551615.0.0").unwrap();
274        assert!(major.next_major().is_none());
275    }
276
277    #[test]
278    fn malformed_requirement_is_an_error() {
279        let version = Version::parse("1.2.3").unwrap();
280        assert!(version.satisfies("^1.2").unwrap());
281        assert!(!version.satisfies("^2").unwrap());
282        assert!(version.satisfies("not a requirement").is_err());
283    }
284
285    #[test]
286    fn patch_bump_reports_overflow() {
287        let maximum = Version::parse("1.2.18446744073709551615").unwrap();
288        assert!(maximum.next_patch().is_none());
289    }
290
291    #[test]
292    fn strict_semver_rejects_leading_zeroes() {
293        assert!(Version::parse("1.02.3").is_err());
294    }
295
296    #[test]
297    fn numeric_suffix_is_a_revision_not_a_prerelease() {
298        let parse = |tag| {
299            TagVersion::parse(tag, "v").map(|parsed| (parsed.version.to_string(), parsed.revision))
300        };
301        assert_eq!(parse("v3.1.0-3"), Some(("3.1.0".to_owned(), Some(3))));
302        assert_eq!(parse("v3.1.0"), Some(("3.1.0".to_owned(), None)));
303        assert_eq!(parse("v3.1.0-rc.1"), Some(("3.1.0-rc.1".to_owned(), None)));
304        assert_eq!(parse("v3.1.0-rc-1"), Some(("3.1.0-rc-1".to_owned(), None)));
305        assert_eq!(parse("v3.1-3"), None);
306        assert_eq!(parse("w3.1.0"), None);
307        assert!(TagVersion::parse("v3.1.0-3", "v") > TagVersion::parse("v3.1.0+build", "v"));
308    }
309
310    #[test]
311    fn latest_ignores_tags_without_a_version_after_the_prefix() {
312        let tags = ["app-v1.0.0", "app-v1.2.0", "app-vnext", "app-v1.10.0"].map(String::from);
313        assert_eq!(
314            TagVersion::latest(&tags, "app-v").map(|(_, tag)| tag),
315            Some("app-v1.10.0")
316        );
317        assert!(TagVersion::latest(&tags, "lib-v").is_none());
318    }
319
320    #[test]
321    fn latest_orders_revisions_after_their_version() {
322        fn latest(tags: &[String]) -> Option<&str> {
323            TagVersion::latest(tags, "v").map(|(_, tag)| tag)
324        }
325        let tags = ["v3.1.0", "v3.1.0-2", "v3.1.1", "v3.1.0-3", "v3.1.0-10"].map(String::from);
326        assert_eq!(latest(&tags), Some("v3.1.1"));
327        assert_eq!(latest(&tags[..2]), Some("v3.1.0-2"));
328        assert_eq!(
329            latest(&[&tags[..2], &tags[3..]].concat()),
330            Some("v3.1.0-10")
331        );
332    }
333}