Skip to main content

blue_build_utils/
semver.rs

1use std::{ops::Not, str::FromStr};
2
3use lazy_regex::regex_captures;
4use miette::bail;
5use semver::{BuildMetadata, Prerelease};
6use serde::{Deserialize, Serialize, de::Error};
7
8#[derive(Debug, Clone, Serialize)]
9pub struct Version {
10    prefix: Option<String>,
11    version: semver::Version,
12}
13
14impl std::ops::Deref for Version {
15    type Target = semver::Version;
16
17    fn deref(&self) -> &Self::Target {
18        &self.version
19    }
20}
21
22impl std::fmt::Display for Version {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        self.version.fmt(f)
25    }
26}
27
28impl<'de> Deserialize<'de> for Version {
29    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
30    where
31        D: serde::Deserializer<'de>,
32    {
33        let ver = String::deserialize(deserializer)?;
34        ver.parse()
35            .map_err(|e: miette::Error| D::Error::custom(e.to_string()))
36    }
37}
38
39impl FromStr for Version {
40    type Err = miette::Error;
41
42    fn from_str(s: &str) -> Result<Self, Self::Err> {
43        let Some((_whole, prefix, major, minor, patch)) = regex_captures!(
44            r"(?:(?<prefix>[a-zA-Z0-9_-]+)-)?v?(?<major>\d+)(?:\.(?<minor>\d+))?(?:\.(?<patch>\d+))?(?:[-_].*)?",
45            s.trim()
46        ) else {
47            bail!("Failed to parse version {s} with regex");
48        };
49
50        let Ok(mut version) = semver::Version::parse(&format!(
51            "{major}.{minor}.{patch}",
52            minor = if minor.is_empty() { "0" } else { minor },
53            patch = if patch.is_empty() { "0" } else { patch }
54        )) else {
55            bail!("Failed to parse version {s}");
56        };
57
58        // delete pre-release field or we can never match pre-release versions of tools
59        version.pre = Prerelease::EMPTY;
60        version.build = BuildMetadata::EMPTY;
61
62        let prefix = prefix.is_empty().not().then(|| prefix.into());
63
64        Ok(Self { prefix, version })
65    }
66}
67
68#[cfg(test)]
69mod test {
70    use rstest::rstest;
71
72    use crate::semver::Version;
73
74    #[rstest]
75    #[case("42", None, 42, 0, 0)]
76    #[case("42.20251020.1", None, 42, 20_251_020, 1)]
77    #[case("42.20251020", None, 42, 20_251_020, 0)]
78    #[case("latest-42.20251020.1", Some("latest"), 42, 20_251_020, 1)]
79    #[case("42-beta.0", None, 42, 0, 0)]
80    #[case("stable-42-beta.0", Some("stable"), 42, 0, 0)]
81    #[case("v42", None, 42, 0, 0)]
82    #[case("v42.20251020.1", None, 42, 20_251_020, 1)]
83    #[case("v42.20251020", None, 42, 20_251_020, 0)]
84    #[case("latest-v42.20251020.1", Some("latest"), 42, 20_251_020, 1)]
85    #[case("v42-beta.0", None, 42, 0, 0)]
86    #[case("stable-v42-beta.0", Some("stable"), 42, 0, 0)]
87    fn parse_version(
88        #[case] version: &str,
89        #[case] prefix: Option<&str>,
90        #[case] major: u64,
91        #[case] minor: u64,
92        #[case] patch: u64,
93    ) {
94        let version = version.parse::<Version>().unwrap();
95        assert_eq!(version.prefix.as_deref(), prefix);
96        assert_eq!(version.version.major, major);
97        assert_eq!(version.version.minor, minor);
98        assert_eq!(version.version.patch, patch);
99    }
100}