1use standard_commit::ConventionalCommit;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
35pub enum BumpLevel {
36 Patch,
38 Minor,
40 Major,
42}
43
44pub fn determine_bump(commits: &[ConventionalCommit]) -> Option<BumpLevel> {
56 let mut level: Option<BumpLevel> = None;
57
58 for commit in commits {
59 let bump = commit_bump(commit);
60 if let Some(b) = bump {
61 level = Some(match level {
62 Some(current) => current.max(b),
63 None => b,
64 });
65 }
66 }
67
68 level
69}
70
71fn commit_bump(commit: &ConventionalCommit) -> Option<BumpLevel> {
73 if commit.is_breaking {
75 return Some(BumpLevel::Major);
76 }
77 for footer in &commit.footers {
78 if footer.token == "BREAKING CHANGE" || footer.token == "BREAKING-CHANGE" {
79 return Some(BumpLevel::Major);
80 }
81 }
82
83 match commit.r#type.as_str() {
84 "feat" => Some(BumpLevel::Minor),
85 "fix" | "perf" => Some(BumpLevel::Patch),
86 _ => None,
87 }
88}
89
90pub fn apply_bump(current: &semver::Version, level: BumpLevel) -> semver::Version {
95 let mut next = current.clone();
96 next.pre = semver::Prerelease::EMPTY;
98 next.build = semver::BuildMetadata::EMPTY;
99
100 match level {
101 BumpLevel::Major => {
102 next.major += 1;
103 next.minor = 0;
104 next.patch = 0;
105 }
106 BumpLevel::Minor => {
107 next.minor += 1;
108 next.patch = 0;
109 }
110 BumpLevel::Patch => {
111 next.patch += 1;
112 }
113 }
114
115 next
116}
117
118pub fn apply_prerelease(current: &semver::Version, level: BumpLevel, tag: &str) -> semver::Version {
125 if !current.pre.is_empty() {
127 let pre_str = current.pre.as_str();
128 if let Some(rest) = pre_str.strip_prefix(tag)
129 && let Some(num_str) = rest.strip_prefix('.')
130 && let Ok(n) = num_str.parse::<u64>()
131 {
132 let mut next = current.clone();
133 next.pre = semver::Prerelease::new(&format!("{tag}.{}", n + 1)).unwrap_or_default();
134 next.build = semver::BuildMetadata::EMPTY;
135 return next;
136 }
137 }
138
139 let mut next = apply_bump(current, level);
141 next.pre = semver::Prerelease::new(&format!("{tag}.0")).unwrap_or_default();
142 next
143}
144
145#[derive(Debug, Default)]
151pub struct BumpSummary {
152 pub feat_count: usize,
154 pub fix_count: usize,
156 pub breaking_count: usize,
158 pub other_count: usize,
160}
161
162pub fn summarise(commits: &[ConventionalCommit]) -> BumpSummary {
164 let mut summary = BumpSummary::default();
165 for commit in commits {
166 let is_breaking = commit.is_breaking
167 || commit
168 .footers
169 .iter()
170 .any(|f| f.token == "BREAKING CHANGE" || f.token == "BREAKING-CHANGE");
171 if is_breaking {
172 summary.breaking_count += 1;
173 }
174 match commit.r#type.as_str() {
175 "feat" => summary.feat_count += 1,
176 "fix" => summary.fix_count += 1,
177 _ => summary.other_count += 1,
178 }
179 }
180 summary
181}
182
183pub fn replace_version_in_toml(
211 content: &str,
212 new_version: &str,
213) -> Result<String, Box<dyn std::error::Error>> {
214 let mut in_package = false;
215 let mut result = String::new();
216 let mut replaced = false;
217
218 for line in content.lines() {
219 let trimmed = line.trim();
220 if trimmed == "[package]" {
221 in_package = true;
222 } else if trimmed.starts_with('[') {
223 in_package = false;
224 }
225
226 if in_package
227 && !replaced
228 && trimmed.starts_with("version")
229 && let Some(eq_pos) = line.find('=')
230 {
231 let prefix = &line[..=eq_pos];
232 result.push_str(prefix);
233 result.push_str(&format!(" \"{new_version}\""));
234 result.push('\n');
235 replaced = true;
236 continue;
237 }
238
239 result.push_str(line);
240 result.push('\n');
241 }
242
243 if !replaced {
244 return Err("could not find version field in [package] section".into());
245 }
246
247 if !content.ends_with('\n') && result.ends_with('\n') {
249 result.pop();
250 }
251
252 Ok(result)
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use standard_commit::Footer;
259
260 fn commit(typ: &str, breaking: bool) -> ConventionalCommit {
261 ConventionalCommit {
262 r#type: typ.to_string(),
263 scope: None,
264 description: "test".to_string(),
265 body: None,
266 footers: vec![],
267 is_breaking: breaking,
268 }
269 }
270
271 fn commit_with_footer(typ: &str, footer_token: &str) -> ConventionalCommit {
272 ConventionalCommit {
273 r#type: typ.to_string(),
274 scope: None,
275 description: "test".to_string(),
276 body: None,
277 footers: vec![Footer {
278 token: footer_token.to_string(),
279 value: "some breaking change".to_string(),
280 }],
281 is_breaking: false,
282 }
283 }
284
285 #[test]
286 fn no_commits_returns_none() {
287 assert_eq!(determine_bump(&[]), None);
288 }
289
290 #[test]
291 fn non_bump_commits_return_none() {
292 let commits = vec![commit("chore", false), commit("docs", false)];
293 assert_eq!(determine_bump(&commits), None);
294 }
295
296 #[test]
297 fn fix_yields_patch() {
298 let commits = vec![commit("fix", false)];
299 assert_eq!(determine_bump(&commits), Some(BumpLevel::Patch));
300 }
301
302 #[test]
303 fn perf_yields_patch() {
304 let commits = vec![commit("perf", false)];
305 assert_eq!(determine_bump(&commits), Some(BumpLevel::Patch));
306 }
307
308 #[test]
309 fn feat_yields_minor() {
310 let commits = vec![commit("feat", false)];
311 assert_eq!(determine_bump(&commits), Some(BumpLevel::Minor));
312 }
313
314 #[test]
315 fn breaking_bang_yields_major() {
316 let commits = vec![commit("feat", true)];
317 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
318 }
319
320 #[test]
321 fn breaking_footer_yields_major() {
322 let commits = vec![commit_with_footer("fix", "BREAKING CHANGE")];
323 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
324 }
325
326 #[test]
327 fn breaking_change_hyphenated_footer() {
328 let commits = vec![commit_with_footer("fix", "BREAKING-CHANGE")];
329 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
330 }
331
332 #[test]
333 fn highest_bump_wins() {
334 let commits = vec![commit("fix", false), commit("feat", false)];
335 assert_eq!(determine_bump(&commits), Some(BumpLevel::Minor));
336 }
337
338 #[test]
339 fn breaking_beats_all() {
340 let commits = vec![
341 commit("fix", false),
342 commit("feat", false),
343 commit("chore", true),
344 ];
345 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
346 }
347
348 #[test]
349 fn apply_bump_patch() {
350 let v = semver::Version::new(1, 2, 3);
351 assert_eq!(
352 apply_bump(&v, BumpLevel::Patch),
353 semver::Version::new(1, 2, 4)
354 );
355 }
356
357 #[test]
358 fn apply_bump_minor() {
359 let v = semver::Version::new(1, 2, 3);
360 assert_eq!(
361 apply_bump(&v, BumpLevel::Minor),
362 semver::Version::new(1, 3, 0)
363 );
364 }
365
366 #[test]
367 fn apply_bump_major() {
368 let v = semver::Version::new(1, 2, 3);
369 assert_eq!(
370 apply_bump(&v, BumpLevel::Major),
371 semver::Version::new(2, 0, 0)
372 );
373 }
374
375 #[test]
376 fn apply_bump_clears_prerelease() {
377 let v = semver::Version::parse("1.2.3-rc.1").unwrap();
378 assert_eq!(
379 apply_bump(&v, BumpLevel::Patch),
380 semver::Version::new(1, 2, 4)
381 );
382 }
383
384 #[test]
385 fn apply_prerelease_new() {
386 let v = semver::Version::new(1, 0, 0);
387 let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
388 assert_eq!(next, semver::Version::parse("1.1.0-rc.0").unwrap());
389 }
390
391 #[test]
392 fn apply_prerelease_increment() {
393 let v = semver::Version::parse("1.1.0-rc.0").unwrap();
394 let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
395 assert_eq!(next, semver::Version::parse("1.1.0-rc.1").unwrap());
396 }
397
398 #[test]
399 fn apply_prerelease_different_tag() {
400 let v = semver::Version::parse("1.1.0-alpha.2").unwrap();
401 let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
402 assert_eq!(next, semver::Version::parse("1.2.0-rc.0").unwrap());
404 }
405
406 #[test]
407 fn summarise_counts() {
408 let commits = vec![
409 commit("feat", false),
410 commit("feat", false),
411 commit("fix", false),
412 commit("chore", true),
413 commit("refactor", false),
414 ];
415 let s = summarise(&commits);
416 assert_eq!(s.feat_count, 2);
417 assert_eq!(s.fix_count, 1);
418 assert_eq!(s.breaking_count, 1);
419 assert_eq!(s.other_count, 2); }
421
422 #[test]
423 fn bump_level_ordering() {
424 assert!(BumpLevel::Major > BumpLevel::Minor);
425 assert!(BumpLevel::Minor > BumpLevel::Patch);
426 }
427
428 #[test]
429 fn replace_version_in_toml_basic() {
430 let input = r#"[package]
431name = "my-crate"
432version = "0.1.0"
433edition = "2021"
434"#;
435 let result = replace_version_in_toml(input, "1.0.0").unwrap();
436 assert!(result.contains("version = \"1.0.0\""));
437 assert!(result.contains("name = \"my-crate\""));
438 assert!(result.contains("edition = \"2021\""));
439 }
440
441 #[test]
442 fn replace_version_only_in_package_section() {
443 let input = r#"[package]
444name = "my-crate"
445version = "0.1.0"
446
447[dependencies]
448foo = { version = "1.0" }
449"#;
450 let result = replace_version_in_toml(input, "2.0.0").unwrap();
451 assert!(result.contains("[package]"));
452 assert!(result.contains("version = \"2.0.0\""));
453 assert!(result.contains("foo = { version = \"1.0\" }"));
455 }
456}