1use chrono::{DateTime, FixedOffset};
7use std::cmp::Ordering;
8use std::fmt;
9
10use super::VersionField;
11
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct PreReleaseTag {
15 pub name: String,
16 pub number: Option<i64>,
17 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 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 pub fn parse(input: &str) -> Self {
40 if input.trim().is_empty() {
41 return Self::default();
42 }
43 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 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 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#[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 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 pub fn format_short(&self) -> String {
122 self.commits_since_tag
123 .map(|c| c.to_string())
124 .unwrap_or_default()
125 }
126
127 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#[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 pub fn major_minor_patch(&self) -> String {
170 format!("{}.{}.{}", self.major, self.minor, self.patch)
171 }
172
173 pub fn parse(input: &str, tag_prefix: &str) -> Option<Self> {
176 Self::parse_with(input, tag_prefix, false)
177 }
178
179 pub fn parse_with(input: &str, tag_prefix: &str, strict: bool) -> Option<Self> {
182 let trimmed = input.trim();
183 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 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 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 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 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 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 if bump_core && field != VersionField::None {
289 v.pre_release_tag = PreReleaseTag::default();
290 }
291
292 if let Some(l) = label {
294 if v.pre_release_tag.has_tag() && v.pre_release_tag.name == l {
295 v.pre_release_tag.number = Some(v.pre_release_tag.number.unwrap_or(0) + 1);
297 } else {
298 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 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 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 assert_eq!(v.to_string(), "1.1.0-alpha.2");
381 }
382
383 #[test]
384 fn strict_rejects_partial_version() {
385 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 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 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 let v = SemanticVersion::parse_with("01.02.03", "[vV]?", false).unwrap();
415 assert_eq!((v.major, v.minor, v.patch), (1, 2, 3));
416 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 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 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 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}