Skip to main content

pro_core/semver/
version.rs

1//! SemVer version parsing and manipulation
2
3use std::cmp::Ordering;
4use std::fmt;
5use std::str::FromStr;
6
7use serde::{Deserialize, Serialize};
8
9use crate::Error;
10
11/// A semantic version number (MAJOR.MINOR.PATCH[-prerelease][+build])
12#[derive(Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
13pub struct Version {
14    /// Major version (breaking changes)
15    pub major: u64,
16    /// Minor version (new features, backwards compatible)
17    pub minor: u64,
18    /// Patch version (bug fixes, backwards compatible)
19    pub patch: u64,
20    /// Pre-release identifier (e.g., alpha, beta, rc.1)
21    pub pre: Prerelease,
22    /// Build metadata (ignored in version precedence)
23    pub build: BuildMetadata,
24}
25
26impl Version {
27    /// Create a new version
28    pub const fn new(major: u64, minor: u64, patch: u64) -> Self {
29        Self {
30            major,
31            minor,
32            patch,
33            pre: Prerelease::EMPTY,
34            build: BuildMetadata::EMPTY,
35        }
36    }
37
38    /// Parse a version string
39    pub fn parse(text: &str) -> Result<Self, Error> {
40        let text = text.trim();
41
42        // Strip leading 'v' or 'V' if present
43        let text = text
44            .strip_prefix('v')
45            .or_else(|| text.strip_prefix('V'))
46            .unwrap_or(text);
47
48        if text.is_empty() {
49            return Err(Error::InvalidVersion("empty version string".to_string()));
50        }
51
52        // Split off build metadata first (+...)
53        let (version_pre, build) = match text.find('+') {
54            Some(pos) => {
55                let build = BuildMetadata::new(&text[pos + 1..])?;
56                (&text[..pos], build)
57            }
58            None => (text, BuildMetadata::EMPTY),
59        };
60
61        // Split off prerelease (-...)
62        let (version, pre) = match version_pre.find('-') {
63            Some(pos) => {
64                let pre = Prerelease::new(&version_pre[pos + 1..])?;
65                (&version_pre[..pos], pre)
66            }
67            None => (version_pre, Prerelease::EMPTY),
68        };
69
70        // Parse major.minor.patch
71        let mut parts = version.split('.');
72
73        let major = parts
74            .next()
75            .ok_or_else(|| Error::InvalidVersion("missing major version".to_string()))?
76            .parse::<u64>()
77            .map_err(|_| Error::InvalidVersion("invalid major version".to_string()))?;
78
79        let minor = parts
80            .next()
81            .map(|s| s.parse::<u64>())
82            .transpose()
83            .map_err(|_| Error::InvalidVersion("invalid minor version".to_string()))?
84            .unwrap_or(0);
85
86        let patch = parts
87            .next()
88            .map(|s| s.parse::<u64>())
89            .transpose()
90            .map_err(|_| Error::InvalidVersion("invalid patch version".to_string()))?
91            .unwrap_or(0);
92
93        // Reject extra parts
94        if parts.next().is_some() {
95            return Err(Error::InvalidVersion("too many version parts".to_string()));
96        }
97
98        Ok(Self {
99            major,
100            minor,
101            patch,
102            pre,
103            build,
104        })
105    }
106
107    /// Check if this is a prerelease version
108    pub fn is_prerelease(&self) -> bool {
109        !self.pre.is_empty()
110    }
111
112    /// Bump the major version (resets minor, patch, prerelease)
113    pub fn bump_major(&self) -> Self {
114        Self::new(self.major + 1, 0, 0)
115    }
116
117    /// Bump the minor version (resets patch, prerelease)
118    pub fn bump_minor(&self) -> Self {
119        Self::new(self.major, self.minor + 1, 0)
120    }
121
122    /// Bump the patch version (resets prerelease)
123    pub fn bump_patch(&self) -> Self {
124        Self::new(self.major, self.minor, self.patch + 1)
125    }
126
127    /// Set prerelease identifier
128    pub fn with_prerelease(mut self, pre: Prerelease) -> Self {
129        self.pre = pre;
130        self
131    }
132
133    /// Set build metadata
134    pub fn with_build(mut self, build: BuildMetadata) -> Self {
135        self.build = build;
136        self
137    }
138
139    /// Get the base version without prerelease or build metadata
140    pub fn base(&self) -> Self {
141        Self::new(self.major, self.minor, self.patch)
142    }
143}
144
145impl fmt::Display for Version {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
148        if !self.pre.is_empty() {
149            write!(f, "-{}", self.pre)?;
150        }
151        if !self.build.is_empty() {
152            write!(f, "+{}", self.build)?;
153        }
154        Ok(())
155    }
156}
157
158impl fmt::Debug for Version {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        write!(f, "Version({})", self)
161    }
162}
163
164impl FromStr for Version {
165    type Err = Error;
166
167    fn from_str(s: &str) -> Result<Self, Self::Err> {
168        Self::parse(s)
169    }
170}
171
172impl Ord for Version {
173    fn cmp(&self, other: &Self) -> Ordering {
174        // Compare major.minor.patch first
175        match self.major.cmp(&other.major) {
176            Ordering::Equal => {}
177            ord => return ord,
178        }
179        match self.minor.cmp(&other.minor) {
180            Ordering::Equal => {}
181            ord => return ord,
182        }
183        match self.patch.cmp(&other.patch) {
184            Ordering::Equal => {}
185            ord => return ord,
186        }
187
188        // Prerelease comparison (per SemVer spec):
189        // - A version without prerelease > a version with prerelease
190        // - Compare prerelease identifiers left to right
191        self.pre.cmp(&other.pre)
192    }
193}
194
195impl PartialOrd for Version {
196    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
197        Some(self.cmp(other))
198    }
199}
200
201impl Default for Version {
202    fn default() -> Self {
203        Self::new(0, 1, 0)
204    }
205}
206
207/// Pre-release identifier (e.g., "alpha", "beta.1", "rc.2")
208#[derive(Clone, Eq, PartialEq, Hash, Default, Serialize, Deserialize)]
209pub struct Prerelease {
210    identifier: String,
211}
212
213impl Prerelease {
214    /// Empty prerelease
215    pub const EMPTY: Self = Self {
216        identifier: String::new(),
217    };
218
219    /// Create a new prerelease identifier
220    pub fn new(s: &str) -> Result<Self, Error> {
221        if s.is_empty() {
222            return Ok(Self::EMPTY);
223        }
224
225        // Validate: alphanumeric and hyphens, dot-separated
226        for part in s.split('.') {
227            if part.is_empty() {
228                return Err(Error::InvalidVersion(
229                    "empty prerelease identifier".to_string(),
230                ));
231            }
232            if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
233                return Err(Error::InvalidVersion(format!(
234                    "invalid prerelease identifier: {}",
235                    part
236                )));
237            }
238        }
239
240        Ok(Self {
241            identifier: s.to_string(),
242        })
243    }
244
245    /// Check if empty
246    pub fn is_empty(&self) -> bool {
247        self.identifier.is_empty()
248    }
249
250    /// Get the identifier string
251    pub fn as_str(&self) -> &str {
252        &self.identifier
253    }
254
255    /// Parse identifier parts for comparison
256    fn parts(&self) -> impl Iterator<Item = PrereleasePart<'_>> {
257        self.identifier.split('.').map(|s| {
258            if let Ok(n) = s.parse::<u64>() {
259                PrereleasePart::Numeric(n)
260            } else {
261                PrereleasePart::Alphanumeric(s)
262            }
263        })
264    }
265}
266
267#[derive(Eq, PartialEq)]
268enum PrereleasePart<'a> {
269    Numeric(u64),
270    Alphanumeric(&'a str),
271}
272
273impl Ord for PrereleasePart<'_> {
274    fn cmp(&self, other: &Self) -> Ordering {
275        match (self, other) {
276            // Numeric < Alphanumeric
277            (PrereleasePart::Numeric(_), PrereleasePart::Alphanumeric(_)) => Ordering::Less,
278            (PrereleasePart::Alphanumeric(_), PrereleasePart::Numeric(_)) => Ordering::Greater,
279            // Both numeric: compare as numbers
280            (PrereleasePart::Numeric(a), PrereleasePart::Numeric(b)) => a.cmp(b),
281            // Both alphanumeric: compare lexically
282            (PrereleasePart::Alphanumeric(a), PrereleasePart::Alphanumeric(b)) => a.cmp(b),
283        }
284    }
285}
286
287impl PartialOrd for PrereleasePart<'_> {
288    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
289        Some(self.cmp(other))
290    }
291}
292
293impl Ord for Prerelease {
294    fn cmp(&self, other: &Self) -> Ordering {
295        // Empty prerelease (release) > non-empty prerelease
296        match (self.is_empty(), other.is_empty()) {
297            (true, true) => Ordering::Equal,
298            (true, false) => Ordering::Greater,
299            (false, true) => Ordering::Less,
300            (false, false) => {
301                // Compare parts
302                let mut self_parts = self.parts();
303                let mut other_parts = other.parts();
304
305                loop {
306                    match (self_parts.next(), other_parts.next()) {
307                        (None, None) => return Ordering::Equal,
308                        (None, Some(_)) => return Ordering::Less,
309                        (Some(_), None) => return Ordering::Greater,
310                        (Some(a), Some(b)) => match a.cmp(&b) {
311                            Ordering::Equal => continue,
312                            ord => return ord,
313                        },
314                    }
315                }
316            }
317        }
318    }
319}
320
321impl PartialOrd for Prerelease {
322    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
323        Some(self.cmp(other))
324    }
325}
326
327impl fmt::Display for Prerelease {
328    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
329        f.write_str(&self.identifier)
330    }
331}
332
333impl fmt::Debug for Prerelease {
334    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
335        if self.is_empty() {
336            write!(f, "Prerelease::EMPTY")
337        } else {
338            write!(f, "Prerelease({})", self.identifier)
339        }
340    }
341}
342
343/// Build metadata (e.g., "build.123", "20230101")
344#[derive(Clone, Eq, PartialEq, Hash, Default, Serialize, Deserialize)]
345pub struct BuildMetadata {
346    identifier: String,
347}
348
349impl BuildMetadata {
350    /// Empty build metadata
351    pub const EMPTY: Self = Self {
352        identifier: String::new(),
353    };
354
355    /// Create new build metadata
356    pub fn new(s: &str) -> Result<Self, Error> {
357        if s.is_empty() {
358            return Ok(Self::EMPTY);
359        }
360
361        // Validate: alphanumeric and hyphens, dot-separated
362        for part in s.split('.') {
363            if part.is_empty() {
364                return Err(Error::InvalidVersion("empty build metadata".to_string()));
365            }
366            if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
367                return Err(Error::InvalidVersion(format!(
368                    "invalid build metadata: {}",
369                    part
370                )));
371            }
372        }
373
374        Ok(Self {
375            identifier: s.to_string(),
376        })
377    }
378
379    /// Check if empty
380    pub fn is_empty(&self) -> bool {
381        self.identifier.is_empty()
382    }
383
384    /// Get the identifier string
385    pub fn as_str(&self) -> &str {
386        &self.identifier
387    }
388}
389
390impl fmt::Display for BuildMetadata {
391    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392        f.write_str(&self.identifier)
393    }
394}
395
396impl fmt::Debug for BuildMetadata {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        if self.is_empty() {
399            write!(f, "BuildMetadata::EMPTY")
400        } else {
401            write!(f, "BuildMetadata({})", self.identifier)
402        }
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn test_parse_simple() {
412        let v = Version::parse("1.2.3").unwrap();
413        assert_eq!(v.major, 1);
414        assert_eq!(v.minor, 2);
415        assert_eq!(v.patch, 3);
416        assert!(v.pre.is_empty());
417        assert!(v.build.is_empty());
418    }
419
420    #[test]
421    fn test_parse_with_v_prefix() {
422        let v = Version::parse("v1.2.3").unwrap();
423        assert_eq!(v.major, 1);
424        assert_eq!(v.minor, 2);
425        assert_eq!(v.patch, 3);
426    }
427
428    #[test]
429    fn test_parse_partial() {
430        let v = Version::parse("1").unwrap();
431        assert_eq!(v.major, 1);
432        assert_eq!(v.minor, 0);
433        assert_eq!(v.patch, 0);
434
435        let v = Version::parse("1.2").unwrap();
436        assert_eq!(v.major, 1);
437        assert_eq!(v.minor, 2);
438        assert_eq!(v.patch, 0);
439    }
440
441    #[test]
442    fn test_parse_prerelease() {
443        let v = Version::parse("1.0.0-alpha").unwrap();
444        assert_eq!(v.pre.as_str(), "alpha");
445
446        let v = Version::parse("1.0.0-alpha.1").unwrap();
447        assert_eq!(v.pre.as_str(), "alpha.1");
448
449        let v = Version::parse("1.0.0-0.3.7").unwrap();
450        assert_eq!(v.pre.as_str(), "0.3.7");
451
452        let v = Version::parse("1.0.0-x.7.z.92").unwrap();
453        assert_eq!(v.pre.as_str(), "x.7.z.92");
454    }
455
456    #[test]
457    fn test_parse_build_metadata() {
458        let v = Version::parse("1.0.0+build.123").unwrap();
459        assert_eq!(v.build.as_str(), "build.123");
460
461        let v = Version::parse("1.0.0-alpha+001").unwrap();
462        assert_eq!(v.pre.as_str(), "alpha");
463        assert_eq!(v.build.as_str(), "001");
464    }
465
466    #[test]
467    fn test_display() {
468        assert_eq!(Version::new(1, 2, 3).to_string(), "1.2.3");
469        assert_eq!(
470            Version::parse("1.0.0-alpha").unwrap().to_string(),
471            "1.0.0-alpha"
472        );
473        assert_eq!(
474            Version::parse("1.0.0+build").unwrap().to_string(),
475            "1.0.0+build"
476        );
477        assert_eq!(
478            Version::parse("1.0.0-alpha+build").unwrap().to_string(),
479            "1.0.0-alpha+build"
480        );
481    }
482
483    #[test]
484    fn test_ordering_basic() {
485        assert!(Version::new(2, 0, 0) > Version::new(1, 0, 0));
486        assert!(Version::new(1, 1, 0) > Version::new(1, 0, 0));
487        assert!(Version::new(1, 0, 1) > Version::new(1, 0, 0));
488    }
489
490    #[test]
491    fn test_ordering_prerelease() {
492        // Release > prerelease
493        assert!(Version::new(1, 0, 0) > Version::parse("1.0.0-alpha").unwrap());
494
495        // alpha < beta < rc
496        let alpha = Version::parse("1.0.0-alpha").unwrap();
497        let beta = Version::parse("1.0.0-beta").unwrap();
498        let rc = Version::parse("1.0.0-rc").unwrap();
499        assert!(alpha < beta);
500        assert!(beta < rc);
501
502        // Numeric comparison in prerelease
503        let alpha1 = Version::parse("1.0.0-alpha.1").unwrap();
504        let alpha2 = Version::parse("1.0.0-alpha.2").unwrap();
505        let alpha10 = Version::parse("1.0.0-alpha.10").unwrap();
506        assert!(alpha1 < alpha2);
507        assert!(alpha2 < alpha10);
508
509        // Per SemVer: numeric < alphanumeric
510        let pre_1 = Version::parse("1.0.0-1").unwrap();
511        let pre_alpha = Version::parse("1.0.0-alpha").unwrap();
512        assert!(pre_1 < pre_alpha);
513    }
514
515    #[test]
516    fn test_ordering_build_metadata_ignored() {
517        let v1 = Version::parse("1.0.0+build1").unwrap();
518        let v2 = Version::parse("1.0.0+build2").unwrap();
519        assert_eq!(v1.cmp(&v2), Ordering::Equal);
520    }
521
522    #[test]
523    fn test_bump() {
524        let v = Version::new(1, 2, 3);
525        assert_eq!(v.bump_major(), Version::new(2, 0, 0));
526        assert_eq!(v.bump_minor(), Version::new(1, 3, 0));
527        assert_eq!(v.bump_patch(), Version::new(1, 2, 4));
528    }
529
530    #[test]
531    fn test_bump_clears_prerelease() {
532        let v = Version::parse("1.0.0-alpha").unwrap();
533        assert!(v.bump_patch().pre.is_empty());
534    }
535
536    #[test]
537    fn test_is_prerelease() {
538        assert!(!Version::new(1, 0, 0).is_prerelease());
539        assert!(Version::parse("1.0.0-alpha").unwrap().is_prerelease());
540    }
541
542    #[test]
543    fn test_base() {
544        let v = Version::parse("1.2.3-alpha+build").unwrap();
545        let base = v.base();
546        assert_eq!(base, Version::new(1, 2, 3));
547        assert!(base.pre.is_empty());
548        assert!(base.build.is_empty());
549    }
550}