Skip to main content

gitversion_rs/version/
semver.rs

1//! SemanticVersion data model.
2//!
3//! Corresponds to the upstream `GitVersion.Core/SemVer/SemanticVersion.cs`,
4//! `SemanticVersionPreReleaseTag.cs`, and `SemanticVersionBuildMetaData.cs`.
5
6use chrono::{DateTime, FixedOffset};
7use std::cmp::Ordering;
8use std::fmt;
9
10use super::VersionField;
11
12/// Pre-release tag. Example: `beta.1` => name="beta", number=Some(1).
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct PreReleaseTag {
15    pub name: String,
16    pub number: Option<i64>,
17    /// Even if the name is empty, treat as a tag (promote) when a number is present.
18    pub promote_tag_even_if_name_is_empty: bool,
19}
20
21impl PreReleaseTag {
22    pub fn new(name: impl Into<String>, number: Option<i64>, promote: bool) -> Self {
23        Self {
24            name: name.into(),
25            number,
26            promote_tag_even_if_name_is_empty: promote,
27        }
28    }
29
30    /// Returns true if a meaningful pre-release tag exists.
31    pub fn has_tag(&self) -> bool {
32        !self.name.is_empty() || (self.number.is_some() && self.promote_tag_even_if_name_is_empty)
33    }
34
35    /// Corresponds to the upstream `SemanticVersionPreReleaseTag.Parse`. Supports `beta.1`, `beta`, and `1` forms.
36    ///
37    /// Sets `promote_tag_even_if_name_is_empty = true` for non-empty input,
38    /// so a number-only pre-release (e.g. `1`, where `name=""`) correctly returns `has_tag() = true`.
39    pub fn parse(input: &str) -> Self {
40        if input.trim().is_empty() {
41            return Self::default();
42        }
43        // Split name and trailing number: the trailing digits (optionally preceded by '.') become the number.
44        let re = regex::Regex::new(r"(?<name>.*?)\.?(?<number>\d+)?$").unwrap();
45        if let Some(c) = re.captures(input) {
46            let name = c.name("name").map(|m| m.as_str()).unwrap_or("").to_string();
47            let number = c
48                .name("number")
49                .and_then(|m| m.as_str().parse::<i64>().ok());
50            return Self {
51                name,
52                number,
53                promote_tag_even_if_name_is_empty: true,
54            };
55        }
56        Self {
57            name: input.to_string(),
58            number: None,
59            promote_tag_even_if_name_is_empty: true,
60        }
61    }
62
63    /// `t` format: name only. Default format: `name.number`.
64    pub fn format(&self, legacy_dash: bool) -> String {
65        let _ = legacy_dash;
66        match self.number {
67            Some(n) if !self.name.is_empty() => format!("{}.{}", self.name, n),
68            Some(n) => n.to_string(),
69            None => self.name.clone(),
70        }
71    }
72}
73
74impl Ord for PreReleaseTag {
75    fn cmp(&self, other: &Self) -> Ordering {
76        // No tag (stable) > has tag (pre-release)
77        match (self.has_tag(), other.has_tag()) {
78            (false, false) => Ordering::Equal,
79            (false, true) => Ordering::Greater,
80            (true, false) => Ordering::Less,
81            (true, true) => self
82                .name
83                .to_lowercase()
84                .cmp(&other.name.to_lowercase())
85                .then(self.number.unwrap_or(-1).cmp(&other.number.unwrap_or(-1))),
86        }
87    }
88}
89impl PartialOrd for PreReleaseTag {
90    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
91        Some(self.cmp(other))
92    }
93}
94
95/// Build metadata: commits-since-tag, branch, sha, etc.
96#[derive(Debug, Clone, Default, PartialEq, Eq)]
97pub struct BuildMetaData {
98    pub commits_since_tag: Option<i64>,
99    pub branch: Option<String>,
100    pub sha: Option<String>,
101    pub short_sha: Option<String>,
102    pub commit_date: Option<DateTime<FixedOffset>>,
103    pub other_metadata: Option<String>,
104    pub version_source_sha: Option<String>,
105    pub version_source_distance: i64,
106    pub uncommitted_changes: i64,
107    pub version_source_increment: VersionField,
108}
109
110impl BuildMetaData {
111    /// Keep only safe characters: `[^0-9A-Za-z-.]` => `-`.
112    /// The Branch part of InformationalVersion allows dots.
113    /// The upstream .NET GitVersion BuildMetaData sanitizes branches by replacing
114    /// special characters like slashes but preserving dots.
115    fn sanitize(s: &str) -> String {
116        let re = regex::Regex::new(r"[^0-9A-Za-z\-.]").unwrap();
117        re.replace_all(s, "-").into_owned()
118    }
119
120    /// Short format `b`: commits-since-tag only.
121    pub fn format_short(&self) -> String {
122        self.commits_since_tag
123            .map(|c| c.to_string())
124            .unwrap_or_default()
125    }
126
127    /// Full format `f`: commits.Branch.<branch>.Sha.<sha>[.other].
128    pub fn format_full(&self) -> String {
129        let mut parts: Vec<String> = Vec::new();
130        if let Some(c) = self.commits_since_tag {
131            parts.push(c.to_string());
132        }
133        if let Some(b) = &self.branch {
134            parts.push(format!("Branch.{}", Self::sanitize(b)));
135        }
136        if let Some(s) = &self.sha {
137            parts.push(format!("Sha.{}", s));
138        }
139        if let Some(o) = &self.other_metadata {
140            if !o.is_empty() {
141                parts.push(Self::sanitize(o));
142            }
143        }
144        parts.join(".")
145    }
146}
147
148/// A complete semantic version.
149#[derive(Debug, Clone, Default, PartialEq, Eq)]
150pub struct SemanticVersion {
151    pub major: i64,
152    pub minor: i64,
153    pub patch: i64,
154    pub pre_release_tag: PreReleaseTag,
155    pub build_metadata: BuildMetaData,
156}
157
158impl SemanticVersion {
159    pub fn new(major: i64, minor: i64, patch: i64) -> Self {
160        Self {
161            major,
162            minor,
163            patch,
164            ..Default::default()
165        }
166    }
167
168    /// Returns only `Major.Minor.Patch`.
169    pub fn major_minor_patch(&self) -> String {
170        format!("{}.{}.{}", self.major, self.minor, self.patch)
171    }
172
173    /// Parse a version string (Loose). Strips the leading prefix matched by `tag_prefix` before parsing.
174    /// Examples: `v1.2.3-beta.4`, `1.2`, `1`.
175    pub fn parse(input: &str, tag_prefix: &str) -> Option<Self> {
176        Self::parse_with(input, tag_prefix, false)
177    }
178
179    /// Parse a version string. When `strict` is true, all three components (Major.Minor.Patch) are
180    /// required, as in SemVer 2.0 (mirrors the original `SemanticVersionFormat.Strict`). Loose allows partial versions.
181    pub fn parse_with(input: &str, tag_prefix: &str, strict: bool) -> Option<Self> {
182        let trimmed = input.trim();
183        // Strip the tag prefix.
184        let body = if tag_prefix.is_empty() {
185            trimmed.to_string()
186        } else {
187            let re = regex::Regex::new(&format!("^({})", tag_prefix)).ok()?;
188            re.replace(trimmed, "").into_owned()
189        };
190        let body = body.trim();
191        if strict {
192            Self::parse_strict(body)
193        } else {
194            Self::parse_loose(body)
195        }
196    }
197
198    /// Mirrors the original `ParseStrictRegex`: all three of major.minor.patch required, no leading zeros
199    /// (`0|[1-9]\d*`), SemVer 2.0 pre-release/build metadata.
200    fn parse_strict(body: &str) -> Option<Self> {
201        let re = regex::Regex::new(
202            r"^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<tag>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?<meta>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$",
203        )
204        .ok()?;
205        let c = re.captures(body)?;
206        Some(Self {
207            major: c.name("major")?.as_str().parse().ok()?,
208            minor: c.name("minor")?.as_str().parse().ok()?,
209            patch: c.name("patch")?.as_str().parse().ok()?,
210            pre_release_tag: c
211                .name("tag")
212                .map(|m| PreReleaseTag::parse(m.as_str()))
213                .unwrap_or_default(),
214            build_metadata: BuildMetaData::default(),
215        })
216    }
217
218    /// Mirrors the original `ParseLooseRegex`: minor/patch are optional, leading zeros allowed (`\d+`),
219    /// the fourth numeric part (FourthPart) is interpreted as commits-since-tag, and
220    /// pre-release tags are accepted loosely up to `+`.
221    fn parse_loose(body: &str) -> Option<Self> {
222        let re = regex::Regex::new(
223            r"^(?<major>\d+)(\.(?<minor>\d+))?(\.(?<patch>\d+))?(\.(?<fourth>\d+))?(-(?<tag>[^+]*))?(\+(?<meta>.*))?$",
224        )
225        .ok()?;
226        let c = re.captures(body)?;
227        let major = c.name("major")?.as_str().parse().ok()?;
228        let minor = c
229            .name("minor")
230            .and_then(|m| m.as_str().parse().ok())
231            .unwrap_or(0);
232        let patch = c
233            .name("patch")
234            .and_then(|m| m.as_str().parse().ok())
235            .unwrap_or(0);
236        let pre_release_tag = c
237            .name("tag")
238            .map(|m| PreReleaseTag::parse(m.as_str()))
239            .unwrap_or_default();
240        let build_metadata = BuildMetaData {
241            commits_since_tag: c.name("fourth").and_then(|m| m.as_str().parse().ok()),
242            ..Default::default()
243        };
244        Some(Self {
245            major,
246            minor,
247            patch,
248            pre_release_tag,
249            build_metadata,
250        })
251    }
252
253    /// Compare only the core version (ignoring pre-release).
254    pub fn cmp_core(&self, other: &Self) -> Ordering {
255        self.major
256            .cmp(&other.major)
257            .then(self.minor.cmp(&other.minor))
258            .then(self.patch.cmp(&other.patch))
259    }
260
261    /// Increment the specified field and apply the label. Mirrors the original `SemanticVersion.Increment`.
262    ///
263    /// - If a pre-release already exists and `force` is false, the core is not bumped; the pre-release is kept.
264    /// - `label` of `Some("")` (empty string) creates a name-less promoted pre-release that still
265    ///   exposes its number (e.g. `0.0.1-1`). `None` means no label is applied.
266    pub fn increment(&self, field: VersionField, label: Option<&str>, force: bool) -> Self {
267        let mut v = self.clone();
268        let has_pre = self.pre_release_tag.has_tag();
269        // Do not bump the core if a pre-release already exists (unless forced).
270        let bump_core = !has_pre || force;
271
272        match field {
273            VersionField::None => {}
274            VersionField::Patch if bump_core => v.patch += 1,
275            VersionField::Minor if bump_core => {
276                v.minor += 1;
277                v.patch = 0;
278            }
279            VersionField::Major if bump_core => {
280                v.major += 1;
281                v.minor = 0;
282                v.patch = 0;
283            }
284            _ => {}
285        }
286
287        // Bumping the core resets any existing pre-release.
288        if bump_core && field != VersionField::None {
289            v.pre_release_tag = PreReleaseTag::default();
290        }
291
292        // Apply the label.
293        if let Some(l) = label {
294            if v.pre_release_tag.has_tag() && v.pre_release_tag.name == l {
295                // Same label: bump the number only.
296                v.pre_release_tag.number = Some(v.pre_release_tag.number.unwrap_or(0) + 1);
297            } else {
298                // New label. When the name is empty, promote to expose the number.
299                v.pre_release_tag = PreReleaseTag::new(l, Some(1), l.is_empty());
300            }
301        }
302        v
303    }
304}
305
306impl fmt::Display for SemanticVersion {
307    /// `s` format: Major.Minor.Patch[-pre].
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        write!(f, "{}", self.major_minor_patch())?;
310        if self.pre_release_tag.has_tag() {
311            write!(f, "-{}", self.pre_release_tag.format(false))?;
312        }
313        Ok(())
314    }
315}
316
317impl Ord for SemanticVersion {
318    fn cmp(&self, other: &Self) -> Ordering {
319        self.cmp_core(other)
320            .then(self.pre_release_tag.cmp(&other.pre_release_tag))
321    }
322}
323impl PartialOrd for SemanticVersion {
324    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
325        Some(self.cmp(other))
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::version::VersionField;
333
334    #[test]
335    fn parse_basic() {
336        let v = SemanticVersion::parse("v1.2.3", "[vV]?").unwrap();
337        assert_eq!((v.major, v.minor, v.patch), (1, 2, 3));
338        assert!(!v.pre_release_tag.has_tag());
339    }
340
341    #[test]
342    fn parse_partial_and_prerelease() {
343        let v = SemanticVersion::parse("1.2", "[vV]?").unwrap();
344        assert_eq!((v.major, v.minor, v.patch), (1, 2, 0));
345        let v = SemanticVersion::parse("2.0.0-beta.4", "[vV]?").unwrap();
346        assert_eq!(v.pre_release_tag.name, "beta");
347        assert_eq!(v.pre_release_tag.number, Some(4));
348    }
349
350    #[test]
351    fn ordering_stable_gt_prerelease() {
352        let stable = SemanticVersion::parse("1.0.0", "").unwrap();
353        let pre = SemanticVersion::parse("1.0.0-alpha.1", "").unwrap();
354        assert!(stable > pre);
355    }
356
357    #[test]
358    fn increment_empty_label_promotes_number() {
359        // Empty label → name-less promoted pre-release (e.g. 0.0.1-1).
360        let base = SemanticVersion::new(0, 0, 0);
361        let v = base.increment(VersionField::Patch, Some(""), false);
362        assert_eq!(v.major_minor_patch(), "0.0.1");
363        assert_eq!(v.to_string(), "0.0.1-1");
364        assert_eq!(v.pre_release_tag.number, Some(1));
365    }
366
367    #[test]
368    fn increment_named_label_resets_to_one() {
369        let base = SemanticVersion::new(1, 0, 0);
370        let v = base.increment(VersionField::Minor, Some("alpha"), false);
371        assert_eq!(v.to_string(), "1.1.0-alpha.1");
372    }
373
374    #[test]
375    fn increment_same_label_bumps_number() {
376        let mut base = SemanticVersion::new(1, 1, 0);
377        base.pre_release_tag = PreReleaseTag::new("alpha", Some(1), false);
378        let v = base.increment(VersionField::Minor, Some("alpha"), false);
379        // Pre-release already exists: keep the core, bump the number only.
380        assert_eq!(v.to_string(), "1.1.0-alpha.2");
381    }
382
383    #[test]
384    fn strict_rejects_partial_version() {
385        // Strict requires all three of Major.Minor.Patch.
386        assert!(SemanticVersion::parse_with("1.2", "[vV]?", true).is_none());
387        assert!(SemanticVersion::parse_with("1", "[vV]?", true).is_none());
388        assert!(SemanticVersion::parse_with("1.2.3", "[vV]?", true).is_some());
389    }
390
391    #[test]
392    fn loose_accepts_partial_version() {
393        let v = SemanticVersion::parse_with("1.2", "[vV]?", false).unwrap();
394        assert_eq!((v.major, v.minor, v.patch), (1, 2, 0));
395        let v = SemanticVersion::parse_with("v1", "[vV]?", false).unwrap();
396        assert_eq!((v.major, v.minor, v.patch), (1, 0, 0));
397    }
398
399    #[test]
400    fn strict_rejects_four_part_and_leading_zero() {
401        // Strict (original ParseStrictRegex): rejects 4-part and leading zeros.
402        assert!(SemanticVersion::parse_with("1.2.3.4", "[vV]?", true).is_none());
403        assert!(SemanticVersion::parse_with("01.02.03", "[vV]?", true).is_none());
404        assert!(SemanticVersion::parse_with("1.2.3", "[vV]?", true).is_some());
405    }
406
407    #[test]
408    fn loose_accepts_four_part_and_leading_zero() {
409        // Loose (original ParseLooseRegex): the fourth part is interpreted as commits-since-tag.
410        let v = SemanticVersion::parse_with("1.2.3.4", "[vV]?", false).unwrap();
411        assert_eq!((v.major, v.minor, v.patch), (1, 2, 3));
412        assert_eq!(v.build_metadata.commits_since_tag, Some(4));
413        // Leading zeros are allowed: 01.02.03 → 1.2.3.
414        let v = SemanticVersion::parse_with("01.02.03", "[vV]?", false).unwrap();
415        assert_eq!((v.major, v.minor, v.patch), (1, 2, 3));
416        // 3-part versions have no commits-since-tag.
417        let v = SemanticVersion::parse_with("1.2.3", "[vV]?", false).unwrap();
418        assert_eq!(v.build_metadata.commits_since_tag, None);
419    }
420
421    #[test]
422    fn increment_none_keeps_core() {
423        let base = SemanticVersion::new(2, 0, 0);
424        let v = base.increment(VersionField::None, Some(""), false);
425        assert_eq!(v.major_minor_patch(), "2.0.0");
426        assert_eq!(v.to_string(), "2.0.0-1");
427    }
428
429    #[test]
430    fn prerelease_tag_parse_empty_returns_default() {
431        let t = PreReleaseTag::parse("");
432        assert!(!t.has_tag());
433        assert_eq!(t.name, "");
434        assert_eq!(t.number, None);
435    }
436
437    #[test]
438    fn prerelease_tag_format_number_only() {
439        // name is empty and only number is set → returns the number as a string.
440        let t = PreReleaseTag::new("", Some(3), true);
441        assert_eq!(t.format(false), "3");
442    }
443
444    #[test]
445    fn prerelease_tag_format_name_and_number() {
446        let t = PreReleaseTag::new("rc", Some(2), false);
447        assert_eq!(t.format(false), "rc.2");
448    }
449
450    #[test]
451    fn prerelease_tag_ordering_both_without_tag() {
452        // Neither has a tag → Equal.
453        let a = PreReleaseTag::default();
454        let b = PreReleaseTag::default();
455        assert_eq!(a.cmp(&b), std::cmp::Ordering::Equal);
456        assert_eq!(a.partial_cmp(&b), Some(std::cmp::Ordering::Equal));
457    }
458
459    #[test]
460    fn prerelease_tag_ordering_with_vs_without() {
461        let stable = PreReleaseTag::default();
462        let pre = PreReleaseTag::new("alpha", Some(1), false);
463        assert!(stable > pre);
464        assert!(pre < stable);
465    }
466
467    #[test]
468    fn build_metadata_format_short_none() {
469        let meta = BuildMetaData::default();
470        assert_eq!(meta.format_short(), "");
471    }
472
473    #[test]
474    fn build_metadata_format_short_value() {
475        let meta = BuildMetaData {
476            commits_since_tag: Some(5),
477            ..Default::default()
478        };
479        assert_eq!(meta.format_short(), "5");
480    }
481
482    #[test]
483    fn build_metadata_format_full_all_fields() {
484        let meta = BuildMetaData {
485            commits_since_tag: Some(3),
486            branch: Some("feature/foo".into()),
487            sha: Some("abc1234".into()),
488            other_metadata: Some("extra!info".into()),
489            ..Default::default()
490        };
491        let full = meta.format_full();
492        assert!(full.contains("3"), "commits: {full}");
493        assert!(
494            full.contains("Branch.feature-foo"),
495            "branch sanitize: {full}"
496        );
497        assert!(full.contains("Sha.abc1234"), "sha: {full}");
498        assert!(full.contains("extra-info"), "other sanitize: {full}");
499    }
500
501    #[test]
502    fn build_metadata_format_full_empty_other_omitted() {
503        let meta = BuildMetaData {
504            commits_since_tag: Some(1),
505            other_metadata: Some(String::new()),
506            ..Default::default()
507        };
508        let full = meta.format_full();
509        // Empty other_metadata must not appear in the output.
510        assert_eq!(full, "1");
511    }
512
513    #[test]
514    fn semver_display_no_prerelease() {
515        let v = SemanticVersion::new(1, 2, 3);
516        assert_eq!(v.to_string(), "1.2.3");
517    }
518
519    #[test]
520    fn semver_partial_ord() {
521        let a = SemanticVersion::new(1, 0, 0);
522        let b = SemanticVersion::new(2, 0, 0);
523        assert!(a < b);
524        assert!(a.partial_cmp(&b) == Some(std::cmp::Ordering::Less));
525    }
526
527    #[test]
528    fn increment_major_resets_minor_patch() {
529        let base = SemanticVersion::new(1, 2, 3);
530        let v = base.increment(VersionField::Major, None, true);
531        assert_eq!((v.major, v.minor, v.patch), (2, 0, 0));
532    }
533}