Skip to main content

xmtp_mls_common/
libxmtp_version.rs

1use thiserror::Error;
2
3/// A version string that is not valid semver.
4#[derive(Debug, Clone, PartialEq, Eq, Error)]
5#[error("Invalid version format: {0}")]
6pub struct InvalidVersionFormat(pub String);
7
8/// Wrapper around [`semver::Version`] used for the
9/// `MIN_SUPPORTED_PROTOCOL_VERSION` floor and related min-version checks.
10///
11/// Delegates parsing and ordering to the [`semver`] crate so behavior
12/// matches the semver 2.0 spec — most importantly:
13///
14/// * Pre-release versions sort *before* the release: `1.0.0-alpha <
15///   1.0.0-beta < 1.0.0`. The previous hand-rolled implementation got
16///   this backwards (`1.0.0 < 1.0.0-alpha`), which would silently
17///   pause clients running release builds against any group floor set
18///   by a caller passing a pre-release string.
19/// * Pre-release identifiers compare numerically when all-digits, so
20///   `rc2 < rc10` instead of lexicographic `rc10 < rc2`.
21/// * Multi-segment pre-release tags like `1.0.0-alpha.1` parse cleanly
22///   instead of failing with `InvalidVersionFormat`.
23/// * Build metadata (after `+`) parses cleanly. Note: the [`semver`]
24///   crate's `Ord` impl deliberately *includes* build metadata for
25///   total-ordering / `Hash` consistency, deviating from semver 2.0
26///   §10 ("build metadata MUST be ignored when determining version
27///   precedence"). Irrelevant in practice — `CARGO_PKG_VERSION` and
28///   the application-facing `update_group_min_version` callers never
29///   pass `+`-suffixed input.
30#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
31pub struct LibXMTPVersion(semver::Version);
32
33impl LibXMTPVersion {
34    pub fn parse(version_str: &str) -> Result<Self, InvalidVersionFormat> {
35        semver::Version::parse(version_str)
36            .map(Self)
37            .map_err(|_| InvalidVersionFormat(version_str.to_string()))
38    }
39
40    /// The parsed form. Spec 006 compares a published minimum against this.
41    pub fn semver(&self) -> &semver::Version {
42        &self.0
43    }
44}
45
46impl std::fmt::Display for LibXMTPVersion {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        std::fmt::Display::fmt(&self.0, f)
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    #[xmtp_common::test(unwrap_try = true)]
57    fn test_parse_and_compare_basic_versions() {
58        let v1_0_0 = LibXMTPVersion::parse("1.0.0").unwrap();
59        let v1_0_1 = LibXMTPVersion::parse("1.0.1").unwrap();
60        let v1_1_0 = LibXMTPVersion::parse("1.1.0").unwrap();
61        let v2_0_0 = LibXMTPVersion::parse("2.0.0").unwrap();
62
63        assert!(v1_0_0 < v1_0_1);
64        assert!(v1_0_1 > v1_0_0);
65
66        assert!(v1_0_1 < v1_1_0);
67        assert!(v1_1_0 > v1_0_1);
68
69        assert!(v1_1_0 < v2_0_0);
70        assert!(v2_0_0 > v1_1_0);
71
72        let v1_0_0_dup = LibXMTPVersion::parse("1.0.0").unwrap();
73        assert_eq!(v1_0_0, v1_0_0_dup);
74    }
75
76    #[xmtp_common::test(unwrap_try = true)]
77    fn test_parse_and_compare_with_suffixes() {
78        let v1_0_0 = LibXMTPVersion::parse("1.0.0").unwrap();
79        let v1_0_0_alpha = LibXMTPVersion::parse("1.0.0-alpha").unwrap();
80        let v1_0_0_beta = LibXMTPVersion::parse("1.0.0-beta").unwrap();
81        let v1_0_0_rc1 = LibXMTPVersion::parse("1.0.0-rc1").unwrap();
82        let v1_0_1_alpha = LibXMTPVersion::parse("1.0.1-alpha").unwrap();
83
84        // Pre-release identifiers compare alphabetically when non-numeric.
85        assert!(v1_0_0_alpha < v1_0_0_beta);
86        assert!(v1_0_0_beta < v1_0_0_rc1);
87
88        // Per semver 2.0 §11: a pre-release version sorts BEFORE the
89        // corresponding release. This is the correctness fix the
90        // semver-crate swap delivers — the old hand-rolled comparison had
91        // the relationship inverted.
92        assert!(v1_0_0_alpha < v1_0_0);
93        assert!(v1_0_0_beta < v1_0_0);
94
95        // Numeric parts take precedence over the pre-release tag.
96        assert!(v1_0_0 < v1_0_1_alpha);
97        assert!(v1_0_0_rc1 < v1_0_1_alpha);
98    }
99
100    #[xmtp_common::test(unwrap_try = true)]
101    fn test_parse_and_compare_zero_versions() {
102        let v0_0_0 = LibXMTPVersion::parse("0.0.0").unwrap();
103        let v0_0_1 = LibXMTPVersion::parse("0.0.1").unwrap();
104        let v0_1_0 = LibXMTPVersion::parse("0.1.0").unwrap();
105        let v1_0_0 = LibXMTPVersion::parse("1.0.0").unwrap();
106
107        assert!(v0_0_0 < v0_0_1);
108        assert!(v0_0_1 < v0_1_0);
109        assert!(v0_1_0 < v1_0_0);
110    }
111
112    // verifies: GMOD-027
113    #[xmtp_common::test(unwrap_try = true)]
114    fn test_numeric_pre_release_identifiers_compare_numerically() {
115        // Per semver 2.0 §11.4.1: identifiers consisting only of digits
116        // are compared numerically. The hand-rolled implementation
117        // compared lexicographically, which got `rc10 < rc2` because of
118        // ASCII ordering; this test pins the fix.
119        let v1_0_0_rc_2 = LibXMTPVersion::parse("1.0.0-rc.2").unwrap();
120        let v1_0_0_rc_10 = LibXMTPVersion::parse("1.0.0-rc.10").unwrap();
121        assert!(v1_0_0_rc_2 < v1_0_0_rc_10);
122
123        let v1_0_0_alpha_2 = LibXMTPVersion::parse("1.0.0-alpha.2").unwrap();
124        let v1_0_0_alpha_11 = LibXMTPVersion::parse("1.0.0-alpha.11").unwrap();
125        assert!(v1_0_0_alpha_2 < v1_0_0_alpha_11);
126    }
127
128    #[xmtp_common::test(unwrap_try = true)]
129    fn test_multi_segment_pre_release_parses() {
130        // Multi-segment pre-release identifiers like `1.0.0-alpha.1` are
131        // valid semver 2.0; the hand-rolled parser rejected them because
132        // it split on `.` first and required exactly three parts.
133        assert!(LibXMTPVersion::parse("1.0.0-alpha.1").is_ok());
134        assert!(LibXMTPVersion::parse("1.0.0-rc.1.build.42").is_ok());
135    }
136
137    #[xmtp_common::test(unwrap_try = true)]
138    fn test_build_metadata_parses() {
139        // Build metadata strings (`+...`) are accepted by the parser. Note
140        // that the [`semver`] crate's `Ord` impl deliberately includes
141        // build metadata for total-ordering / `Hash` consistency, which
142        // deviates from semver 2.0 §10 ("build metadata MUST be ignored
143        // when determining version precedence"). This is irrelevant for
144        // libxmtp's floor comparison because no caller passes a `+`-
145        // suffixed string today — `CARGO_PKG_VERSION` is a plain
146        // `X.Y.Z`, and the application-facing `update_group_min_version`
147        // host API never injects build metadata. Pinning parse-success
148        // here so a future caller that *does* pass `+`-suffixed input
149        // gets predictable behavior instead of `InvalidVersionFormat`.
150        assert!(LibXMTPVersion::parse("1.0.0+build.5").is_ok());
151        assert!(LibXMTPVersion::parse("1.0.0-rc.1+build.5").is_ok());
152    }
153
154    #[xmtp_common::test(unwrap_try = true)]
155    fn test_parse_invalid_format() {
156        for bad in ["1.0", "1.0.0.0", "1.x.0", "a.b.c", "1..0", ""] {
157            assert_eq!(
158                LibXMTPVersion::parse(bad),
159                Err(InvalidVersionFormat(bad.to_string())),
160                "expected {bad:?} to fail parsing"
161            );
162        }
163    }
164
165    /// Creation must not write a floor above the version of the creating client.
166    #[xmtp_common::test(unwrap_try = true)]
167    fn proposals_min_protocol_version_does_not_exceed_workspace_version() {
168        let default_floor =
169            LibXMTPVersion::parse(xmtp_configuration::PROPOSALS_MIN_PROTOCOL_VERSION)
170                .expect("PROPOSALS_MIN_PROTOCOL_VERSION must be valid semver");
171        let workspace = LibXMTPVersion::parse(env!("CARGO_PKG_VERSION"))
172            .expect("CARGO_PKG_VERSION must be valid semver");
173        assert!(
174            default_floor <= workspace,
175            "PROPOSALS_MIN_PROTOCOL_VERSION ({}) must be <= CARGO_PKG_VERSION ({}); \
176             a higher default would pause freshly created groups",
177            xmtp_configuration::PROPOSALS_MIN_PROTOCOL_VERSION,
178            env!("CARGO_PKG_VERSION"),
179        );
180    }
181}