use standard_commit::ConventionalCommit;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum BumpLevel {
Patch,
Minor,
Major,
}
pub fn determine_bump(commits: &[ConventionalCommit]) -> Option<BumpLevel> {
let mut level: Option<BumpLevel> = None;
for commit in commits {
let bump = commit_bump(commit);
if let Some(b) = bump {
level = Some(match level {
Some(current) => current.max(b),
None => b,
});
}
}
level
}
fn commit_bump(commit: &ConventionalCommit) -> Option<BumpLevel> {
if commit.is_breaking {
return Some(BumpLevel::Major);
}
for footer in &commit.footers {
if footer.token == "BREAKING CHANGE" || footer.token == "BREAKING-CHANGE" {
return Some(BumpLevel::Major);
}
}
match commit.r#type.as_str() {
"feat" => Some(BumpLevel::Minor),
"fix" | "perf" | "revert" => Some(BumpLevel::Patch),
_ => None,
}
}
pub fn apply_bump(current: &semver::Version, level: BumpLevel) -> semver::Version {
let mut next = current.clone();
next.pre = semver::Prerelease::EMPTY;
next.build = semver::BuildMetadata::EMPTY;
let effective = if current.major == 0 {
match level {
BumpLevel::Major => BumpLevel::Minor,
BumpLevel::Minor | BumpLevel::Patch => BumpLevel::Patch,
}
} else {
level
};
match effective {
BumpLevel::Major => {
next.major += 1;
next.minor = 0;
next.patch = 0;
}
BumpLevel::Minor => {
next.minor += 1;
next.patch = 0;
}
BumpLevel::Patch => {
next.patch += 1;
}
}
next
}
pub fn apply_prerelease(current: &semver::Version, level: BumpLevel, tag: &str) -> semver::Version {
if !current.pre.is_empty() {
let pre_str = current.pre.as_str();
if let Some(rest) = pre_str.strip_prefix(tag)
&& let Some(num_str) = rest.strip_prefix('.')
&& let Ok(n) = num_str.parse::<u64>()
{
let mut next = current.clone();
next.pre = semver::Prerelease::new(&format!("{tag}.{}", n + 1)).unwrap_or_default();
next.build = semver::BuildMetadata::EMPTY;
return next;
}
}
let mut next = apply_bump(current, level);
next.pre = semver::Prerelease::new(&format!("{tag}.0")).unwrap_or_default();
next
}
#[derive(Debug, Default)]
pub struct BumpSummary {
pub feat_count: usize,
pub fix_count: usize,
pub breaking_count: usize,
pub other_count: usize,
}
pub fn summarise(commits: &[ConventionalCommit]) -> BumpSummary {
let mut summary = BumpSummary::default();
for commit in commits {
let is_breaking = commit.is_breaking
|| commit
.footers
.iter()
.any(|f| f.token == "BREAKING CHANGE" || f.token == "BREAKING-CHANGE");
if is_breaking {
summary.breaking_count += 1;
}
match commit.r#type.as_str() {
"feat" => summary.feat_count += 1,
"fix" => summary.fix_count += 1,
_ => summary.other_count += 1,
}
}
summary
}
#[cfg(test)]
mod tests {
use super::*;
use standard_commit::Footer;
fn commit(typ: &str, breaking: bool) -> ConventionalCommit {
ConventionalCommit {
r#type: typ.to_string(),
scope: None,
description: "test".to_string(),
body: None,
footers: vec![],
is_breaking: breaking,
}
}
fn commit_with_footer(typ: &str, footer_token: &str) -> ConventionalCommit {
ConventionalCommit {
r#type: typ.to_string(),
scope: None,
description: "test".to_string(),
body: None,
footers: vec![Footer {
token: footer_token.to_string(),
value: "some breaking change".to_string(),
}],
is_breaking: false,
}
}
#[test]
fn no_commits_returns_none() {
assert_eq!(determine_bump(&[]), None);
}
#[test]
fn non_bump_commits_return_none() {
let commits = vec![commit("chore", false), commit("docs", false)];
assert_eq!(determine_bump(&commits), None);
}
#[test]
fn fix_yields_patch() {
let commits = vec![commit("fix", false)];
assert_eq!(determine_bump(&commits), Some(BumpLevel::Patch));
}
#[test]
fn perf_yields_patch() {
let commits = vec![commit("perf", false)];
assert_eq!(determine_bump(&commits), Some(BumpLevel::Patch));
}
#[test]
fn feat_yields_minor() {
let commits = vec![commit("feat", false)];
assert_eq!(determine_bump(&commits), Some(BumpLevel::Minor));
}
#[test]
fn breaking_bang_yields_major() {
let commits = vec![commit("feat", true)];
assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
}
#[test]
fn breaking_footer_yields_major() {
let commits = vec![commit_with_footer("fix", "BREAKING CHANGE")];
assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
}
#[test]
fn breaking_change_hyphenated_footer() {
let commits = vec![commit_with_footer("fix", "BREAKING-CHANGE")];
assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
}
#[test]
fn highest_bump_wins() {
let commits = vec![commit("fix", false), commit("feat", false)];
assert_eq!(determine_bump(&commits), Some(BumpLevel::Minor));
}
#[test]
fn breaking_beats_all() {
let commits = vec![
commit("fix", false),
commit("feat", false),
commit("chore", true),
];
assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
}
#[test]
fn apply_bump_patch() {
let v = semver::Version::new(1, 2, 3);
assert_eq!(
apply_bump(&v, BumpLevel::Patch),
semver::Version::new(1, 2, 4)
);
}
#[test]
fn apply_bump_minor() {
let v = semver::Version::new(1, 2, 3);
assert_eq!(
apply_bump(&v, BumpLevel::Minor),
semver::Version::new(1, 3, 0)
);
}
#[test]
fn apply_bump_major() {
let v = semver::Version::new(1, 2, 3);
assert_eq!(
apply_bump(&v, BumpLevel::Major),
semver::Version::new(2, 0, 0)
);
}
#[test]
fn apply_bump_clears_prerelease() {
let v = semver::Version::parse("1.2.3-rc.1").unwrap();
assert_eq!(
apply_bump(&v, BumpLevel::Patch),
semver::Version::new(1, 2, 4)
);
}
#[test]
fn apply_prerelease_new() {
let v = semver::Version::new(1, 0, 0);
let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
assert_eq!(next, semver::Version::parse("1.1.0-rc.0").unwrap());
}
#[test]
fn apply_prerelease_increment() {
let v = semver::Version::parse("1.1.0-rc.0").unwrap();
let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
assert_eq!(next, semver::Version::parse("1.1.0-rc.1").unwrap());
}
#[test]
fn apply_prerelease_different_tag() {
let v = semver::Version::parse("1.1.0-alpha.2").unwrap();
let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
assert_eq!(next, semver::Version::parse("1.2.0-rc.0").unwrap());
}
#[test]
fn summarise_counts() {
let commits = vec![
commit("feat", false),
commit("feat", false),
commit("fix", false),
commit("chore", true),
commit("refactor", false),
];
let s = summarise(&commits);
assert_eq!(s.feat_count, 2);
assert_eq!(s.fix_count, 1);
assert_eq!(s.breaking_count, 1);
assert_eq!(s.other_count, 2); }
#[test]
fn bump_level_ordering() {
assert!(BumpLevel::Major > BumpLevel::Minor);
assert!(BumpLevel::Minor > BumpLevel::Patch);
}
#[test]
fn pre1_breaking_bumps_minor() {
let v = semver::Version::new(0, 10, 2);
assert_eq!(
apply_bump(&v, BumpLevel::Major),
semver::Version::new(0, 11, 0)
);
}
#[test]
fn pre1_feat_bumps_patch() {
let v = semver::Version::new(0, 10, 2);
assert_eq!(
apply_bump(&v, BumpLevel::Minor),
semver::Version::new(0, 10, 3)
);
}
#[test]
fn pre1_fix_bumps_patch() {
let v = semver::Version::new(0, 10, 2);
assert_eq!(
apply_bump(&v, BumpLevel::Patch),
semver::Version::new(0, 10, 3)
);
}
#[test]
fn pre1_zero_minor_breaking_bumps_minor() {
let v = semver::Version::new(0, 0, 5);
assert_eq!(
apply_bump(&v, BumpLevel::Major),
semver::Version::new(0, 1, 0)
);
}
#[test]
fn pre1_zero_minor_feat_bumps_patch() {
let v = semver::Version::new(0, 0, 5);
assert_eq!(
apply_bump(&v, BumpLevel::Minor),
semver::Version::new(0, 0, 6)
);
}
#[test]
fn post1_major_unchanged() {
let v = semver::Version::new(1, 2, 3);
assert_eq!(
apply_bump(&v, BumpLevel::Major),
semver::Version::new(2, 0, 0)
);
}
#[test]
fn pre1_clears_prerelease_metadata() {
let v = semver::Version::parse("0.3.0-rc.2").unwrap();
assert_eq!(
apply_bump(&v, BumpLevel::Major),
semver::Version::new(0, 4, 0)
);
}
#[test]
fn pre1_prerelease_breaking() {
let v = semver::Version::new(0, 5, 0);
let next = apply_prerelease(&v, BumpLevel::Major, "rc");
assert_eq!(next, semver::Version::parse("0.6.0-rc.0").unwrap());
}
#[test]
fn pre1_prerelease_feat() {
let v = semver::Version::new(0, 5, 0);
let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
assert_eq!(next, semver::Version::parse("0.5.1-rc.0").unwrap());
}
}