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
18/// Regex for parsing semantic version tags.
19/// Supports:
20/// - Optional prefix: py-, rust-, python-, etc.
21/// - Optional 'v' prefix
22/// - Version: X.Y, X.Y.Z, X.Y.Z.W
23/// - Pre-release: -alpha, -beta.1, -rc.1 (dash style) OR a1, b1, rc1 (Python style)
24/// - Build metadata: +build.123
25static SEMVER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
26    Regex::new(
27        r"^(?:[a-z]+-)?v?(\d+)\.(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(?:-([a-zA-Z0-9.-]+))|(?:([a-z]+)(\d+)))?(?:\+(.+))?$"
28    )
29    .expect("Invalid semver regex")
30});
31
32/// Parse a semantic version string
33/// Supports:
34/// - 2-part: 1.0
35/// - 3-part: 1.2.3
36/// - 4-part: 1.2.3.4
37/// - Pre-release: 1.0.0-alpha, 1.0.0-rc.1, 1.0.0-beta.1
38/// - Python-style pre-release: 1.2.3a1, 1.2.3b1, 1.2.3rc1
39/// - Build metadata: 1.0.0+build.123
40/// - With or without 'v' prefix (e.g., v1.0.0)
41/// - With custom prefixes (e.g., py-v1.0.0, rust-v1.0.0, python-1.0.0)
42pub fn parse_semver(tag: &str) -> Option<SemverInfo> {
43    let caps = SEMVER_REGEX.captures(tag)?;
44
45    let major = caps.get(1)?.as_str().parse::<u32>().ok()?;
46    let minor = caps.get(2)?.as_str().parse::<u32>().ok()?;
47    let patch = caps.get(3).and_then(|m| m.as_str().parse::<u32>().ok());
48    let build = caps.get(4).and_then(|m| m.as_str().parse::<u32>().ok());
49
50    // Pre-release can be either:
51    // - Group 5: dash-style (-alpha, -beta.1, -rc.1)
52    // - Groups 6+7: Python-style (a1, b1, rc1)
53    let pre_release = caps.get(5).map_or_else(
54        || {
55            caps.get(6).map(|py_pre| {
56                let py_num = caps
57                    .get(7)
58                    .map_or(String::new(), |m| m.as_str().to_string());
59                format!("{}{}", py_pre.as_str(), py_num)
60            })
61        },
62        |dash_pre| Some(dash_pre.as_str().to_string()),
63    );
64
65    let build_metadata = caps.get(8).map(|m| m.as_str().to_string());
66
67    Some(SemverInfo {
68        major,
69        minor,
70        patch,
71        build,
72        pre_release,
73        build_metadata,
74    })
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    /// Check if a tag name is a semantic version
82    fn is_semver_tag(tag: &str) -> bool {
83        parse_semver(tag).is_some()
84    }
85
86    #[test]
87    fn test_parse_semver_2_part() {
88        let result = parse_semver("1.0");
89        assert!(result.is_some());
90        let semver = result.unwrap();
91        assert_eq!(semver.major, 1);
92        assert_eq!(semver.minor, 0);
93        assert_eq!(semver.patch, None);
94        assert_eq!(semver.build, None);
95    }
96
97    #[test]
98    fn test_parse_semver_2_part_with_v_prefix() {
99        let result = parse_semver("v2.1");
100        assert!(result.is_some());
101        let semver = result.unwrap();
102        assert_eq!(semver.major, 2);
103        assert_eq!(semver.minor, 1);
104    }
105
106    #[test]
107    fn test_parse_semver_3_part() {
108        let result = parse_semver("1.2.3");
109        assert!(result.is_some());
110        let semver = result.unwrap();
111        assert_eq!(semver.major, 1);
112        assert_eq!(semver.minor, 2);
113        assert_eq!(semver.patch, Some(3));
114        assert_eq!(semver.build, None);
115    }
116
117    #[test]
118    fn test_parse_semver_3_part_with_v_prefix() {
119        let result = parse_semver("v1.2.3");
120        assert!(result.is_some());
121        let semver = result.unwrap();
122        assert_eq!(semver.major, 1);
123        assert_eq!(semver.minor, 2);
124        assert_eq!(semver.patch, Some(3));
125    }
126
127    #[test]
128    fn test_parse_semver_4_part() {
129        let result = parse_semver("1.2.3.4");
130        assert!(result.is_some());
131        let semver = result.unwrap();
132        assert_eq!(semver.major, 1);
133        assert_eq!(semver.minor, 2);
134        assert_eq!(semver.patch, Some(3));
135        assert_eq!(semver.build, Some(4));
136    }
137
138    #[test]
139    fn test_parse_semver_with_pre_release() {
140        let result = parse_semver("1.0.0-alpha");
141        assert!(result.is_some());
142        let semver = result.unwrap();
143        assert_eq!(semver.major, 1);
144        assert_eq!(semver.minor, 0);
145        assert_eq!(semver.patch, Some(0));
146        assert_eq!(semver.pre_release, Some("alpha".to_string()));
147    }
148
149    #[test]
150    fn test_parse_semver_with_pre_release_numeric() {
151        let result = parse_semver("v2.0.0-rc.1");
152        assert!(result.is_some());
153        let semver = result.unwrap();
154        assert_eq!(semver.major, 2);
155        assert_eq!(semver.minor, 0);
156        assert_eq!(semver.patch, Some(0));
157        assert_eq!(semver.pre_release, Some("rc.1".to_string()));
158    }
159
160    #[test]
161    fn test_parse_semver_with_build_metadata() {
162        let result = parse_semver("1.0.0+build.123");
163        assert!(result.is_some());
164        let semver = result.unwrap();
165        assert_eq!(semver.major, 1);
166        assert_eq!(semver.minor, 0);
167        assert_eq!(semver.patch, Some(0));
168        assert_eq!(semver.build_metadata, Some("build.123".to_string()));
169    }
170
171    #[test]
172    fn test_parse_semver_with_pre_release_and_build() {
173        let result = parse_semver("v1.0.0-beta.2+20130313144700");
174        assert!(result.is_some());
175        let semver = result.unwrap();
176        assert_eq!(semver.major, 1);
177        assert_eq!(semver.minor, 0);
178        assert_eq!(semver.patch, Some(0));
179        assert_eq!(semver.pre_release, Some("beta.2".to_string()));
180        assert_eq!(semver.build_metadata, Some("20130313144700".to_string()));
181    }
182
183    #[test]
184    fn test_parse_semver_2_part_with_pre_release() {
185        let result = parse_semver("2.0-alpha");
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, None);
191        assert_eq!(semver.pre_release, Some("alpha".to_string()));
192    }
193
194    #[test]
195    fn test_parse_semver_invalid_single_part() {
196        assert!(parse_semver("1").is_none());
197    }
198
199    #[test]
200    fn test_parse_semver_invalid_non_numeric() {
201        assert!(parse_semver("abc.def").is_none());
202        assert!(parse_semver("1.x.3").is_none());
203    }
204
205    #[test]
206    fn test_parse_semver_invalid_too_many_parts() {
207        assert!(parse_semver("1.2.3.4.5").is_none());
208    }
209
210    #[test]
211    fn test_is_semver_tag() {
212        // Basic versions
213        assert!(is_semver_tag("1.0"));
214        assert!(is_semver_tag("v1.0"));
215        assert!(is_semver_tag("1.2.3"));
216        assert!(is_semver_tag("v1.2.3"));
217        assert!(is_semver_tag("1.2.3.4"));
218
219        // Pre-release versions
220        assert!(is_semver_tag("1.0.0-alpha"));
221        assert!(is_semver_tag("v2.0.0-rc.1"));
222        assert!(is_semver_tag("1.2.3-beta.2"));
223
224        // Python-style pre-release
225        assert!(is_semver_tag("1.2.3a1"));
226        assert!(is_semver_tag("1.2.3b1"));
227        assert!(is_semver_tag("1.2.3rc1"));
228
229        // Build metadata
230        assert!(is_semver_tag("1.0.0+build"));
231
232        // Custom prefixes
233        assert!(is_semver_tag("py-v1.0.0"));
234        assert!(is_semver_tag("rust-v1.2.3-beta.1"));
235        assert!(is_semver_tag("python-1.2.3b1"));
236
237        // Invalid
238        assert!(!is_semver_tag("v1"));
239        assert!(!is_semver_tag("abc"));
240        assert!(!is_semver_tag("1.2.3.4.5"));
241        assert!(!is_semver_tag("server-v-1.0.0")); // Double dash should fail
242    }
243
244    #[test]
245    fn test_parse_semver_with_custom_prefix() {
246        // Test py-v prefix
247        let result = parse_semver("py-v1.0.0-beta.1");
248        assert!(result.is_some());
249        let semver = result.unwrap();
250        assert_eq!(semver.major, 1);
251        assert_eq!(semver.minor, 0);
252        assert_eq!(semver.patch, Some(0));
253        assert_eq!(semver.pre_release, Some("beta.1".to_string()));
254
255        // Test rust-v prefix
256        let result = parse_semver("rust-v1.0.0-beta.2");
257        assert!(result.is_some());
258        let semver = result.unwrap();
259        assert_eq!(semver.major, 1);
260        assert_eq!(semver.minor, 0);
261        assert_eq!(semver.patch, Some(0));
262        assert_eq!(semver.pre_release, Some("beta.2".to_string()));
263
264        // Test prefix without v
265        let result = parse_semver("python-2.1.0");
266        assert!(result.is_some());
267        let semver = result.unwrap();
268        assert_eq!(semver.major, 2);
269        assert_eq!(semver.minor, 1);
270        assert_eq!(semver.patch, Some(0));
271    }
272
273    #[test]
274    fn test_parse_semver_python_style() {
275        // Alpha
276        let result = parse_semver("1.2.3a1");
277        assert!(result.is_some());
278        let semver = result.unwrap();
279        assert_eq!(semver.major, 1);
280        assert_eq!(semver.minor, 2);
281        assert_eq!(semver.patch, Some(3));
282        assert_eq!(semver.pre_release, Some("a1".to_string()));
283
284        // Beta
285        let result = parse_semver("v1.2.3b2");
286        assert!(result.is_some());
287        let semver = result.unwrap();
288        assert_eq!(semver.major, 1);
289        assert_eq!(semver.minor, 2);
290        assert_eq!(semver.patch, Some(3));
291        assert_eq!(semver.pre_release, Some("b2".to_string()));
292
293        // Release candidate
294        let result = parse_semver("2.0.0rc1");
295        assert!(result.is_some());
296        let semver = result.unwrap();
297        assert_eq!(semver.major, 2);
298        assert_eq!(semver.minor, 0);
299        assert_eq!(semver.patch, Some(0));
300        assert_eq!(semver.pre_release, Some("rc1".to_string()));
301
302        // With prefix
303        let result = parse_semver("py-v1.0.0b1");
304        assert!(result.is_some());
305        let semver = result.unwrap();
306        assert_eq!(semver.major, 1);
307        assert_eq!(semver.minor, 0);
308        assert_eq!(semver.patch, Some(0));
309        assert_eq!(semver.pre_release, Some("b1".to_string()));
310    }
311
312    #[test]
313    fn test_parse_semver_rejects_garbage() {
314        // Should reject random strings with -v in them
315        assert!(parse_semver("server-v-config").is_none());
316        assert!(parse_semver("whatever-v-something").is_none());
317
318        // Should reject malformed versions
319        assert!(parse_semver("v1").is_none());
320        assert!(parse_semver("1").is_none());
321        assert!(parse_semver("1.2.3.4.5").is_none());
322        assert!(parse_semver("abc.def").is_none());
323    }
324}