1use standard_commit::ConventionalCommit;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
17pub enum BumpLevel {
18 Patch,
20 Minor,
22 Major,
24}
25
26pub fn determine_bump(commits: &[ConventionalCommit]) -> Option<BumpLevel> {
38 let mut level: Option<BumpLevel> = None;
39
40 for commit in commits {
41 let bump = commit_bump(commit);
42 if let Some(b) = bump {
43 level = Some(match level {
44 Some(current) => current.max(b),
45 None => b,
46 });
47 }
48 }
49
50 level
51}
52
53fn commit_bump(commit: &ConventionalCommit) -> Option<BumpLevel> {
55 if commit.is_breaking {
57 return Some(BumpLevel::Major);
58 }
59 for footer in &commit.footers {
60 if footer.token == "BREAKING CHANGE" || footer.token == "BREAKING-CHANGE" {
61 return Some(BumpLevel::Major);
62 }
63 }
64
65 match commit.r#type.as_str() {
66 "feat" => Some(BumpLevel::Minor),
67 "fix" | "perf" | "revert" => Some(BumpLevel::Patch),
68 _ => None,
69 }
70}
71
72pub fn apply_bump(current: &semver::Version, level: BumpLevel) -> semver::Version {
84 let mut next = current.clone();
85 next.pre = semver::Prerelease::EMPTY;
87 next.build = semver::BuildMetadata::EMPTY;
88
89 let effective = if current.major == 0 {
91 match level {
92 BumpLevel::Major => BumpLevel::Minor,
93 BumpLevel::Minor | BumpLevel::Patch => BumpLevel::Patch,
94 }
95 } else {
96 level
97 };
98
99 match effective {
100 BumpLevel::Major => {
101 next.major += 1;
102 next.minor = 0;
103 next.patch = 0;
104 }
105 BumpLevel::Minor => {
106 next.minor += 1;
107 next.patch = 0;
108 }
109 BumpLevel::Patch => {
110 next.patch += 1;
111 }
112 }
113
114 next
115}
116
117pub fn apply_prerelease(current: &semver::Version, level: BumpLevel, tag: &str) -> semver::Version {
124 if !current.pre.is_empty() {
126 let pre_str = current.pre.as_str();
127 if let Some(rest) = pre_str.strip_prefix(tag)
128 && let Some(num_str) = rest.strip_prefix('.')
129 && let Ok(n) = num_str.parse::<u64>()
130 {
131 let mut next = current.clone();
132 next.pre = semver::Prerelease::new(&format!("{tag}.{}", n + 1)).unwrap_or_default();
133 next.build = semver::BuildMetadata::EMPTY;
134 return next;
135 }
136 }
137
138 let mut next = apply_bump(current, level);
140 next.pre = semver::Prerelease::new(&format!("{tag}.0")).unwrap_or_default();
141 next
142}
143
144#[derive(Debug, Default)]
150pub struct BumpSummary {
151 pub feat_count: usize,
153 pub fix_count: usize,
155 pub breaking_count: usize,
157 pub other_count: usize,
159}
160
161pub fn summarise(commits: &[ConventionalCommit]) -> BumpSummary {
163 let mut summary = BumpSummary::default();
164 for commit in commits {
165 let is_breaking = commit.is_breaking
166 || commit
167 .footers
168 .iter()
169 .any(|f| f.token == "BREAKING CHANGE" || f.token == "BREAKING-CHANGE");
170 if is_breaking {
171 summary.breaking_count += 1;
172 }
173 match commit.r#type.as_str() {
174 "feat" => summary.feat_count += 1,
175 "fix" => summary.fix_count += 1,
176 _ => summary.other_count += 1,
177 }
178 }
179 summary
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use standard_commit::Footer;
186
187 fn commit(typ: &str, breaking: bool) -> ConventionalCommit {
188 ConventionalCommit {
189 r#type: typ.to_string(),
190 scope: None,
191 description: "test".to_string(),
192 body: None,
193 footers: vec![],
194 is_breaking: breaking,
195 }
196 }
197
198 fn commit_with_footer(typ: &str, footer_token: &str) -> ConventionalCommit {
199 ConventionalCommit {
200 r#type: typ.to_string(),
201 scope: None,
202 description: "test".to_string(),
203 body: None,
204 footers: vec![Footer {
205 token: footer_token.to_string(),
206 value: "some breaking change".to_string(),
207 }],
208 is_breaking: false,
209 }
210 }
211
212 #[test]
213 fn no_commits_returns_none() {
214 assert_eq!(determine_bump(&[]), None);
215 }
216
217 #[test]
218 fn non_bump_commits_return_none() {
219 let commits = vec![commit("chore", false), commit("docs", false)];
220 assert_eq!(determine_bump(&commits), None);
221 }
222
223 #[test]
224 fn fix_yields_patch() {
225 let commits = vec![commit("fix", false)];
226 assert_eq!(determine_bump(&commits), Some(BumpLevel::Patch));
227 }
228
229 #[test]
230 fn perf_yields_patch() {
231 let commits = vec![commit("perf", false)];
232 assert_eq!(determine_bump(&commits), Some(BumpLevel::Patch));
233 }
234
235 #[test]
236 fn feat_yields_minor() {
237 let commits = vec![commit("feat", false)];
238 assert_eq!(determine_bump(&commits), Some(BumpLevel::Minor));
239 }
240
241 #[test]
242 fn breaking_bang_yields_major() {
243 let commits = vec![commit("feat", true)];
244 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
245 }
246
247 #[test]
248 fn breaking_footer_yields_major() {
249 let commits = vec![commit_with_footer("fix", "BREAKING CHANGE")];
250 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
251 }
252
253 #[test]
254 fn breaking_change_hyphenated_footer() {
255 let commits = vec![commit_with_footer("fix", "BREAKING-CHANGE")];
256 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
257 }
258
259 #[test]
260 fn highest_bump_wins() {
261 let commits = vec![commit("fix", false), commit("feat", false)];
262 assert_eq!(determine_bump(&commits), Some(BumpLevel::Minor));
263 }
264
265 #[test]
266 fn breaking_beats_all() {
267 let commits = vec![
268 commit("fix", false),
269 commit("feat", false),
270 commit("chore", true),
271 ];
272 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
273 }
274
275 #[test]
276 fn apply_bump_patch() {
277 let v = semver::Version::new(1, 2, 3);
278 assert_eq!(
279 apply_bump(&v, BumpLevel::Patch),
280 semver::Version::new(1, 2, 4)
281 );
282 }
283
284 #[test]
285 fn apply_bump_minor() {
286 let v = semver::Version::new(1, 2, 3);
287 assert_eq!(
288 apply_bump(&v, BumpLevel::Minor),
289 semver::Version::new(1, 3, 0)
290 );
291 }
292
293 #[test]
294 fn apply_bump_major() {
295 let v = semver::Version::new(1, 2, 3);
296 assert_eq!(
297 apply_bump(&v, BumpLevel::Major),
298 semver::Version::new(2, 0, 0)
299 );
300 }
301
302 #[test]
303 fn apply_bump_clears_prerelease() {
304 let v = semver::Version::parse("1.2.3-rc.1").unwrap();
305 assert_eq!(
306 apply_bump(&v, BumpLevel::Patch),
307 semver::Version::new(1, 2, 4)
308 );
309 }
310
311 #[test]
312 fn apply_prerelease_new() {
313 let v = semver::Version::new(1, 0, 0);
314 let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
315 assert_eq!(next, semver::Version::parse("1.1.0-rc.0").unwrap());
316 }
317
318 #[test]
319 fn apply_prerelease_increment() {
320 let v = semver::Version::parse("1.1.0-rc.0").unwrap();
321 let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
322 assert_eq!(next, semver::Version::parse("1.1.0-rc.1").unwrap());
323 }
324
325 #[test]
326 fn apply_prerelease_different_tag() {
327 let v = semver::Version::parse("1.1.0-alpha.2").unwrap();
328 let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
329 assert_eq!(next, semver::Version::parse("1.2.0-rc.0").unwrap());
331 }
332
333 #[test]
334 fn summarise_counts() {
335 let commits = vec![
336 commit("feat", false),
337 commit("feat", false),
338 commit("fix", false),
339 commit("chore", true),
340 commit("refactor", false),
341 ];
342 let s = summarise(&commits);
343 assert_eq!(s.feat_count, 2);
344 assert_eq!(s.fix_count, 1);
345 assert_eq!(s.breaking_count, 1);
346 assert_eq!(s.other_count, 2); }
348
349 #[test]
350 fn bump_level_ordering() {
351 assert!(BumpLevel::Major > BumpLevel::Minor);
352 assert!(BumpLevel::Minor > BumpLevel::Patch);
353 }
354
355 #[test]
358 fn pre1_breaking_bumps_minor() {
359 let v = semver::Version::new(0, 10, 2);
360 assert_eq!(
361 apply_bump(&v, BumpLevel::Major),
362 semver::Version::new(0, 11, 0)
363 );
364 }
365
366 #[test]
367 fn pre1_feat_bumps_patch() {
368 let v = semver::Version::new(0, 10, 2);
369 assert_eq!(
370 apply_bump(&v, BumpLevel::Minor),
371 semver::Version::new(0, 10, 3)
372 );
373 }
374
375 #[test]
376 fn pre1_fix_bumps_patch() {
377 let v = semver::Version::new(0, 10, 2);
378 assert_eq!(
379 apply_bump(&v, BumpLevel::Patch),
380 semver::Version::new(0, 10, 3)
381 );
382 }
383
384 #[test]
385 fn pre1_zero_minor_breaking_bumps_minor() {
386 let v = semver::Version::new(0, 0, 5);
387 assert_eq!(
388 apply_bump(&v, BumpLevel::Major),
389 semver::Version::new(0, 1, 0)
390 );
391 }
392
393 #[test]
394 fn pre1_zero_minor_feat_bumps_patch() {
395 let v = semver::Version::new(0, 0, 5);
396 assert_eq!(
397 apply_bump(&v, BumpLevel::Minor),
398 semver::Version::new(0, 0, 6)
399 );
400 }
401
402 #[test]
403 fn post1_major_unchanged() {
404 let v = semver::Version::new(1, 2, 3);
405 assert_eq!(
406 apply_bump(&v, BumpLevel::Major),
407 semver::Version::new(2, 0, 0)
408 );
409 }
410
411 #[test]
412 fn pre1_clears_prerelease_metadata() {
413 let v = semver::Version::parse("0.3.0-rc.2").unwrap();
414 assert_eq!(
415 apply_bump(&v, BumpLevel::Major),
416 semver::Version::new(0, 4, 0)
417 );
418 }
419
420 #[test]
421 fn pre1_prerelease_breaking() {
422 let v = semver::Version::new(0, 5, 0);
423 let next = apply_prerelease(&v, BumpLevel::Major, "rc");
424 assert_eq!(next, semver::Version::parse("0.6.0-rc.0").unwrap());
425 }
426
427 #[test]
428 fn pre1_prerelease_feat() {
429 let v = semver::Version::new(0, 5, 0);
430 let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
431 assert_eq!(next, semver::Version::parse("0.5.1-rc.0").unwrap());
432 }
433}