Skip to main content

wtg_cli/
semver.rs

1//! Semantic version parsing for git tags.
2
3use std::sync::LazyLock;
4
5use regex::Regex;
6
7/// Parsed semantic version information from a tag.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct SemverInfo {
10    pub major: u32,
11    pub minor: u32,
12    pub patch: Option<u32>,
13    pub build: Option<u32>,
14    pub pre_release: Option<String>,
15    pub build_metadata: Option<String>,
16}
17
18impl Ord for SemverInfo {
19    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
20        match self.major.cmp(&other.major) {
21            std::cmp::Ordering::Equal => {}
22            ord => return ord,
23        }
24        match self.minor.cmp(&other.minor) {
25            std::cmp::Ordering::Equal => {}
26            ord => return ord,
27        }
28        match self.patch.cmp(&other.patch) {
29            std::cmp::Ordering::Equal => {}
30            ord => return ord,
31        }
32        match self.build.cmp(&other.build) {
33            std::cmp::Ordering::Equal => {}
34            ord => return ord,
35        }
36        // Pre-release: None (stable) > Some (pre-release)
37        match (&self.pre_release, &other.pre_release) {
38            (None, Some(_)) => std::cmp::Ordering::Greater,
39            (Some(_), None) => std::cmp::Ordering::Less,
40            (Some(a), Some(b)) => a.cmp(b),
41            (None, None) => std::cmp::Ordering::Equal,
42        }
43    }
44}
45
46impl PartialOrd for SemverInfo {
47    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
48        Some(self.cmp(other))
49    }
50}
51
52/// Regex for parsing semantic version tags.
53/// Supports:
54/// - Optional prefix: py-, rust-, python-, etc.
55/// - Optional 'v' prefix
56/// - Version: X.Y, X.Y.Z, X.Y.Z.W
57/// - Pre-release: -alpha, -beta.1, -rc.1 (dash style) OR a1, b1, rc1 (Python style)
58/// - Build metadata: +build.123
59static SEMVER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
60    Regex::new(
61        r"^(?:[a-z]+-)?v?(\d+)\.(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(?:-([a-zA-Z0-9.-]+))|(?:([a-z]+)(\d+)))?(?:\+(.+))?$"
62    )
63    .expect("Invalid semver regex")
64});
65
66/// Parse a semantic version string
67/// Supports:
68/// - 2-part: 1.0
69/// - 3-part: 1.2.3
70/// - 4-part: 1.2.3.4
71/// - Pre-release: 1.0.0-alpha, 1.0.0-rc.1, 1.0.0-beta.1
72/// - Python-style pre-release: 1.2.3a1, 1.2.3b1, 1.2.3rc1
73/// - Build metadata: 1.0.0+build.123
74/// - With or without 'v' prefix (e.g., v1.0.0)
75/// - With custom prefixes (e.g., py-v1.0.0, rust-v1.0.0, python-1.0.0)
76pub fn parse_semver(tag: &str) -> Option<SemverInfo> {
77    let caps = SEMVER_REGEX.captures(tag)?;
78
79    let major = caps.get(1)?.as_str().parse::<u32>().ok()?;
80    let minor = caps.get(2)?.as_str().parse::<u32>().ok()?;
81    let patch = caps.get(3).and_then(|m| m.as_str().parse::<u32>().ok());
82    let build = caps.get(4).and_then(|m| m.as_str().parse::<u32>().ok());
83
84    // Pre-release can be either:
85    // - Group 5: dash-style (-alpha, -beta.1, -rc.1)
86    // - Groups 6+7: Python-style (a1, b1, rc1)
87    let pre_release = caps.get(5).map_or_else(
88        || {
89            caps.get(6).map(|py_pre| {
90                let py_num = caps
91                    .get(7)
92                    .map_or(String::new(), |m| m.as_str().to_string());
93                format!("{}{}", py_pre.as_str(), py_num)
94            })
95        },
96        |dash_pre| Some(dash_pre.as_str().to_string()),
97    );
98
99    let build_metadata = caps.get(8).map(|m| m.as_str().to_string());
100
101    Some(SemverInfo {
102        major,
103        minor,
104        patch,
105        build,
106        pre_release,
107        build_metadata,
108    })
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    /// Check if a tag name is a semantic version
116    fn is_semver_tag(tag: &str) -> bool {
117        parse_semver(tag).is_some()
118    }
119
120    #[test]
121    fn test_parse_semver_2_part() {
122        let result = parse_semver("1.0");
123        assert!(result.is_some());
124        let semver = result.unwrap();
125        assert_eq!(semver.major, 1);
126        assert_eq!(semver.minor, 0);
127        assert_eq!(semver.patch, None);
128        assert_eq!(semver.build, None);
129    }
130
131    #[test]
132    fn test_parse_semver_2_part_with_v_prefix() {
133        let result = parse_semver("v2.1");
134        assert!(result.is_some());
135        let semver = result.unwrap();
136        assert_eq!(semver.major, 2);
137        assert_eq!(semver.minor, 1);
138    }
139
140    #[test]
141    fn test_parse_semver_3_part() {
142        let result = parse_semver("1.2.3");
143        assert!(result.is_some());
144        let semver = result.unwrap();
145        assert_eq!(semver.major, 1);
146        assert_eq!(semver.minor, 2);
147        assert_eq!(semver.patch, Some(3));
148        assert_eq!(semver.build, None);
149    }
150
151    #[test]
152    fn test_parse_semver_3_part_with_v_prefix() {
153        let result = parse_semver("v1.2.3");
154        assert!(result.is_some());
155        let semver = result.unwrap();
156        assert_eq!(semver.major, 1);
157        assert_eq!(semver.minor, 2);
158        assert_eq!(semver.patch, Some(3));
159    }
160
161    #[test]
162    fn test_parse_semver_4_part() {
163        let result = parse_semver("1.2.3.4");
164        assert!(result.is_some());
165        let semver = result.unwrap();
166        assert_eq!(semver.major, 1);
167        assert_eq!(semver.minor, 2);
168        assert_eq!(semver.patch, Some(3));
169        assert_eq!(semver.build, Some(4));
170    }
171
172    #[test]
173    fn test_parse_semver_with_pre_release() {
174        let result = parse_semver("1.0.0-alpha");
175        assert!(result.is_some());
176        let semver = result.unwrap();
177        assert_eq!(semver.major, 1);
178        assert_eq!(semver.minor, 0);
179        assert_eq!(semver.patch, Some(0));
180        assert_eq!(semver.pre_release, Some("alpha".to_string()));
181    }
182
183    #[test]
184    fn test_parse_semver_with_pre_release_numeric() {
185        let result = parse_semver("v2.0.0-rc.1");
186        assert!(result.is_some());
187        let semver = result.unwrap();
188        assert_eq!(semver.major, 2);
189        assert_eq!(semver.minor, 0);
190        assert_eq!(semver.patch, Some(0));
191        assert_eq!(semver.pre_release, Some("rc.1".to_string()));
192    }
193
194    #[test]
195    fn test_parse_semver_with_build_metadata() {
196        let result = parse_semver("1.0.0+build.123");
197        assert!(result.is_some());
198        let semver = result.unwrap();
199        assert_eq!(semver.major, 1);
200        assert_eq!(semver.minor, 0);
201        assert_eq!(semver.patch, Some(0));
202        assert_eq!(semver.build_metadata, Some("build.123".to_string()));
203    }
204
205    #[test]
206    fn test_parse_semver_with_pre_release_and_build() {
207        let result = parse_semver("v1.0.0-beta.2+20130313144700");
208        assert!(result.is_some());
209        let semver = result.unwrap();
210        assert_eq!(semver.major, 1);
211        assert_eq!(semver.minor, 0);
212        assert_eq!(semver.patch, Some(0));
213        assert_eq!(semver.pre_release, Some("beta.2".to_string()));
214        assert_eq!(semver.build_metadata, Some("20130313144700".to_string()));
215    }
216
217    #[test]
218    fn test_parse_semver_2_part_with_pre_release() {
219        let result = parse_semver("2.0-alpha");
220        assert!(result.is_some());
221        let semver = result.unwrap();
222        assert_eq!(semver.major, 2);
223        assert_eq!(semver.minor, 0);
224        assert_eq!(semver.patch, None);
225        assert_eq!(semver.pre_release, Some("alpha".to_string()));
226    }
227
228    #[test]
229    fn test_parse_semver_invalid_single_part() {
230        assert!(parse_semver("1").is_none());
231    }
232
233    #[test]
234    fn test_parse_semver_invalid_non_numeric() {
235        assert!(parse_semver("abc.def").is_none());
236        assert!(parse_semver("1.x.3").is_none());
237    }
238
239    #[test]
240    fn test_parse_semver_invalid_too_many_parts() {
241        assert!(parse_semver("1.2.3.4.5").is_none());
242    }
243
244    #[test]
245    fn test_is_semver_tag() {
246        // Basic versions
247        assert!(is_semver_tag("1.0"));
248        assert!(is_semver_tag("v1.0"));
249        assert!(is_semver_tag("1.2.3"));
250        assert!(is_semver_tag("v1.2.3"));
251        assert!(is_semver_tag("1.2.3.4"));
252
253        // Pre-release versions
254        assert!(is_semver_tag("1.0.0-alpha"));
255        assert!(is_semver_tag("v2.0.0-rc.1"));
256        assert!(is_semver_tag("1.2.3-beta.2"));
257
258        // Python-style pre-release
259        assert!(is_semver_tag("1.2.3a1"));
260        assert!(is_semver_tag("1.2.3b1"));
261        assert!(is_semver_tag("1.2.3rc1"));
262
263        // Build metadata
264        assert!(is_semver_tag("1.0.0+build"));
265
266        // Custom prefixes
267        assert!(is_semver_tag("py-v1.0.0"));
268        assert!(is_semver_tag("rust-v1.2.3-beta.1"));
269        assert!(is_semver_tag("python-1.2.3b1"));
270
271        // Invalid
272        assert!(!is_semver_tag("v1"));
273        assert!(!is_semver_tag("abc"));
274        assert!(!is_semver_tag("1.2.3.4.5"));
275        assert!(!is_semver_tag("server-v-1.0.0")); // Double dash should fail
276    }
277
278    #[test]
279    fn test_parse_semver_with_custom_prefix() {
280        // Test py-v prefix
281        let result = parse_semver("py-v1.0.0-beta.1");
282        assert!(result.is_some());
283        let semver = result.unwrap();
284        assert_eq!(semver.major, 1);
285        assert_eq!(semver.minor, 0);
286        assert_eq!(semver.patch, Some(0));
287        assert_eq!(semver.pre_release, Some("beta.1".to_string()));
288
289        // Test rust-v prefix
290        let result = parse_semver("rust-v1.0.0-beta.2");
291        assert!(result.is_some());
292        let semver = result.unwrap();
293        assert_eq!(semver.major, 1);
294        assert_eq!(semver.minor, 0);
295        assert_eq!(semver.patch, Some(0));
296        assert_eq!(semver.pre_release, Some("beta.2".to_string()));
297
298        // Test prefix without v
299        let result = parse_semver("python-2.1.0");
300        assert!(result.is_some());
301        let semver = result.unwrap();
302        assert_eq!(semver.major, 2);
303        assert_eq!(semver.minor, 1);
304        assert_eq!(semver.patch, Some(0));
305    }
306
307    #[test]
308    fn test_parse_semver_python_style() {
309        // Alpha
310        let result = parse_semver("1.2.3a1");
311        assert!(result.is_some());
312        let semver = result.unwrap();
313        assert_eq!(semver.major, 1);
314        assert_eq!(semver.minor, 2);
315        assert_eq!(semver.patch, Some(3));
316        assert_eq!(semver.pre_release, Some("a1".to_string()));
317
318        // Beta
319        let result = parse_semver("v1.2.3b2");
320        assert!(result.is_some());
321        let semver = result.unwrap();
322        assert_eq!(semver.major, 1);
323        assert_eq!(semver.minor, 2);
324        assert_eq!(semver.patch, Some(3));
325        assert_eq!(semver.pre_release, Some("b2".to_string()));
326
327        // Release candidate
328        let result = parse_semver("2.0.0rc1");
329        assert!(result.is_some());
330        let semver = result.unwrap();
331        assert_eq!(semver.major, 2);
332        assert_eq!(semver.minor, 0);
333        assert_eq!(semver.patch, Some(0));
334        assert_eq!(semver.pre_release, Some("rc1".to_string()));
335
336        // With prefix
337        let result = parse_semver("py-v1.0.0b1");
338        assert!(result.is_some());
339        let semver = result.unwrap();
340        assert_eq!(semver.major, 1);
341        assert_eq!(semver.minor, 0);
342        assert_eq!(semver.patch, Some(0));
343        assert_eq!(semver.pre_release, Some("b1".to_string()));
344    }
345
346    #[test]
347    fn test_parse_semver_rejects_garbage() {
348        // Should reject random strings with -v in them
349        assert!(parse_semver("server-v-config").is_none());
350        assert!(parse_semver("whatever-v-something").is_none());
351
352        // Should reject malformed versions
353        assert!(parse_semver("v1").is_none());
354        assert!(parse_semver("1").is_none());
355        assert!(parse_semver("1.2.3.4.5").is_none());
356        assert!(parse_semver("abc.def").is_none());
357    }
358
359    #[test]
360    fn test_semver_ordering() {
361        let v1_0_0 = parse_semver("1.0.0").unwrap();
362        let v1_0_1 = parse_semver("1.0.1").unwrap();
363        let v1_0_0_alpha = parse_semver("1.0.0-alpha").unwrap();
364        let v1_0_0_beta = parse_semver("1.0.0-beta").unwrap();
365
366        // Pre-release versions compare lexicographically
367        assert!(v1_0_0_alpha < v1_0_0_beta);
368        // Pre-release versions are less than stable versions
369        assert!(v1_0_0_beta < v1_0_0);
370        // Patch versions compare correctly
371        assert!(v1_0_0 < v1_0_1);
372    }
373
374    #[test]
375    fn test_semver_ordering_with_build() {
376        let v1_2_3_4 = parse_semver("1.2.3.4").unwrap();
377        let v1_2_3_5 = parse_semver("1.2.3.5").unwrap();
378        let v1_2_3 = parse_semver("1.2.3").unwrap();
379
380        // Build numbers compare correctly
381        assert!(v1_2_3_4 < v1_2_3_5);
382        // None build is less than Some build (None < Some in Option ordering)
383        assert!(v1_2_3 < v1_2_3_4);
384    }
385}