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    /// Canonical `RawVersion` string: `major.minor.patch`, with no prerelease
20    /// or build-metadata suffix.
21    pub fn raw_version_string(&self) -> String {
22        format!("{}.{}.{}", self.major, self.minor, self.patch)
23    }
24
25    /// Canonical `Version` string: `major.minor.patch` plus the optional
26    /// `-prerelease` and `+build-metadata` suffixes. This is the single source
27    /// of truth for deriving the `Version` template var from a parsed tag, used
28    /// by both [`Context::populate_git_vars`](crate::context::Context::populate_git_vars)
29    /// and the build stage's per-crate re-scoping so the two never drift.
30    pub fn version_string(&self) -> String {
31        let mut version = self.raw_version_string();
32        if let Some(ref pre) = self.prerelease {
33            version.push('-');
34            version.push_str(pre);
35        }
36        if let Some(ref meta) = self.build_metadata {
37            version.push('+');
38            version.push_str(meta);
39        }
40        version
41    }
42}
43
44impl PartialEq for SemVer {
45    fn eq(&self, other: &Self) -> bool {
46        self.major == other.major
47            && self.minor == other.minor
48            && self.patch == other.patch
49            && self.prerelease == other.prerelease
50    }
51}
52
53impl Eq for SemVer {}
54
55impl PartialOrd for SemVer {
56    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
57        Some(self.cmp(other))
58    }
59}
60
61impl Ord for SemVer {
62    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
63        self.major
64            .cmp(&other.major)
65            .then(self.minor.cmp(&other.minor))
66            .then(self.patch.cmp(&other.patch))
67            .then(match (&self.prerelease, &other.prerelease) {
68                (Some(_), None) => std::cmp::Ordering::Less, // prerelease < release
69                (None, Some(_)) => std::cmp::Ordering::Greater, // release > prerelease
70                (Some(a), Some(b)) => compare_prerelease(a, b),
71                (None, None) => std::cmp::Ordering::Equal,
72            })
73    }
74}
75
76/// Compare two prerelease strings per SemVer 2.0.0 section 11.
77///
78/// Dot-separated identifiers are compared individually: numeric identifiers are
79/// compared as integers, alphanumeric identifiers are compared lexicographically,
80/// and numeric identifiers always have lower precedence than alphanumeric ones.
81/// A shorter set of identifiers has lower precedence when all preceding
82/// identifiers are equal.
83pub(super) fn compare_prerelease(a: &str, b: &str) -> std::cmp::Ordering {
84    use std::cmp::Ordering;
85
86    let a_ids: Vec<&str> = a.split('.').collect();
87    let b_ids: Vec<&str> = b.split('.').collect();
88
89    for (ai, bi) in a_ids.iter().zip(b_ids.iter()) {
90        let ord = match (ai.parse::<u64>(), bi.parse::<u64>()) {
91            (Ok(an), Ok(bn)) => an.cmp(&bn), // both numeric: compare as integers
92            (Ok(_), Err(_)) => Ordering::Less, // numeric < alphanumeric
93            (Err(_), Ok(_)) => Ordering::Greater, // alphanumeric > numeric
94            (Err(_), Err(_)) => ai.cmp(bi),  // both alpha: lexicographic
95        };
96        if ord != Ordering::Equal {
97            return ord;
98        }
99    }
100    // Shorter set has lower precedence
101    a_ids.len().cmp(&b_ids.len())
102}
103
104/// Compiled once and reused across all calls to [`parse_semver`].
105///
106/// Captures: 1=major, 2=minor, 3=patch, 4=prerelease (optional), 5=build metadata (optional).
107/// Prerelease is after `-` but before `+`. Build metadata is after `+`.
108static SEMVER_RE: LazyLock<Regex> =
109    LazyLock::new(|| crate::util::static_regex(r"^v?(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+(.+))?$"));
110
111/// Parse a strict semver version from a string like "v1.2.3", "1.2.3", "v1.0.0-rc.1",
112/// "v1.0.0+build.42", or "v1.0.0-rc.1+build.42".
113///
114/// The string must start with an optional `v` prefix followed by the version.
115/// For prefixed tags like "cfgd-core-v2.1.0", use [`parse_semver_tag`] instead.
116pub fn parse_semver(tag: &str) -> Result<SemVer> {
117    let caps = SEMVER_RE
118        .captures(tag)
119        .ok_or_else(|| anyhow::anyhow!("not a valid semver tag: {}", tag))?;
120    Ok(SemVer {
121        major: caps[1].parse()?,
122        minor: caps[2].parse()?,
123        patch: caps[3].parse()?,
124        prerelease: caps.get(4).map(|m| m.as_str().to_string()),
125        build_metadata: caps.get(5).map(|m| m.as_str().to_string()),
126    })
127}
128
129/// Parse a semver version from a prefixed tag string.
130///
131/// Strips everything up to and including the last `-` or `_` before the version
132/// portion, then delegates to [`parse_semver`]. Handles tags like
133/// "cfgd-core-v2.1.0", "my_project-v1.0.0-rc.1", or plain "v1.2.3".
134/// Canonical `Version` string a release tag stamps, whatever its family
135/// prefix (`v1.2.3`, `crd-v1.2.3`, `sub/v1.2.3` → `1.2.3`). `None` for an
136/// empty tag (no previous release) or a tag that does not parse as a
137/// semver tag. This is the one tag→version derivation shared by every
138/// consumer (version rewrites, burn-evidence filtering) so their
139/// semantics cannot drift.
140pub fn version_from_tag(tag: &str) -> Option<String> {
141    if tag.is_empty() {
142        return None;
143    }
144    parse_semver_tag(tag).ok().map(|sv| sv.version_string())
145}
146
147/// Split a release tag into its family prefix and the parsed version it
148/// stamps: `crd-v0.5.0` → (`"crd-v"`, 0.5.0), `v1.2.3` → (`"v"`, 1.2.3),
149/// `sub/v1.2.3-rc.1` → (`"sub/v"`, 1.2.3-rc.1). Two tags with equal
150/// prefixes belong to the same tag family (the same `tag_template`
151/// track). `None` when no semver version can be located in the tag.
152pub fn split_tag_family(tag: &str) -> Option<(&str, SemVer)> {
153    static FAMILY_RE: LazyLock<Regex> = LazyLock::new(|| {
154        crate::util::static_regex(r"^((?:|.*[-_/])v?)(\d+\.\d+\.\d+(?:-[^+]+)?(?:\+.+)?)$")
155    });
156    let caps = FAMILY_RE.captures(tag)?;
157    let prefix_len = caps.get(1)?.as_str().len();
158    let sv = parse_semver(caps.get(2)?.as_str()).ok()?;
159    Some((&tag[..prefix_len], sv))
160}
161
162pub fn parse_semver_tag(tag: &str) -> Result<SemVer> {
163    // Try strict parse first (handles "v1.2.3" and "1.2.3")
164    if let Ok(sv) = parse_semver(tag) {
165        return Ok(sv);
166    }
167    // Find the version portion: look for `v?\d+.\d+.\d+` after a separator
168    static PREFIX_RE: LazyLock<Regex> =
169        LazyLock::new(|| crate::util::static_regex(r"[-_/](v?\d+\.\d+\.\d+(?:-[^+]+)?(?:\+.+)?)$"));
170    if let Some(caps) = PREFIX_RE.captures(tag) {
171        return parse_semver(&caps[1]);
172    }
173    anyhow::bail!("not a valid semver tag: {}", tag)
174}