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" => 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, Box<dyn std::error::Error>> {
244 CargoVersionFile
245 .write_version(content, new_version)
246 .map_err(|e| e.to_string().into())
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use standard_commit::Footer;
253
254 fn commit(typ: &str, breaking: bool) -> ConventionalCommit {
255 ConventionalCommit {
256 r#type: typ.to_string(),
257 scope: None,
258 description: "test".to_string(),
259 body: None,
260 footers: vec![],
261 is_breaking: breaking,
262 }
263 }
264
265 fn commit_with_footer(typ: &str, footer_token: &str) -> ConventionalCommit {
266 ConventionalCommit {
267 r#type: typ.to_string(),
268 scope: None,
269 description: "test".to_string(),
270 body: None,
271 footers: vec![Footer {
272 token: footer_token.to_string(),
273 value: "some breaking change".to_string(),
274 }],
275 is_breaking: false,
276 }
277 }
278
279 #[test]
280 fn no_commits_returns_none() {
281 assert_eq!(determine_bump(&[]), None);
282 }
283
284 #[test]
285 fn non_bump_commits_return_none() {
286 let commits = vec![commit("chore", false), commit("docs", false)];
287 assert_eq!(determine_bump(&commits), None);
288 }
289
290 #[test]
291 fn fix_yields_patch() {
292 let commits = vec![commit("fix", false)];
293 assert_eq!(determine_bump(&commits), Some(BumpLevel::Patch));
294 }
295
296 #[test]
297 fn perf_yields_patch() {
298 let commits = vec![commit("perf", false)];
299 assert_eq!(determine_bump(&commits), Some(BumpLevel::Patch));
300 }
301
302 #[test]
303 fn feat_yields_minor() {
304 let commits = vec![commit("feat", false)];
305 assert_eq!(determine_bump(&commits), Some(BumpLevel::Minor));
306 }
307
308 #[test]
309 fn breaking_bang_yields_major() {
310 let commits = vec![commit("feat", true)];
311 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
312 }
313
314 #[test]
315 fn breaking_footer_yields_major() {
316 let commits = vec![commit_with_footer("fix", "BREAKING CHANGE")];
317 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
318 }
319
320 #[test]
321 fn breaking_change_hyphenated_footer() {
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 highest_bump_wins() {
328 let commits = vec![commit("fix", false), commit("feat", false)];
329 assert_eq!(determine_bump(&commits), Some(BumpLevel::Minor));
330 }
331
332 #[test]
333 fn breaking_beats_all() {
334 let commits = vec![
335 commit("fix", false),
336 commit("feat", false),
337 commit("chore", true),
338 ];
339 assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
340 }
341
342 #[test]
343 fn apply_bump_patch() {
344 let v = semver::Version::new(1, 2, 3);
345 assert_eq!(
346 apply_bump(&v, BumpLevel::Patch),
347 semver::Version::new(1, 2, 4)
348 );
349 }
350
351 #[test]
352 fn apply_bump_minor() {
353 let v = semver::Version::new(1, 2, 3);
354 assert_eq!(
355 apply_bump(&v, BumpLevel::Minor),
356 semver::Version::new(1, 3, 0)
357 );
358 }
359
360 #[test]
361 fn apply_bump_major() {
362 let v = semver::Version::new(1, 2, 3);
363 assert_eq!(
364 apply_bump(&v, BumpLevel::Major),
365 semver::Version::new(2, 0, 0)
366 );
367 }
368
369 #[test]
370 fn apply_bump_clears_prerelease() {
371 let v = semver::Version::parse("1.2.3-rc.1").unwrap();
372 assert_eq!(
373 apply_bump(&v, BumpLevel::Patch),
374 semver::Version::new(1, 2, 4)
375 );
376 }
377
378 #[test]
379 fn apply_prerelease_new() {
380 let v = semver::Version::new(1, 0, 0);
381 let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
382 assert_eq!(next, semver::Version::parse("1.1.0-rc.0").unwrap());
383 }
384
385 #[test]
386 fn apply_prerelease_increment() {
387 let v = semver::Version::parse("1.1.0-rc.0").unwrap();
388 let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
389 assert_eq!(next, semver::Version::parse("1.1.0-rc.1").unwrap());
390 }
391
392 #[test]
393 fn apply_prerelease_different_tag() {
394 let v = semver::Version::parse("1.1.0-alpha.2").unwrap();
395 let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
396 assert_eq!(next, semver::Version::parse("1.2.0-rc.0").unwrap());
398 }
399
400 #[test]
401 fn summarise_counts() {
402 let commits = vec![
403 commit("feat", false),
404 commit("feat", false),
405 commit("fix", false),
406 commit("chore", true),
407 commit("refactor", false),
408 ];
409 let s = summarise(&commits);
410 assert_eq!(s.feat_count, 2);
411 assert_eq!(s.fix_count, 1);
412 assert_eq!(s.breaking_count, 1);
413 assert_eq!(s.other_count, 2); }
415
416 #[test]
417 fn bump_level_ordering() {
418 assert!(BumpLevel::Major > BumpLevel::Minor);
419 assert!(BumpLevel::Minor > BumpLevel::Patch);
420 }
421
422 #[test]
423 fn replace_version_in_toml_basic() {
424 let input = r#"[package]
425name = "my-crate"
426version = "0.1.0"
427edition = "2021"
428"#;
429 let result = replace_version_in_toml(input, "1.0.0").unwrap();
430 assert!(result.contains("version = \"1.0.0\""));
431 assert!(result.contains("name = \"my-crate\""));
432 assert!(result.contains("edition = \"2021\""));
433 }
434
435 #[test]
436 fn replace_version_only_in_package_section() {
437 let input = r#"[package]
438name = "my-crate"
439version = "0.1.0"
440
441[dependencies]
442foo = { version = "1.0" }
443"#;
444 let result = replace_version_in_toml(input, "2.0.0").unwrap();
445 assert!(result.contains("[package]"));
446 assert!(result.contains("version = \"2.0.0\""));
447 assert!(result.contains("foo = { version = \"1.0\" }"));
449 }
450}