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 project;
43pub mod pubspec;
44pub mod pyproject;
45pub mod regex_engine;
46pub mod toml_helpers;
47pub mod version_file;
48pub mod version_plain;
49
50pub use cargo::CargoVersionFile;
51pub use gradle::GradleVersionFile;
52pub use json::{DenoVersionFile, JsonVersionFile};
53pub use project::{ProjectJsonVersionFile, ProjectTomlVersionFile, ProjectYamlVersionFile};
54pub use pubspec::PubspecVersionFile;
55pub use pyproject::PyprojectVersionFile;
56pub use regex_engine::RegexVersionFile;
57pub use version_file::{
58    CustomVersionFile, DetectedFile, UpdateResult, VersionFile, VersionFileError,
59    detect_version_files, update_version_files,
60};
61pub use version_plain::PlainVersionFile;
62
63use standard_commit::ConventionalCommit;
64
65/// The level of version bump to apply.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
67pub enum BumpLevel {
68    /// Bug fix — increment the patch component.
69    Patch,
70    /// New feature — increment the minor component.
71    Minor,
72    /// Breaking change — increment the major component.
73    Major,
74}
75
76/// Analyse a list of conventional commits and return the highest applicable
77/// bump level.
78///
79/// Bump rules follow the [Conventional Commits](https://www.conventionalcommits.org/)
80/// specification:
81/// - `feat` → [`BumpLevel::Minor`]
82/// - `fix` or `perf` → [`BumpLevel::Patch`]
83/// - `BREAKING CHANGE` footer or `!` suffix → [`BumpLevel::Major`]
84///
85/// Returns `None` when no bump-worthy commits exist (e.g. only `chore`,
86/// `docs`, `refactor`).
87pub fn determine_bump(commits: &[ConventionalCommit]) -> Option<BumpLevel> {
88    let mut level: Option<BumpLevel> = None;
89
90    for commit in commits {
91        let bump = commit_bump(commit);
92        if let Some(b) = bump {
93            level = Some(match level {
94                Some(current) => current.max(b),
95                None => b,
96            });
97        }
98    }
99
100    level
101}
102
103/// Determine the bump level for a single commit.
104fn commit_bump(commit: &ConventionalCommit) -> Option<BumpLevel> {
105    // Breaking change (footer or `!` suffix) always yields Major.
106    if commit.is_breaking {
107        return Some(BumpLevel::Major);
108    }
109    for footer in &commit.footers {
110        if footer.token == "BREAKING CHANGE" || footer.token == "BREAKING-CHANGE" {
111            return Some(BumpLevel::Major);
112        }
113    }
114
115    match commit.r#type.as_str() {
116        "feat" => Some(BumpLevel::Minor),
117        "fix" | "perf" | "revert" => Some(BumpLevel::Patch),
118        _ => None,
119    }
120}
121
122/// Apply a bump level to a semver version, returning the new version.
123///
124/// Resets lower components to zero (e.g. minor bump `1.2.3` → `1.3.0`).
125/// For versions `< 1.0.0`, major bumps still increment the major component.
126pub fn apply_bump(current: &semver::Version, level: BumpLevel) -> semver::Version {
127    let mut next = current.clone();
128    // Clear any pre-release or build metadata.
129    next.pre = semver::Prerelease::EMPTY;
130    next.build = semver::BuildMetadata::EMPTY;
131
132    match level {
133        BumpLevel::Major => {
134            next.major += 1;
135            next.minor = 0;
136            next.patch = 0;
137        }
138        BumpLevel::Minor => {
139            next.minor += 1;
140            next.patch = 0;
141        }
142        BumpLevel::Patch => {
143            next.patch += 1;
144        }
145    }
146
147    next
148}
149
150/// Apply a pre-release bump. If the current version already has a pre-release
151/// tag matching `tag`, the numeric suffix is incremented. Otherwise, `.0` is
152/// appended to the bumped version.
153///
154/// Example: `1.0.0` + Minor + tag `"rc"` → `1.1.0-rc.0`
155/// Example: `1.1.0-rc.0` + tag `"rc"` → `1.1.0-rc.1`
156pub fn apply_prerelease(current: &semver::Version, level: BumpLevel, tag: &str) -> semver::Version {
157    // If already a pre-release with the same tag prefix, just bump the counter.
158    if !current.pre.is_empty() {
159        let pre_str = current.pre.as_str();
160        if let Some(rest) = pre_str.strip_prefix(tag)
161            && let Some(num_str) = rest.strip_prefix('.')
162            && let Ok(n) = num_str.parse::<u64>()
163        {
164            let mut next = current.clone();
165            next.pre = semver::Prerelease::new(&format!("{tag}.{}", n + 1)).unwrap_or_default();
166            next.build = semver::BuildMetadata::EMPTY;
167            return next;
168        }
169    }
170
171    // Otherwise, bump normally then append the pre-release tag.
172    let mut next = apply_bump(current, level);
173    next.pre = semver::Prerelease::new(&format!("{tag}.0")).unwrap_or_default();
174    next
175}
176
177/// Summary of analysed commits for display purposes.
178///
179/// Counts commits by category. A single commit may increment both
180/// `breaking_count` and its type count (e.g. a breaking `feat` increments
181/// both `feat_count` and `breaking_count`).
182#[derive(Debug, Default)]
183pub struct BumpSummary {
184    /// Count of `feat` commits.
185    pub feat_count: usize,
186    /// Count of `fix` commits.
187    pub fix_count: usize,
188    /// Count of commits with breaking changes.
189    pub breaking_count: usize,
190    /// Count of other conventional commits (perf, refactor, etc.).
191    pub other_count: usize,
192}
193
194/// Summarise a list of conventional commits for display purposes.
195pub fn summarise(commits: &[ConventionalCommit]) -> BumpSummary {
196    let mut summary = BumpSummary::default();
197    for commit in commits {
198        let is_breaking = commit.is_breaking
199            || commit
200                .footers
201                .iter()
202                .any(|f| f.token == "BREAKING CHANGE" || f.token == "BREAKING-CHANGE");
203        if is_breaking {
204            summary.breaking_count += 1;
205        }
206        match commit.r#type.as_str() {
207            "feat" => summary.feat_count += 1,
208            "fix" => summary.fix_count += 1,
209            _ => summary.other_count += 1,
210        }
211    }
212    summary
213}
214
215/// Replace the `version` value in a TOML string's `[package]` section while
216/// preserving formatting.
217///
218/// Scans for the first `version = "..."` line under `[package]` and rewrites
219/// just the value. Lines in other sections (e.g. `[dependencies]`) are left
220/// untouched.
221///
222/// # Errors
223///
224/// Returns an error if no `version` field is found under `[package]`.
225///
226/// # Example
227///
228/// ```
229/// let toml = r#"[package]
230/// name = "my-crate"
231/// version = "0.1.0"
232///
233/// [dependencies]
234/// serde = { version = "1.0" }
235/// "#;
236///
237/// let updated = standard_version::replace_version_in_toml(toml, "2.0.0").unwrap();
238/// assert!(updated.contains(r#"version = "2.0.0""#));
239/// // dependency version unchanged
240/// assert!(updated.contains(r#"serde = { version = "1.0" }"#));
241/// ```
242pub fn replace_version_in_toml(
243    content: &str,
244    new_version: &str,
245) -> Result<String, VersionFileError> {
246    CargoVersionFile.write_version(content, new_version)
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}