Skip to main content

standard_version/
lib.rs

1//! Semantic version bump calculation from conventional commits.
2//!
3//! Computes the next version from a list of parsed conventional commits and
4//! bump rules. Also provides the [`VersionFile`] trait for ecosystem-specific
5//! version file detection and updating, with built-in support for
6//! `Cargo.toml` via [`CargoVersionFile`], `pyproject.toml` via
7//! [`PyprojectVersionFile`], `package.json` via [`JsonVersionFile`],
8//! `deno.json`/`deno.jsonc` via [`DenoVersionFile`], `pubspec.yaml` via
9//! [`PubspecVersionFile`], `gradle.properties` via [`GradleVersionFile`],
10//! and plain `VERSION` files via [`PlainVersionFile`].
11//!
12//! # Main entry points
13//!
14//! - [`determine_bump`] — analyse commits and return the bump level
15//! - [`apply_bump`] — apply a bump level to a semver version
16//! - [`apply_prerelease`] — bump with a pre-release tag (e.g. `rc.0`)
17//! - [`replace_version_in_toml`] — update the version in a `Cargo.toml` string
18//! - [`update_version_files`] — discover and update version files at a repo root
19//!
20//! # Example
21//!
22//! ```
23//! use standard_version::{determine_bump, apply_bump, BumpLevel};
24//!
25//! let commits = vec![
26//!     standard_commit::parse("feat: add login").unwrap(),
27//!     standard_commit::parse("fix: handle timeout").unwrap(),
28//! ];
29//!
30//! let level = determine_bump(&commits).unwrap();
31//! assert_eq!(level, BumpLevel::Minor);
32//!
33//! let current = semver::Version::new(1, 2, 3);
34//! let next = apply_bump(&current, level);
35//! assert_eq!(next, semver::Version::new(1, 3, 0));
36//! ```
37
38pub 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/// The level of version bump to apply.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
65pub enum BumpLevel {
66    /// Bug fix — increment the patch component.
67    Patch,
68    /// New feature — increment the minor component.
69    Minor,
70    /// Breaking change — increment the major component.
71    Major,
72}
73
74/// Analyse a list of conventional commits and return the highest applicable
75/// bump level.
76///
77/// Bump rules follow the [Conventional Commits](https://www.conventionalcommits.org/)
78/// specification:
79/// - `feat` → [`BumpLevel::Minor`]
80/// - `fix` or `perf` → [`BumpLevel::Patch`]
81/// - `BREAKING CHANGE` footer or `!` suffix → [`BumpLevel::Major`]
82///
83/// Returns `None` when no bump-worthy commits exist (e.g. only `chore`,
84/// `docs`, `refactor`).
85pub 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
101/// Determine the bump level for a single commit.
102fn commit_bump(commit: &ConventionalCommit) -> Option<BumpLevel> {
103    // Breaking change (footer or `!` suffix) always yields Major.
104    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
120/// Apply a bump level to a semver version, returning the new version.
121///
122/// Resets lower components to zero (e.g. minor bump `1.2.3` → `1.3.0`).
123/// For versions `< 1.0.0`, major bumps still increment the major component.
124pub fn apply_bump(current: &semver::Version, level: BumpLevel) -> semver::Version {
125    let mut next = current.clone();
126    // Clear any pre-release or build metadata.
127    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
148/// Apply a pre-release bump. If the current version already has a pre-release
149/// tag matching `tag`, the numeric suffix is incremented. Otherwise, `.0` is
150/// appended to the bumped version.
151///
152/// Example: `1.0.0` + Minor + tag `"rc"` → `1.1.0-rc.0`
153/// Example: `1.1.0-rc.0` + tag `"rc"` → `1.1.0-rc.1`
154pub fn apply_prerelease(current: &semver::Version, level: BumpLevel, tag: &str) -> semver::Version {
155    // If already a pre-release with the same tag prefix, just bump the counter.
156    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    // Otherwise, bump normally then append the pre-release tag.
170    let mut next = apply_bump(current, level);
171    next.pre = semver::Prerelease::new(&format!("{tag}.0")).unwrap_or_default();
172    next
173}
174
175/// Summary of analysed commits for display purposes.
176///
177/// Counts commits by category. A single commit may increment both
178/// `breaking_count` and its type count (e.g. a breaking `feat` increments
179/// both `feat_count` and `breaking_count`).
180#[derive(Debug, Default)]
181pub struct BumpSummary {
182    /// Count of `feat` commits.
183    pub feat_count: usize,
184    /// Count of `fix` commits.
185    pub fix_count: usize,
186    /// Count of commits with breaking changes.
187    pub breaking_count: usize,
188    /// Count of other conventional commits (perf, refactor, etc.).
189    pub other_count: usize,
190}
191
192/// Summarise a list of conventional commits for display purposes.
193pub 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
213/// Replace the `version` value in a TOML string's `[package]` section while
214/// preserving formatting.
215///
216/// Scans for the first `version = "..."` line under `[package]` and rewrites
217/// just the value. Lines in other sections (e.g. `[dependencies]`) are left
218/// untouched.
219///
220/// # Errors
221///
222/// Returns an error if no `version` field is found under `[package]`.
223///
224/// # Example
225///
226/// ```
227/// let toml = r#"[package]
228/// name = "my-crate"
229/// version = "0.1.0"
230///
231/// [dependencies]
232/// serde = { version = "1.0" }
233/// "#;
234///
235/// let updated = standard_version::replace_version_in_toml(toml, "2.0.0").unwrap();
236/// assert!(updated.contains(r#"version = "2.0.0""#));
237/// // dependency version unchanged
238/// assert!(updated.contains(r#"serde = { version = "1.0" }"#));
239/// ```
240pub 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        // Different tag → bump normally and start at 0.
397        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); // chore + refactor
414    }
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        // Dependency version should be unchanged.
448        assert!(result.contains("foo = { version = \"1.0\" }"));
449    }
450}