Skip to main content

anodizer_core/git/
semver.rs

1use anyhow::Result;
2use regex::Regex;
3use std::sync::LazyLock;
4
5#[derive(Debug, Clone)]
6pub struct SemVer {
7    pub major: u64,
8    pub minor: u64,
9    pub patch: u64,
10    pub prerelease: Option<String>,
11    pub build_metadata: Option<String>,
12}
13
14impl SemVer {
15    pub fn is_prerelease(&self) -> bool {
16        self.prerelease.is_some()
17    }
18}
19
20impl PartialEq for SemVer {
21    fn eq(&self, other: &Self) -> bool {
22        self.major == other.major
23            && self.minor == other.minor
24            && self.patch == other.patch
25            && self.prerelease == other.prerelease
26    }
27}
28
29impl Eq for SemVer {}
30
31impl PartialOrd for SemVer {
32    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
33        Some(self.cmp(other))
34    }
35}
36
37impl Ord for SemVer {
38    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
39        self.major
40            .cmp(&other.major)
41            .then(self.minor.cmp(&other.minor))
42            .then(self.patch.cmp(&other.patch))
43            .then(match (&self.prerelease, &other.prerelease) {
44                (Some(_), None) => std::cmp::Ordering::Less, // prerelease < release
45                (None, Some(_)) => std::cmp::Ordering::Greater, // release > prerelease
46                (Some(a), Some(b)) => compare_prerelease(a, b),
47                (None, None) => std::cmp::Ordering::Equal,
48            })
49    }
50}
51
52/// Compare two prerelease strings per SemVer 2.0.0 section 11.
53///
54/// Dot-separated identifiers are compared individually: numeric identifiers are
55/// compared as integers, alphanumeric identifiers are compared lexicographically,
56/// and numeric identifiers always have lower precedence than alphanumeric ones.
57/// A shorter set of identifiers has lower precedence when all preceding
58/// identifiers are equal.
59pub(super) fn compare_prerelease(a: &str, b: &str) -> std::cmp::Ordering {
60    use std::cmp::Ordering;
61
62    let a_ids: Vec<&str> = a.split('.').collect();
63    let b_ids: Vec<&str> = b.split('.').collect();
64
65    for (ai, bi) in a_ids.iter().zip(b_ids.iter()) {
66        let ord = match (ai.parse::<u64>(), bi.parse::<u64>()) {
67            (Ok(an), Ok(bn)) => an.cmp(&bn), // both numeric: compare as integers
68            (Ok(_), Err(_)) => Ordering::Less, // numeric < alphanumeric
69            (Err(_), Ok(_)) => Ordering::Greater, // alphanumeric > numeric
70            (Err(_), Err(_)) => ai.cmp(bi),  // both alpha: lexicographic
71        };
72        if ord != Ordering::Equal {
73            return ord;
74        }
75    }
76    // Shorter set has lower precedence
77    a_ids.len().cmp(&b_ids.len())
78}
79
80/// Compiled once and reused across all calls to [`parse_semver`].
81///
82/// Captures: 1=major, 2=minor, 3=patch, 4=prerelease (optional), 5=build metadata (optional).
83/// Prerelease is after `-` but before `+`. Build metadata is after `+`.
84static SEMVER_RE: LazyLock<Regex> =
85    LazyLock::new(|| crate::util::static_regex(r"^v?(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(.+))?$"));
86
87/// Parse a strict semver version from a string like "v1.2.3", "1.2.3", "v1.0.0-rc.1",
88/// "v1.0.0+build.42", or "v1.0.0-rc.1+build.42".
89///
90/// The string must start with an optional `v` prefix followed by the version.
91/// For prefixed tags like "cfgd-core-v2.1.0", use [`parse_semver_tag`] instead.
92pub fn parse_semver(tag: &str) -> Result<SemVer> {
93    let caps = SEMVER_RE
94        .captures(tag)
95        .ok_or_else(|| anyhow::anyhow!("not a valid semver tag: {}", tag))?;
96    Ok(SemVer {
97        major: caps[1].parse()?,
98        minor: caps[2].parse()?,
99        patch: caps[3].parse()?,
100        prerelease: caps.get(4).map(|m| m.as_str().to_string()),
101        build_metadata: caps.get(5).map(|m| m.as_str().to_string()),
102    })
103}
104
105/// Parse a semver version from a prefixed tag string.
106///
107/// Strips everything up to and including the last `-` or `_` before the version
108/// portion, then delegates to [`parse_semver`]. Handles tags like
109/// "cfgd-core-v2.1.0", "my_project-v1.0.0-rc.1", or plain "v1.2.3".
110pub fn parse_semver_tag(tag: &str) -> Result<SemVer> {
111    // Try strict parse first (handles "v1.2.3" and "1.2.3")
112    if let Ok(sv) = parse_semver(tag) {
113        return Ok(sv);
114    }
115    // Find the version portion: look for `v?\d+.\d+.\d+` after a separator
116    static PREFIX_RE: LazyLock<Regex> =
117        LazyLock::new(|| crate::util::static_regex(r"[-_/](v?\d+\.\d+\.\d+(?:-[^+]+)?(?:\+.+)?)$"));
118    if let Some(caps) = PREFIX_RE.captures(tag) {
119        return parse_semver(&caps[1]);
120    }
121    anyhow::bail!("not a valid semver tag: {}", tag)
122}