Skip to main content

hyperdb_api/
server_version.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Server version parsing and comparison.
5//!
6//! Provides a [`ServerVersion`] struct for parsing and comparing Hyper server
7//! version strings. This enables feature detection based on server capabilities.
8//!
9//! # Example
10//!
11//! ```
12//! use hyperdb_api::ServerVersion;
13//!
14//! let v = ServerVersion::parse("0.0.19038").unwrap();
15//! assert_eq!(v.major(), 0);
16//! assert_eq!(v.minor(), 0);
17//! assert_eq!(v.patch(), 19038);
18//! assert!(v >= ServerVersion::new(0, 0, 19000));
19//! ```
20
21use std::fmt;
22
23/// A parsed server version with comparison support.
24///
25/// Hyper server versions typically follow the format `major.minor.patch`,
26/// sometimes with additional build metadata (e.g., `0.0.19038.r12345`).
27///
28/// # Ordering
29///
30/// Versions are compared lexicographically by (major, minor, patch).
31/// The `suffix` field is ignored for comparison and ordering.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ServerVersion {
34    major: u32,
35    minor: u32,
36    patch: u32,
37    /// Optional suffix after the version numbers (e.g., "r12345", "beta1").
38    suffix: Option<String>,
39    /// The original version string.
40    raw: String,
41}
42
43impl ServerVersion {
44    /// Creates a new `ServerVersion` from components.
45    #[must_use]
46    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
47        ServerVersion {
48            major,
49            minor,
50            patch,
51            suffix: None,
52            raw: format!("{major}.{minor}.{patch}"),
53        }
54    }
55
56    /// Parses a version string like "0.0.19038" or "1.2.3.r456".
57    ///
58    /// Accepts various production formats:
59    /// - `"0.0.19038"` — standard dotted numeric
60    /// - `"1.2.3.r456"` — dot-separated suffix
61    /// - `"1.2.3-beta1"` — hyphen-separated pre-release
62    /// - `"v1.2.3"` — optional `v`/`V` prefix
63    /// - `"1.2"` — patch defaults to 0
64    /// - `"  1.2.3  "` — leading/trailing whitespace is trimmed
65    ///
66    /// Returns `None` if the string doesn't contain at least two numeric
67    /// components separated by a dot.
68    #[must_use]
69    pub fn parse(s: &str) -> Option<Self> {
70        let trimmed = s.trim();
71        // Strip optional 'v' or 'V' prefix
72        let trimmed = trimmed
73            .strip_prefix('v')
74            .or_else(|| trimmed.strip_prefix('V'))
75            .unwrap_or(trimmed);
76
77        let mut parts = trimmed.splitn(4, '.');
78
79        let major: u32 = parts.next()?.parse().ok()?;
80        let minor: u32 = parts.next()?.parse().ok()?;
81
82        // Patch is optional (default 0)
83        let (patch, suffix) = if let Some(patch_str) = parts.next() {
84            // The patch component may contain non-numeric trailing text,
85            // e.g. "3-beta1" or "19038rc1". Extract leading digits as
86            // the patch number and treat the rest as suffix.
87            let num_end = patch_str
88                .find(|c: char| !c.is_ascii_digit())
89                .unwrap_or(patch_str.len());
90            if num_end == 0 {
91                // No leading digits at all (e.g. "abc") — not a valid patch
92                return None;
93            }
94            let patch: u32 = patch_str[..num_end].parse().ok()?;
95            // Build suffix from any trailing text in the patch component
96            // plus any remaining dot-separated part.
97            let patch_tail = &patch_str[num_end..];
98            let dot_tail = parts.next();
99            let suffix = match (patch_tail.is_empty(), dot_tail) {
100                (true, None) => None,
101                (true, Some(t)) => Some(t.to_string()),
102                (false, None) => Some(patch_tail.to_string()),
103                (false, Some(t)) => Some(format!("{patch_tail}.{t}")),
104            };
105            (patch, suffix)
106        } else {
107            (0, None)
108        };
109
110        Some(ServerVersion {
111            major,
112            minor,
113            patch,
114            suffix,
115            raw: s.trim().to_string(),
116        })
117    }
118
119    /// Returns the major version number.
120    #[must_use]
121    pub fn major(&self) -> u32 {
122        self.major
123    }
124
125    /// Returns the minor version number.
126    #[must_use]
127    pub fn minor(&self) -> u32 {
128        self.minor
129    }
130
131    /// Returns the patch version number.
132    #[must_use]
133    pub fn patch(&self) -> u32 {
134        self.patch
135    }
136
137    /// Returns the optional suffix (e.g., "r12345").
138    #[must_use]
139    pub fn suffix(&self) -> Option<&str> {
140        self.suffix.as_deref()
141    }
142
143    /// Returns the original version string.
144    #[must_use]
145    pub fn raw(&self) -> &str {
146        &self.raw
147    }
148}
149
150impl PartialOrd for ServerVersion {
151    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
152        Some(self.cmp(other))
153    }
154}
155
156impl Ord for ServerVersion {
157    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
158        (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
159    }
160}
161
162impl fmt::Display for ServerVersion {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        write!(f, "{}", self.raw)
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_parse_basic() {
174        let v = ServerVersion::parse("0.0.19038").unwrap();
175        assert_eq!(v.major(), 0);
176        assert_eq!(v.minor(), 0);
177        assert_eq!(v.patch(), 19038);
178        assert_eq!(v.suffix(), None);
179    }
180
181    #[test]
182    fn test_parse_with_suffix() {
183        let v = ServerVersion::parse("1.2.3.r456").unwrap();
184        assert_eq!(v.major(), 1);
185        assert_eq!(v.minor(), 2);
186        assert_eq!(v.patch(), 3);
187        assert_eq!(v.suffix(), Some("r456"));
188    }
189
190    #[test]
191    fn test_parse_two_parts() {
192        let v = ServerVersion::parse("1.2").unwrap();
193        assert_eq!(v.major(), 1);
194        assert_eq!(v.minor(), 2);
195        assert_eq!(v.patch(), 0);
196    }
197
198    #[test]
199    fn test_parse_invalid() {
200        assert!(ServerVersion::parse("").is_none());
201        assert!(ServerVersion::parse("abc").is_none());
202        assert!(ServerVersion::parse("1").is_none());
203        assert!(ServerVersion::parse("1.2.abc").is_none());
204        assert!(ServerVersion::parse("vabc").is_none());
205        assert!(ServerVersion::parse("v").is_none());
206    }
207
208    #[test]
209    fn test_parse_v_prefix() {
210        let v = ServerVersion::parse("v1.2.3").unwrap();
211        assert_eq!(v.major(), 1);
212        assert_eq!(v.minor(), 2);
213        assert_eq!(v.patch(), 3);
214        assert_eq!(v.suffix(), None);
215    }
216
217    #[test]
218    fn test_parse_uppercase_v_prefix() {
219        let v = ServerVersion::parse("V1.2.3").unwrap();
220        assert_eq!(v.major(), 1);
221        assert_eq!(v.patch(), 3);
222    }
223
224    #[test]
225    fn test_parse_hyphen_prerelease() {
226        let v = ServerVersion::parse("1.2.3-beta1").unwrap();
227        assert_eq!(v.major(), 1);
228        assert_eq!(v.minor(), 2);
229        assert_eq!(v.patch(), 3);
230        assert_eq!(v.suffix(), Some("-beta1"));
231    }
232
233    #[test]
234    fn test_parse_patch_with_rc_suffix() {
235        let v = ServerVersion::parse("0.0.19038rc1").unwrap();
236        assert_eq!(v.patch(), 19038);
237        assert_eq!(v.suffix(), Some("rc1"));
238    }
239
240    #[test]
241    fn test_parse_hyphen_prerelease_with_dot_suffix() {
242        let v = ServerVersion::parse("1.2.3-beta.1").unwrap();
243        assert_eq!(v.patch(), 3);
244        // "-beta" from patch tail, ".1" from dot tail
245        assert_eq!(v.suffix(), Some("-beta.1"));
246    }
247
248    #[test]
249    fn test_parse_whitespace() {
250        let v = ServerVersion::parse("  1.2.3  ").unwrap();
251        assert_eq!(v.major(), 1);
252        assert_eq!(v.patch(), 3);
253    }
254
255    #[test]
256    fn test_comparison() {
257        let v1 = ServerVersion::new(1, 0, 0);
258        let v2 = ServerVersion::new(1, 0, 1);
259        let v3 = ServerVersion::new(1, 1, 0);
260        let v4 = ServerVersion::new(2, 0, 0);
261
262        assert!(v1 < v2);
263        assert!(v2 < v3);
264        assert!(v3 < v4);
265        assert!(v1 == ServerVersion::new(1, 0, 0));
266    }
267
268    #[test]
269    fn test_comparison_ignores_suffix() {
270        let v1 = ServerVersion::parse("1.2.3").unwrap();
271        let v2 = ServerVersion::parse("1.2.3-beta1").unwrap();
272        // PartialEq compares all fields, but Ord ignores suffix
273        assert_eq!(v1.cmp(&v2), std::cmp::Ordering::Equal);
274        assert!(v1 >= v2);
275        assert!(v2 >= v1);
276    }
277
278    #[test]
279    fn test_display() {
280        let v = ServerVersion::parse("0.0.19038").unwrap();
281        assert_eq!(format!("{v}"), "0.0.19038");
282    }
283
284    #[test]
285    fn test_display_preserves_original() {
286        // v-prefix is preserved in raw/display
287        let v = ServerVersion::parse("v1.2.3").unwrap();
288        assert_eq!(format!("{v}"), "v1.2.3");
289    }
290}