1use changeset_core::{BumpType, PrereleaseSpec, ZeroVersionBehavior};
2use semver::{Prerelease, Version};
3use thiserror::Error;
4
5#[derive(Debug, Error)]
6pub enum VersionError {
7 #[error("invalid prerelease identifier: {identifier}")]
8 InvalidPrerelease { identifier: String },
9 #[error("cannot graduate from prerelease version '{version}'; release stable 0.x first")]
10 CannotGraduateFromPrerelease { version: String },
11 #[error("can only graduate 0.x versions to 1.0.0; version is {version}")]
12 CanOnlyGraduateZeroVersions { version: String },
13}
14
15#[must_use]
16pub fn max_bump_type(bumps: &[BumpType]) -> Option<BumpType> {
17 bumps.iter().max().copied()
18}
19
20pub fn bump_version(version: &Version, bump_type: BumpType) -> Version {
21 let mut new_version = version.clone();
22
23 match bump_type {
24 BumpType::Major => {
25 new_version.major += 1;
26 new_version.minor = 0;
27 new_version.patch = 0;
28 }
29 BumpType::Minor => {
30 new_version.minor += 1;
31 new_version.patch = 0;
32 }
33 BumpType::Patch => {
34 new_version.patch += 1;
35 }
36 }
37
38 new_version.pre = Prerelease::EMPTY;
39 new_version
40}
41
42fn parse_prerelease(pre: &Prerelease) -> Option<(String, u64)> {
43 let pre_str = pre.as_str();
44 if pre_str.is_empty() {
45 return None;
46 }
47
48 let parts: Vec<&str> = pre_str.split('.').collect();
49 if parts.len() < 2 {
50 return Some((pre_str.to_string(), 1));
51 }
52
53 let last = parts.last()?;
54 if let Ok(num) = last.parse::<u64>() {
55 let tag = parts[..parts.len() - 1].join(".");
56 Some((tag, num))
57 } else {
58 Some((pre_str.to_string(), 1))
59 }
60}
61
62pub fn calculate_new_version(
69 current: &Version,
70 bump_type: Option<BumpType>,
71 prerelease: Option<&PrereleaseSpec>,
72) -> Result<Version, VersionError> {
73 let mut new_version = current.clone();
74
75 match prerelease {
76 Some(spec) => {
77 let tag = spec.identifier();
78
79 if current.pre.is_empty() {
80 let bump = bump_type.unwrap_or(BumpType::Patch);
81 new_version = bump_version(current, bump);
82 new_version.pre = make_prerelease(tag, 1)?;
83 } else if let Some((current_tag, current_num)) = parse_prerelease(¤t.pre) {
84 if current_tag == tag {
85 new_version.pre = make_prerelease(tag, current_num + 1)?;
86 } else {
87 new_version.pre = make_prerelease(tag, 1)?;
88 }
89 } else {
90 new_version.pre = make_prerelease(tag, 1)?;
91 }
92 }
93 None => {
94 if !current.pre.is_empty() {
95 new_version.pre = Prerelease::EMPTY;
96 } else if let Some(bump) = bump_type {
97 new_version = bump_version(current, bump);
98 }
99 }
100 }
101
102 Ok(new_version)
103}
104
105fn make_prerelease(tag: &str, num: u64) -> Result<Prerelease, VersionError> {
106 let identifier = format!("{tag}.{num}");
107 Prerelease::new(&identifier).map_err(|_| VersionError::InvalidPrerelease { identifier })
108}
109
110#[must_use]
111pub fn is_prerelease(version: &Version) -> bool {
112 !version.pre.is_empty()
113}
114
115#[must_use]
116pub fn extract_prerelease_tag(version: &Version) -> Option<String> {
117 parse_prerelease(&version.pre).map(|(tag, _)| tag)
118}
119
120#[must_use]
121pub fn is_zero_version(version: &Version) -> bool {
122 version.major == 0
123}
124
125pub fn calculate_new_version_with_zero_behavior(
147 current: &Version,
148 bump_type: Option<BumpType>,
149 prerelease: Option<&PrereleaseSpec>,
150 zero_behavior: ZeroVersionBehavior,
151 graduate: bool,
152) -> Result<Version, VersionError> {
153 if graduate {
154 return calculate_graduation(current, prerelease);
155 }
156
157 if current.major >= 1 {
158 return calculate_new_version(current, bump_type, prerelease);
159 }
160
161 let effective_bump = match zero_behavior {
162 ZeroVersionBehavior::EffectiveMinor => bump_type.map(|bt| match bt {
163 BumpType::Major => BumpType::Minor,
164 BumpType::Minor | BumpType::Patch => BumpType::Patch,
165 }),
166 ZeroVersionBehavior::AutoPromoteOnMajor => {
167 if bump_type == Some(BumpType::Major) {
168 return apply_prerelease_to_version(Version::new(1, 0, 0), prerelease);
169 }
170 bump_type
171 }
172 };
173
174 calculate_new_version(current, effective_bump, prerelease)
175}
176
177fn calculate_graduation(
178 current: &Version,
179 prerelease: Option<&PrereleaseSpec>,
180) -> Result<Version, VersionError> {
181 if is_prerelease(current) {
182 return Err(VersionError::CannotGraduateFromPrerelease {
183 version: current.to_string(),
184 });
185 }
186
187 if current.major >= 1 {
188 return Err(VersionError::CanOnlyGraduateZeroVersions {
189 version: current.to_string(),
190 });
191 }
192
193 apply_prerelease_to_version(Version::new(1, 0, 0), prerelease)
194}
195
196fn apply_prerelease_to_version(
197 base: Version,
198 prerelease: Option<&PrereleaseSpec>,
199) -> Result<Version, VersionError> {
200 match prerelease {
201 Some(spec) => {
202 let mut version = base;
203 version.pre = make_prerelease(spec.identifier(), 1)?;
204 Ok(version)
205 }
206 None => Ok(base),
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn bump_patch() {
216 let version = Version::parse("1.2.3").unwrap();
217 let bumped = bump_version(&version, BumpType::Patch);
218 assert_eq!(bumped, Version::parse("1.2.4").unwrap());
219 }
220
221 #[test]
222 fn bump_minor() {
223 let version = Version::parse("1.2.3").unwrap();
224 let bumped = bump_version(&version, BumpType::Minor);
225 assert_eq!(bumped, Version::parse("1.3.0").unwrap());
226 }
227
228 #[test]
229 fn bump_major() {
230 let version = Version::parse("1.2.3").unwrap();
231 let bumped = bump_version(&version, BumpType::Major);
232 assert_eq!(bumped, Version::parse("2.0.0").unwrap());
233 }
234
235 #[test]
236 fn bump_version_strips_prerelease() {
237 let version = Version::parse("1.2.3-alpha.1").unwrap();
238 let bumped = bump_version(&version, BumpType::Patch);
239 assert_eq!(bumped, Version::parse("1.2.4").unwrap());
240 }
241
242 mod max_bump_type_tests {
243 use super::*;
244
245 #[test]
246 fn returns_none_for_empty_slice() {
247 assert_eq!(max_bump_type(&[]), None);
248 }
249
250 #[test]
251 fn returns_single_element() {
252 assert_eq!(max_bump_type(&[BumpType::Patch]), Some(BumpType::Patch));
253 assert_eq!(max_bump_type(&[BumpType::Minor]), Some(BumpType::Minor));
254 assert_eq!(max_bump_type(&[BumpType::Major]), Some(BumpType::Major));
255 }
256
257 #[test]
258 fn returns_minor_for_patch_and_minor() {
259 assert_eq!(
260 max_bump_type(&[BumpType::Patch, BumpType::Minor]),
261 Some(BumpType::Minor)
262 );
263 }
264
265 #[test]
266 fn returns_major_for_minor_and_major() {
267 assert_eq!(
268 max_bump_type(&[BumpType::Minor, BumpType::Major]),
269 Some(BumpType::Major)
270 );
271 }
272
273 #[test]
274 fn returns_major_for_all_three() {
275 assert_eq!(
276 max_bump_type(&[BumpType::Patch, BumpType::Minor, BumpType::Major]),
277 Some(BumpType::Major)
278 );
279 }
280
281 #[test]
282 fn handles_duplicates() {
283 assert_eq!(
284 max_bump_type(&[BumpType::Patch, BumpType::Patch, BumpType::Minor]),
285 Some(BumpType::Minor)
286 );
287 }
288
289 #[test]
290 fn order_does_not_matter() {
291 assert_eq!(
292 max_bump_type(&[BumpType::Major, BumpType::Patch, BumpType::Minor]),
293 Some(BumpType::Major)
294 );
295 }
296 }
297
298 mod parse_prerelease_tests {
299 use super::*;
300
301 #[test]
302 fn empty_prerelease_returns_none() {
303 let pre = Prerelease::EMPTY;
304 assert!(parse_prerelease(&pre).is_none());
305 }
306
307 #[test]
308 fn parses_standard_format() {
309 let pre = Prerelease::new("alpha.1").unwrap();
310 let (tag, num) = parse_prerelease(&pre).unwrap();
311 assert_eq!(tag, "alpha");
312 assert_eq!(num, 1);
313 }
314
315 #[test]
316 fn parses_higher_numbers() {
317 let pre = Prerelease::new("rc.42").unwrap();
318 let (tag, num) = parse_prerelease(&pre).unwrap();
319 assert_eq!(tag, "rc");
320 assert_eq!(num, 42);
321 }
322
323 #[test]
324 fn handles_tag_without_number() {
325 let pre = Prerelease::new("beta").unwrap();
326 let (tag, num) = parse_prerelease(&pre).unwrap();
327 assert_eq!(tag, "beta");
328 assert_eq!(num, 1);
329 }
330
331 #[test]
332 fn handles_complex_tag_with_dots() {
333 let pre = Prerelease::new("pre.release.3").unwrap();
334 let (tag, num) = parse_prerelease(&pre).unwrap();
335 assert_eq!(tag, "pre.release");
336 assert_eq!(num, 3);
337 }
338 }
339
340 mod calculate_new_version_tests {
341 use super::*;
342
343 #[test]
344 fn stable_to_alpha_with_patch() {
345 let version = Version::parse("1.0.0").unwrap();
346 let result = calculate_new_version(
347 &version,
348 Some(BumpType::Patch),
349 Some(&PrereleaseSpec::Alpha),
350 )
351 .unwrap();
352 assert_eq!(result, Version::parse("1.0.1-alpha.1").unwrap());
353 }
354
355 #[test]
356 fn stable_to_alpha_with_minor() {
357 let version = Version::parse("1.0.0").unwrap();
358 let result = calculate_new_version(
359 &version,
360 Some(BumpType::Minor),
361 Some(&PrereleaseSpec::Alpha),
362 )
363 .unwrap();
364 assert_eq!(result, Version::parse("1.1.0-alpha.1").unwrap());
365 }
366
367 #[test]
368 fn stable_to_alpha_with_major() {
369 let version = Version::parse("1.0.0").unwrap();
370 let result = calculate_new_version(
371 &version,
372 Some(BumpType::Major),
373 Some(&PrereleaseSpec::Alpha),
374 )
375 .unwrap();
376 assert_eq!(result, Version::parse("2.0.0-alpha.1").unwrap());
377 }
378
379 #[test]
380 fn alpha_increment_same_tag() {
381 let version = Version::parse("1.0.1-alpha.1").unwrap();
382 let result =
383 calculate_new_version(&version, None, Some(&PrereleaseSpec::Alpha)).unwrap();
384 assert_eq!(result, Version::parse("1.0.1-alpha.2").unwrap());
385 }
386
387 #[test]
388 fn alpha_to_beta_transition() {
389 let version = Version::parse("1.0.1-alpha.3").unwrap();
390 let result =
391 calculate_new_version(&version, None, Some(&PrereleaseSpec::Beta)).unwrap();
392 assert_eq!(result, Version::parse("1.0.1-beta.1").unwrap());
393 }
394
395 #[test]
396 fn beta_to_rc_transition() {
397 let version = Version::parse("1.0.1-beta.2").unwrap();
398 let result = calculate_new_version(&version, None, Some(&PrereleaseSpec::Rc)).unwrap();
399 assert_eq!(result, Version::parse("1.0.1-rc.1").unwrap());
400 }
401
402 #[test]
403 fn rc_graduate_to_stable() {
404 let version = Version::parse("1.0.1-rc.1").unwrap();
405 let result = calculate_new_version(&version, None, None).unwrap();
406 assert_eq!(result, Version::parse("1.0.1").unwrap());
407 }
408
409 #[test]
410 fn alpha_graduate_to_stable() {
411 let version = Version::parse("1.0.1-alpha.5").unwrap();
412 let result = calculate_new_version(&version, None, None).unwrap();
413 assert_eq!(result, Version::parse("1.0.1").unwrap());
414 }
415
416 #[test]
417 fn custom_prerelease_tag() {
418 let version = Version::parse("1.0.0").unwrap();
419 let spec = PrereleaseSpec::Custom("dev".to_string());
420 let result =
421 calculate_new_version(&version, Some(BumpType::Patch), Some(&spec)).unwrap();
422 assert_eq!(result, Version::parse("1.0.1-dev.1").unwrap());
423 }
424
425 #[test]
426 fn stable_bump_without_prerelease() {
427 let version = Version::parse("1.0.0").unwrap();
428 let result = calculate_new_version(&version, Some(BumpType::Minor), None).unwrap();
429 assert_eq!(result, Version::parse("1.1.0").unwrap());
430 }
431
432 #[test]
433 fn stable_no_change_without_bump_or_prerelease() {
434 let version = Version::parse("1.0.0").unwrap();
435 let result = calculate_new_version(&version, None, None).unwrap();
436 assert_eq!(result, Version::parse("1.0.0").unwrap());
437 }
438
439 #[test]
440 fn prerelease_defaults_to_patch_when_no_bump_specified() {
441 let version = Version::parse("1.0.0").unwrap();
442 let result =
443 calculate_new_version(&version, None, Some(&PrereleaseSpec::Alpha)).unwrap();
444 assert_eq!(result, Version::parse("1.0.1-alpha.1").unwrap());
445 }
446 }
447
448 mod is_prerelease_tests {
449 use super::*;
450
451 #[test]
452 fn stable_version_is_not_prerelease() {
453 let version = Version::parse("1.0.0").unwrap();
454 assert!(!is_prerelease(&version));
455 }
456
457 #[test]
458 fn alpha_version_is_prerelease() {
459 let version = Version::parse("1.0.0-alpha.1").unwrap();
460 assert!(is_prerelease(&version));
461 }
462
463 #[test]
464 fn rc_version_is_prerelease() {
465 let version = Version::parse("1.0.0-rc.1").unwrap();
466 assert!(is_prerelease(&version));
467 }
468 }
469
470 mod extract_prerelease_tag_tests {
471 use super::*;
472
473 #[test]
474 fn stable_version_returns_none() {
475 let version = Version::parse("1.0.0").unwrap();
476 assert!(extract_prerelease_tag(&version).is_none());
477 }
478
479 #[test]
480 fn extracts_alpha_tag() {
481 let version = Version::parse("1.0.0-alpha.1").unwrap();
482 assert_eq!(extract_prerelease_tag(&version), Some("alpha".to_string()));
483 }
484
485 #[test]
486 fn extracts_rc_tag() {
487 let version = Version::parse("1.0.0-rc.3").unwrap();
488 assert_eq!(extract_prerelease_tag(&version), Some("rc".to_string()));
489 }
490
491 #[test]
492 fn extracts_custom_tag() {
493 let version = Version::parse("1.0.0-nightly.5").unwrap();
494 assert_eq!(
495 extract_prerelease_tag(&version),
496 Some("nightly".to_string())
497 );
498 }
499 }
500
501 mod is_zero_version_tests {
502 use super::*;
503
504 #[test]
505 fn zero_major_is_zero_version() {
506 let version = Version::parse("0.1.0").unwrap();
507 assert!(is_zero_version(&version));
508 }
509
510 #[test]
511 fn zero_minor_patch_is_zero_version() {
512 let version = Version::parse("0.0.1").unwrap();
513 assert!(is_zero_version(&version));
514 }
515
516 #[test]
517 fn one_major_is_not_zero_version() {
518 let version = Version::parse("1.0.0").unwrap();
519 assert!(!is_zero_version(&version));
520 }
521
522 #[test]
523 fn two_major_is_not_zero_version() {
524 let version = Version::parse("2.3.4").unwrap();
525 assert!(!is_zero_version(&version));
526 }
527
528 #[test]
529 fn zero_prerelease_is_zero_version() {
530 let version = Version::parse("0.1.0-alpha.1").unwrap();
531 assert!(is_zero_version(&version));
532 }
533 }
534
535 mod calculate_new_version_with_zero_behavior_tests {
536 use super::*;
537
538 mod effective_minor_behavior {
539 use super::*;
540
541 #[test]
542 fn major_becomes_minor() {
543 let version = Version::parse("0.1.2").unwrap();
544 let result = calculate_new_version_with_zero_behavior(
545 &version,
546 Some(BumpType::Major),
547 None,
548 ZeroVersionBehavior::EffectiveMinor,
549 false,
550 )
551 .unwrap();
552 assert_eq!(result, Version::parse("0.2.0").unwrap());
553 }
554
555 #[test]
556 fn minor_becomes_patch() {
557 let version = Version::parse("0.1.2").unwrap();
558 let result = calculate_new_version_with_zero_behavior(
559 &version,
560 Some(BumpType::Minor),
561 None,
562 ZeroVersionBehavior::EffectiveMinor,
563 false,
564 )
565 .unwrap();
566 assert_eq!(result, Version::parse("0.1.3").unwrap());
567 }
568
569 #[test]
570 fn patch_stays_patch() {
571 let version = Version::parse("0.1.2").unwrap();
572 let result = calculate_new_version_with_zero_behavior(
573 &version,
574 Some(BumpType::Patch),
575 None,
576 ZeroVersionBehavior::EffectiveMinor,
577 false,
578 )
579 .unwrap();
580 assert_eq!(result, Version::parse("0.1.3").unwrap());
581 }
582
583 #[test]
584 fn major_with_prerelease() {
585 let version = Version::parse("0.1.2").unwrap();
586 let result = calculate_new_version_with_zero_behavior(
587 &version,
588 Some(BumpType::Major),
589 Some(&PrereleaseSpec::Alpha),
590 ZeroVersionBehavior::EffectiveMinor,
591 false,
592 )
593 .unwrap();
594 assert_eq!(result, Version::parse("0.2.0-alpha.1").unwrap());
595 }
596
597 #[test]
598 fn double_zero_version() {
599 let version = Version::parse("0.0.5").unwrap();
600 let result = calculate_new_version_with_zero_behavior(
601 &version,
602 Some(BumpType::Major),
603 None,
604 ZeroVersionBehavior::EffectiveMinor,
605 false,
606 )
607 .unwrap();
608 assert_eq!(result, Version::parse("0.1.0").unwrap());
609 }
610 }
611
612 mod auto_promote_behavior {
613 use super::*;
614
615 #[test]
616 fn major_becomes_1_0_0() {
617 let version = Version::parse("0.1.2").unwrap();
618 let result = calculate_new_version_with_zero_behavior(
619 &version,
620 Some(BumpType::Major),
621 None,
622 ZeroVersionBehavior::AutoPromoteOnMajor,
623 false,
624 )
625 .unwrap();
626 assert_eq!(result, Version::parse("1.0.0").unwrap());
627 }
628
629 #[test]
630 fn minor_stays_minor() {
631 let version = Version::parse("0.1.2").unwrap();
632 let result = calculate_new_version_with_zero_behavior(
633 &version,
634 Some(BumpType::Minor),
635 None,
636 ZeroVersionBehavior::AutoPromoteOnMajor,
637 false,
638 )
639 .unwrap();
640 assert_eq!(result, Version::parse("0.2.0").unwrap());
641 }
642
643 #[test]
644 fn patch_stays_patch() {
645 let version = Version::parse("0.1.2").unwrap();
646 let result = calculate_new_version_with_zero_behavior(
647 &version,
648 Some(BumpType::Patch),
649 None,
650 ZeroVersionBehavior::AutoPromoteOnMajor,
651 false,
652 )
653 .unwrap();
654 assert_eq!(result, Version::parse("0.1.3").unwrap());
655 }
656
657 #[test]
658 fn major_with_prerelease() {
659 let version = Version::parse("0.1.2").unwrap();
660 let result = calculate_new_version_with_zero_behavior(
661 &version,
662 Some(BumpType::Major),
663 Some(&PrereleaseSpec::Alpha),
664 ZeroVersionBehavior::AutoPromoteOnMajor,
665 false,
666 )
667 .unwrap();
668 assert_eq!(result, Version::parse("1.0.0-alpha.1").unwrap());
669 }
670 }
671
672 mod stable_versions_unaffected {
673 use super::*;
674
675 #[test]
676 fn effective_minor_major_bump() {
677 let version = Version::parse("1.2.3").unwrap();
678 let result = calculate_new_version_with_zero_behavior(
679 &version,
680 Some(BumpType::Major),
681 None,
682 ZeroVersionBehavior::EffectiveMinor,
683 false,
684 )
685 .unwrap();
686 assert_eq!(result, Version::parse("2.0.0").unwrap());
687 }
688
689 #[test]
690 fn auto_promote_major_bump() {
691 let version = Version::parse("1.2.3").unwrap();
692 let result = calculate_new_version_with_zero_behavior(
693 &version,
694 Some(BumpType::Major),
695 None,
696 ZeroVersionBehavior::AutoPromoteOnMajor,
697 false,
698 )
699 .unwrap();
700 assert_eq!(result, Version::parse("2.0.0").unwrap());
701 }
702 }
703
704 mod graduation {
705 use super::*;
706
707 #[test]
708 fn promotes_zero_to_1_0_0() {
709 let version = Version::parse("0.5.3").unwrap();
710 let result = calculate_new_version_with_zero_behavior(
711 &version,
712 None,
713 None,
714 ZeroVersionBehavior::EffectiveMinor,
715 true,
716 )
717 .unwrap();
718 assert_eq!(result, Version::parse("1.0.0").unwrap());
719 }
720
721 #[test]
722 fn with_prerelease() {
723 let version = Version::parse("0.5.3").unwrap();
724 let result = calculate_new_version_with_zero_behavior(
725 &version,
726 None,
727 Some(&PrereleaseSpec::Alpha),
728 ZeroVersionBehavior::EffectiveMinor,
729 true,
730 )
731 .unwrap();
732 assert_eq!(result, Version::parse("1.0.0-alpha.1").unwrap());
733 }
734
735 #[test]
736 fn errors_on_prerelease_version() {
737 let version = Version::parse("0.5.3-alpha.1").unwrap();
738 let result = calculate_new_version_with_zero_behavior(
739 &version,
740 None,
741 None,
742 ZeroVersionBehavior::EffectiveMinor,
743 true,
744 );
745 assert!(matches!(
746 result,
747 Err(VersionError::CannotGraduateFromPrerelease { .. })
748 ));
749 }
750
751 #[test]
752 fn errors_on_stable_version() {
753 let version = Version::parse("1.2.3").unwrap();
754 let result = calculate_new_version_with_zero_behavior(
755 &version,
756 None,
757 None,
758 ZeroVersionBehavior::EffectiveMinor,
759 true,
760 );
761 assert!(matches!(
762 result,
763 Err(VersionError::CanOnlyGraduateZeroVersions { .. })
764 ));
765 }
766
767 #[test]
768 fn bump_type_ignored_when_graduating() {
769 let version = Version::parse("0.5.3").unwrap();
770 let result = calculate_new_version_with_zero_behavior(
771 &version,
772 Some(BumpType::Patch),
773 None,
774 ZeroVersionBehavior::EffectiveMinor,
775 true,
776 )
777 .unwrap();
778 assert_eq!(result, Version::parse("1.0.0").unwrap());
779 }
780
781 #[test]
782 fn behavior_ignored_when_graduating() {
783 let version = Version::parse("0.5.3").unwrap();
784 let result = calculate_new_version_with_zero_behavior(
785 &version,
786 None,
787 None,
788 ZeroVersionBehavior::AutoPromoteOnMajor,
789 true,
790 )
791 .unwrap();
792 assert_eq!(result, Version::parse("1.0.0").unwrap());
793 }
794 }
795 }
796}