1pub mod calver;
39pub mod cargo;
40pub mod gradle;
41pub mod json;
42pub mod pubspec;
43pub mod pyproject;
44pub mod regex_engine;
45pub mod toml_helpers;
46pub mod version_file;
47pub mod version_plain;
48
49pub use cargo::CargoVersionFile;
50pub use gradle::GradleVersionFile;
51pub use json::{DenoVersionFile, JsonVersionFile};
52pub use pubspec::PubspecVersionFile;
53pub use pyproject::PyprojectVersionFile;
54pub use regex_engine::RegexVersionFile;
55pub use version_file::{
56 CustomVersionFile, DetectedFile, UpdateResult, VersionFile, VersionFileError,
57 detect_version_files, update_version_files,
58};
59pub use version_plain::PlainVersionFile;
60
61use standard_commit::ConventionalCommit;
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
65pub enum BumpLevel {
66 Patch,
68 Minor,
70 Major,
72}
73
74pub fn determine_bump(commits: &[ConventionalCommit]) -> Option<BumpLevel> {
86 let mut level: Option<BumpLevel> = None;
87
88 for commit in commits {
89 let bump = commit_bump(commit);
90 if let Some(b) = bump {
91 level = Some(match level {
92 Some(current) => current.max(b),
93 None => b,
94 });
95 }
96 }
97
98 level
99}
100
101fn commit_bump(commit: &ConventionalCommit) -> Option<BumpLevel> {
103 if commit.is_breaking {
105 return Some(BumpLevel::Major);
106 }
107 for footer in &commit.footers {
108 if footer.token == "BREAKING CHANGE" || footer.token == "BREAKING-CHANGE" {
109 return Some(BumpLevel::Major);
110 }
111 }
112
113 match commit.r#type.as_str() {
114 "feat" => Some(BumpLevel::Minor),
115 "fix" | "perf" | "revert" => Some(BumpLevel::Patch),
116 _ => None,
117 }
118}
119
120pub fn apply_bump(current: &semver::Version, level: BumpLevel) -> semver::Version {
125 let mut next = current.clone();
126 next.pre = semver::Prerelease::EMPTY;
128 next.build = semver::BuildMetadata::EMPTY;
129
130 match level {
131 BumpLevel::Major => {
132 next.major += 1;
133 next.minor = 0;
134 next.patch = 0;
135 }
136 BumpLevel::Minor => {
137 next.minor += 1;
138 next.patch = 0;
139 }
140 BumpLevel::Patch => {
141 next.patch += 1;
142 }
143 }
144
145 next
146}
147
148pub fn apply_prerelease(current: &semver::Version, level: BumpLevel, tag: &str) -> semver::Version {
155 if !current.pre.is_empty() {
157 let pre_str = current.pre.as_str();
158 if let Some(rest) = pre_str.strip_prefix(tag)
159 && let Some(num_str) = rest.strip_prefix('.')
160 && let Ok(n) = num_str.parse::<u64>()
161 {
162 let mut next = current.clone();
163 next.pre = semver::Prerelease::new(&format!("{tag}.{}", n + 1)).unwrap_or_default();
164 next.build = semver::BuildMetadata::EMPTY;
165 return next;
166 }
167 }
168
169 let mut next = apply_bump(current, level);
171 next.pre = semver::Prerelease::new(&format!("{tag}.0")).unwrap_or_default();
172 next
173}
174
175#[derive(Debug, Default)]
181pub struct BumpSummary {
182 pub feat_count: usize,
184 pub fix_count: usize,
186 pub breaking_count: usize,
188 pub other_count: usize,
190}
191
192pub fn summarise(commits: &[ConventionalCommit]) -> BumpSummary {
194 let mut summary = BumpSummary::default();
195 for commit in commits {
196 let is_breaking = commit.is_breaking
197 || commit
198 .footers
199 .iter()
200 .any(|f| f.token == "BREAKING CHANGE" || f.token == "BREAKING-CHANGE");
201 if is_breaking {
202 summary.breaking_count += 1;
203 }
204 match commit.r#type.as_str() {
205 "feat" => summary.feat_count += 1,
206 "fix" => summary.fix_count += 1,
207 _ => summary.other_count += 1,
208 }
209 }
210 summary
211}
212
213pub fn replace_version_in_toml(
241 content: &str,
242 new_version: &str,
243) -> Result<String, VersionFileError> {
244 CargoVersionFile.write_version(content, new_version)
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use standard_commit::Footer;
251
252 fn commit(typ: &str, breaking: bool) -> ConventionalCommit {
253 ConventionalCommit {
254 r#type: typ.to_string(),
255 scope: None,
256 description: "test".to_string(),
257 body: None,
258 footers: vec![],
259 is_breaking: breaking,
260 }
261 }
262
263 fn commit_with_footer(typ: &str, footer_token: &str) -> ConventionalCommit {
264 ConventionalCommit {
265 r#type: typ.to_string(),
266 scope: None,
267 description: "test".to_string(),
268 body: None,
269 footers: vec![Footer {
270 token: footer_token.to_string(),
271 value: "some breaking change".to_string(),
272 }],
273 is_breaking: false,
274 }
275 }
276
277 #[test]
278 fn no_commits_returns_none() {
279 assert_eq!(determine_bump(&[]), None);
280 }
281
282 #[test]
283 fn non_bump_commits_return_none() {
284 let commits = vec![commit("chore", false), commit("docs", false)];
285 assert_eq!(determine_bump(&commits), None);
286 }
287
288 #[test]
289 fn fix_yields_patch() {
290 let commits = vec![commit("fix", false)];
291 assert_eq!(determine_bump(&commits), Some(BumpLevel::Patch));
292 }
293
294 #[test]
295 fn perf_yields_patch() {
296 let commits = vec![commit("perf", false)];
297 assert_eq!(determine_bump(&commits), Some(BumpLevel::Patch));
298 }
299
300 #[test]
301 fn feat_yields_minor() {
302 let commits = vec![commit("feat", false)];
303 assert_eq!(determine_bump(&commits), Some(BumpLevel::Minor));
304 }
305
306 #[test]
307 fn breaking_bang_yields_major() {
308 let commits = vec![commit("feat", true)];
309 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
310 }
311
312 #[test]
313 fn breaking_footer_yields_major() {
314 let commits = vec![commit_with_footer("fix", "BREAKING CHANGE")];
315 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
316 }
317
318 #[test]
319 fn breaking_change_hyphenated_footer() {
320 let commits = vec![commit_with_footer("fix", "BREAKING-CHANGE")];
321 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
322 }
323
324 #[test]
325 fn highest_bump_wins() {
326 let commits = vec![commit("fix", false), commit("feat", false)];
327 assert_eq!(determine_bump(&commits), Some(BumpLevel::Minor));
328 }
329
330 #[test]
331 fn breaking_beats_all() {
332 let commits = vec![
333 commit("fix", false),
334 commit("feat", false),
335 commit("chore", true),
336 ];
337 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
338 }
339
340 #[test]
341 fn apply_bump_patch() {
342 let v = semver::Version::new(1, 2, 3);
343 assert_eq!(
344 apply_bump(&v, BumpLevel::Patch),
345 semver::Version::new(1, 2, 4)
346 );
347 }
348
349 #[test]
350 fn apply_bump_minor() {
351 let v = semver::Version::new(1, 2, 3);
352 assert_eq!(
353 apply_bump(&v, BumpLevel::Minor),
354 semver::Version::new(1, 3, 0)
355 );
356 }
357
358 #[test]
359 fn apply_bump_major() {
360 let v = semver::Version::new(1, 2, 3);
361 assert_eq!(
362 apply_bump(&v, BumpLevel::Major),
363 semver::Version::new(2, 0, 0)
364 );
365 }
366
367 #[test]
368 fn apply_bump_clears_prerelease() {
369 let v = semver::Version::parse("1.2.3-rc.1").unwrap();
370 assert_eq!(
371 apply_bump(&v, BumpLevel::Patch),
372 semver::Version::new(1, 2, 4)
373 );
374 }
375
376 #[test]
377 fn apply_prerelease_new() {
378 let v = semver::Version::new(1, 0, 0);
379 let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
380 assert_eq!(next, semver::Version::parse("1.1.0-rc.0").unwrap());
381 }
382
383 #[test]
384 fn apply_prerelease_increment() {
385 let v = semver::Version::parse("1.1.0-rc.0").unwrap();
386 let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
387 assert_eq!(next, semver::Version::parse("1.1.0-rc.1").unwrap());
388 }
389
390 #[test]
391 fn apply_prerelease_different_tag() {
392 let v = semver::Version::parse("1.1.0-alpha.2").unwrap();
393 let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
394 assert_eq!(next, semver::Version::parse("1.2.0-rc.0").unwrap());
396 }
397
398 #[test]
399 fn summarise_counts() {
400 let commits = vec![
401 commit("feat", false),
402 commit("feat", false),
403 commit("fix", false),
404 commit("chore", true),
405 commit("refactor", false),
406 ];
407 let s = summarise(&commits);
408 assert_eq!(s.feat_count, 2);
409 assert_eq!(s.fix_count, 1);
410 assert_eq!(s.breaking_count, 1);
411 assert_eq!(s.other_count, 2); }
413
414 #[test]
415 fn bump_level_ordering() {
416 assert!(BumpLevel::Major > BumpLevel::Minor);
417 assert!(BumpLevel::Minor > BumpLevel::Patch);
418 }
419
420 #[test]
421 fn replace_version_in_toml_basic() {
422 let input = r#"[package]
423name = "my-crate"
424version = "0.1.0"
425edition = "2021"
426"#;
427 let result = replace_version_in_toml(input, "1.0.0").unwrap();
428 assert!(result.contains("version = \"1.0.0\""));
429 assert!(result.contains("name = \"my-crate\""));
430 assert!(result.contains("edition = \"2021\""));
431 }
432
433 #[test]
434 fn replace_version_only_in_package_section() {
435 let input = r#"[package]
436name = "my-crate"
437version = "0.1.0"
438
439[dependencies]
440foo = { version = "1.0" }
441"#;
442 let result = replace_version_in_toml(input, "2.0.0").unwrap();
443 assert!(result.contains("[package]"));
444 assert!(result.contains("version = \"2.0.0\""));
445 assert!(result.contains("foo = { version = \"1.0\" }"));
447 }
448}