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" | "revert" => 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, 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        // Different tag → bump normally and start at 0.
395        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); // chore + refactor
412    }
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        // Dependency version should be unchanged.
446        assert!(result.contains("foo = { version = \"1.0\" }"));
447    }
448}