Skip to main content

relay_protocol/
release.rs

1//! Releases for the `release` rule condition.
2
3use std::cmp::Ordering;
4use std::fmt;
5
6use semver::Prerelease;
7use sentry_release_parser::Version;
8use serde::de::Error;
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10
11/// A release to compare the versions of other releases against.
12///
13/// A release is a version such as `1.2.3-rc.1+build`, optionally behind a package, such as
14/// `myapp@1.2.3`. The version has one to four numeric components. Missing components are zero.
15/// The build code takes no part in comparisons, so it is dropped.
16///
17/// Serializes as the normalized release string, such as `myapp@1.2.3-rc.1`.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Release {
20    package: Option<String>,
21    quad: Quad,
22    pre: Prerelease,
23}
24
25/// The numeric components of a version. Missing components are zero.
26type Quad = (u64, u64, u64, u64);
27
28impl Release {
29    /// Parses a release.
30    ///
31    /// Returns `None` if the release has no version, such as a commit hash.
32    pub fn parse(release: &str) -> Option<Self> {
33        let (package, quad, pre) = parse_parts(release)?;
34        Some(Self {
35            package: package.map(str::to_owned),
36            quad,
37            pre,
38        })
39    }
40
41    /// Returns how the version of `release` orders relative to this version.
42    ///
43    /// Returns `None` if `release` has no version, or if this release names a package and
44    /// `release` has a different one. Without a package, this compares with releases of every
45    /// package.
46    ///
47    /// Versions order by their numeric components, then by semver precedence of the pre-release.
48    /// A pre-release orders below its final release. Build codes are ignored.
49    pub fn compare(&self, release: &str) -> Option<Ordering> {
50        let (package, quad, pre) = parse_parts(release)?;
51
52        if self.package.is_some() && self.package.as_deref() != package {
53            return None;
54        }
55
56        Some(quad.cmp(&self.quad).then_with(|| pre.cmp(&self.pre)))
57    }
58}
59
60impl fmt::Display for Release {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        if let Some(package) = &self.package {
63            write!(f, "{package}@")?;
64        }
65
66        let (major, minor, patch, revision) = self.quad;
67        write!(f, "{major}.{minor}.{patch}")?;
68        if revision != 0 {
69            write!(f, ".{revision}")?;
70        }
71        if !self.pre.is_empty() {
72            write!(f, "-{}", self.pre)?;
73        }
74
75        Ok(())
76    }
77}
78
79impl Serialize for Release {
80    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
81        serializer.collect_str(self)
82    }
83}
84
85impl<'de> Deserialize<'de> for Release {
86    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
87        let release = String::deserialize(deserializer)?;
88        Self::parse(&release).ok_or_else(|| D::Error::custom("release has no version"))
89    }
90}
91
92/// Splits a release into the parts that take part in comparisons.
93fn parse_parts(release: &str) -> Option<(Option<&str>, Quad, Prerelease)> {
94    let release = sentry_release_parser::Release::parse(release).ok()?;
95
96    // The parser only extracts a version behind a package, so parse the version part
97    // directly. Only the parser knows that a version made of digits is a commit hash.
98    let version = release.version_raw();
99    if release.build_hash() == Some(version) {
100        return None;
101    }
102    let version = Version::parse(version).ok()?;
103
104    Some((release.package(), version.quad(), version.as_semver1().pre))
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_releases_without_version() {
113        for release in ["a4b7e0f9c2d1", "myapp@a4b7e0f9c2d1", "123456789012", ""] {
114            assert_eq!(Release::parse(release), None, "{release}");
115            assert_eq!(
116                Release::parse("1.0.0").unwrap().compare(release),
117                None,
118                "{release}"
119            );
120        }
121    }
122
123    #[test]
124    fn test_compare_versions() {
125        let cases = [
126            ("1.9.0", "1.10.0", Ordering::Less),
127            ("1.2.3.4", "1.2.3.5", Ordering::Less),
128            ("1.2", "1.2.0", Ordering::Equal),
129            ("1.0.0-rc.1", "1.0.0", Ordering::Less),
130            ("1.0.0-beta.2", "1.0.0-beta.11", Ordering::Less),
131            ("1.0.0+build.2", "1.0.0+build.1", Ordering::Equal),
132        ];
133
134        for (release, other, expected) in cases {
135            let ordering = Release::parse(other).unwrap().compare(release);
136            assert_eq!(ordering, Some(expected), "{release} {other}");
137        }
138    }
139
140    #[test]
141    fn test_compare_packages() {
142        let cases = [
143            ("myapp@1.2.3", "1.2.3", Some(Ordering::Equal)),
144            ("1.2.3", "1.2.3", Some(Ordering::Equal)),
145            ("myapp@1.2.3", "myapp@1.2.3", Some(Ordering::Equal)),
146            ("myapp@1.2.3", "other@1.2.3", None),
147            ("1.2.3", "myapp@1.2.3", None),
148        ];
149
150        for (release, other, expected) in cases {
151            let ordering = Release::parse(other).unwrap().compare(release);
152            assert_eq!(ordering, expected, "{release} {other}");
153        }
154    }
155
156    #[test]
157    fn test_serde_normalizes_the_release() {
158        let cases = [
159            ("1.2.0", "1.2.0"),
160            ("1.2", "1.2.0"),
161            ("1.2.3.4", "1.2.3.4"),
162            ("myapp@1.2.0+build", "myapp@1.2.0"),
163            ("myapp@1.2.0-rc.1+build", "myapp@1.2.0-rc.1"),
164        ];
165
166        for (input, normalized) in cases {
167            let release: Release = serde_json::from_str(&format!("{input:?}")).unwrap();
168            assert_eq!(
169                serde_json::to_string(&release).unwrap(),
170                format!("{normalized:?}")
171            );
172            assert_eq!(Release::parse(normalized), Some(release), "{input}");
173        }
174    }
175
176    #[test]
177    fn test_deserialize_rejects_releases_without_version() {
178        assert!(serde_json::from_str::<Release>(r#""a4b7e0f9c2d1""#).is_err());
179    }
180}