Skip to main content

standard_version/
lib.rs

1//! Semantic version bump calculation from conventional commits.
2//!
3//! Pure library — computes the next version from a list of parsed conventional
4//! commits and bump rules. No I/O, no git operations.
5//!
6//! # Main entry points
7//!
8//! - [`determine_bump`] — analyse commits and return the bump level
9//! - [`apply_bump`] — apply a bump level to a semver version
10//! - [`apply_prerelease`] — bump with a pre-release tag (e.g. `rc.0`)
11//! - [`replace_version_in_toml`] — update the version in a `Cargo.toml` string
12//!
13//! # Example
14//!
15//! ```
16//! use standard_version::{determine_bump, apply_bump, BumpLevel};
17//!
18//! let commits = vec![
19//!     standard_commit::parse("feat: add login").unwrap(),
20//!     standard_commit::parse("fix: handle timeout").unwrap(),
21//! ];
22//!
23//! let level = determine_bump(&commits).unwrap();
24//! assert_eq!(level, BumpLevel::Minor);
25//!
26//! let current = semver::Version::new(1, 2, 3);
27//! let next = apply_bump(&current, level);
28//! assert_eq!(next, semver::Version::new(1, 3, 0));
29//! ```
30
31use standard_commit::ConventionalCommit;
32
33/// The level of version bump to apply.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
35pub enum BumpLevel {
36    /// Bug fix — increment the patch component.
37    Patch,
38    /// New feature — increment the minor component.
39    Minor,
40    /// Breaking change — increment the major component.
41    Major,
42}
43
44/// Analyse a list of conventional commits and return the highest applicable
45/// bump level.
46///
47/// Bump rules follow the [Conventional Commits](https://www.conventionalcommits.org/)
48/// specification:
49/// - `feat` → [`BumpLevel::Minor`]
50/// - `fix` or `perf` → [`BumpLevel::Patch`]
51/// - `BREAKING CHANGE` footer or `!` suffix → [`BumpLevel::Major`]
52///
53/// Returns `None` when no bump-worthy commits exist (e.g. only `chore`,
54/// `docs`, `refactor`).
55pub fn determine_bump(commits: &[ConventionalCommit]) -> Option<BumpLevel> {
56    let mut level: Option<BumpLevel> = None;
57
58    for commit in commits {
59        let bump = commit_bump(commit);
60        if let Some(b) = bump {
61            level = Some(match level {
62                Some(current) => current.max(b),
63                None => b,
64            });
65        }
66    }
67
68    level
69}
70
71/// Determine the bump level for a single commit.
72fn commit_bump(commit: &ConventionalCommit) -> Option<BumpLevel> {
73    // Breaking change (footer or `!` suffix) always yields Major.
74    if commit.is_breaking {
75        return Some(BumpLevel::Major);
76    }
77    for footer in &commit.footers {
78        if footer.token == "BREAKING CHANGE" || footer.token == "BREAKING-CHANGE" {
79            return Some(BumpLevel::Major);
80        }
81    }
82
83    match commit.r#type.as_str() {
84        "feat" => Some(BumpLevel::Minor),
85        "fix" | "perf" => Some(BumpLevel::Patch),
86        _ => None,
87    }
88}
89
90/// Apply a bump level to a semver version, returning the new version.
91///
92/// Resets lower components to zero (e.g. minor bump `1.2.3` → `1.3.0`).
93/// For versions `< 1.0.0`, major bumps still increment the major component.
94pub fn apply_bump(current: &semver::Version, level: BumpLevel) -> semver::Version {
95    let mut next = current.clone();
96    // Clear any pre-release or build metadata.
97    next.pre = semver::Prerelease::EMPTY;
98    next.build = semver::BuildMetadata::EMPTY;
99
100    match level {
101        BumpLevel::Major => {
102            next.major += 1;
103            next.minor = 0;
104            next.patch = 0;
105        }
106        BumpLevel::Minor => {
107            next.minor += 1;
108            next.patch = 0;
109        }
110        BumpLevel::Patch => {
111            next.patch += 1;
112        }
113    }
114
115    next
116}
117
118/// Apply a pre-release bump. If the current version already has a pre-release
119/// tag matching `tag`, the numeric suffix is incremented. Otherwise, `.0` is
120/// appended to the bumped version.
121///
122/// Example: `1.0.0` + Minor + tag `"rc"` → `1.1.0-rc.0`
123/// Example: `1.1.0-rc.0` + tag `"rc"` → `1.1.0-rc.1`
124pub fn apply_prerelease(current: &semver::Version, level: BumpLevel, tag: &str) -> semver::Version {
125    // If already a pre-release with the same tag prefix, just bump the counter.
126    if !current.pre.is_empty() {
127        let pre_str = current.pre.as_str();
128        if let Some(rest) = pre_str.strip_prefix(tag)
129            && let Some(num_str) = rest.strip_prefix('.')
130            && let Ok(n) = num_str.parse::<u64>()
131        {
132            let mut next = current.clone();
133            next.pre = semver::Prerelease::new(&format!("{tag}.{}", n + 1)).unwrap_or_default();
134            next.build = semver::BuildMetadata::EMPTY;
135            return next;
136        }
137    }
138
139    // Otherwise, bump normally then append the pre-release tag.
140    let mut next = apply_bump(current, level);
141    next.pre = semver::Prerelease::new(&format!("{tag}.0")).unwrap_or_default();
142    next
143}
144
145/// Summary of analysed commits for display purposes.
146///
147/// Counts commits by category. A single commit may increment both
148/// `breaking_count` and its type count (e.g. a breaking `feat` increments
149/// both `feat_count` and `breaking_count`).
150#[derive(Debug, Default)]
151pub struct BumpSummary {
152    /// Count of `feat` commits.
153    pub feat_count: usize,
154    /// Count of `fix` commits.
155    pub fix_count: usize,
156    /// Count of commits with breaking changes.
157    pub breaking_count: usize,
158    /// Count of other conventional commits (perf, refactor, etc.).
159    pub other_count: usize,
160}
161
162/// Summarise a list of conventional commits for display purposes.
163pub fn summarise(commits: &[ConventionalCommit]) -> BumpSummary {
164    let mut summary = BumpSummary::default();
165    for commit in commits {
166        let is_breaking = commit.is_breaking
167            || commit
168                .footers
169                .iter()
170                .any(|f| f.token == "BREAKING CHANGE" || f.token == "BREAKING-CHANGE");
171        if is_breaking {
172            summary.breaking_count += 1;
173        }
174        match commit.r#type.as_str() {
175            "feat" => summary.feat_count += 1,
176            "fix" => summary.fix_count += 1,
177            _ => summary.other_count += 1,
178        }
179    }
180    summary
181}
182
183/// Replace the `version` value in a TOML string's `[package]` section while
184/// preserving formatting.
185///
186/// Scans for the first `version = "..."` line under `[package]` and rewrites
187/// just the value. Lines in other sections (e.g. `[dependencies]`) are left
188/// untouched.
189///
190/// # Errors
191///
192/// Returns an error if no `version` field is found under `[package]`.
193///
194/// # Example
195///
196/// ```
197/// let toml = r#"[package]
198/// name = "my-crate"
199/// version = "0.1.0"
200///
201/// [dependencies]
202/// serde = { version = "1.0" }
203/// "#;
204///
205/// let updated = standard_version::replace_version_in_toml(toml, "2.0.0").unwrap();
206/// assert!(updated.contains(r#"version = "2.0.0""#));
207/// // dependency version unchanged
208/// assert!(updated.contains(r#"serde = { version = "1.0" }"#));
209/// ```
210pub fn replace_version_in_toml(
211    content: &str,
212    new_version: &str,
213) -> Result<String, Box<dyn std::error::Error>> {
214    let mut in_package = false;
215    let mut result = String::new();
216    let mut replaced = false;
217
218    for line in content.lines() {
219        let trimmed = line.trim();
220        if trimmed == "[package]" {
221            in_package = true;
222        } else if trimmed.starts_with('[') {
223            in_package = false;
224        }
225
226        if in_package
227            && !replaced
228            && trimmed.starts_with("version")
229            && let Some(eq_pos) = line.find('=')
230        {
231            let prefix = &line[..=eq_pos];
232            result.push_str(prefix);
233            result.push_str(&format!(" \"{new_version}\""));
234            result.push('\n');
235            replaced = true;
236            continue;
237        }
238
239        result.push_str(line);
240        result.push('\n');
241    }
242
243    if !replaced {
244        return Err("could not find version field in [package] section".into());
245    }
246
247    // Remove trailing extra newline if the original didn't end with one.
248    if !content.ends_with('\n') && result.ends_with('\n') {
249        result.pop();
250    }
251
252    Ok(result)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use standard_commit::Footer;
259
260    fn commit(typ: &str, breaking: bool) -> ConventionalCommit {
261        ConventionalCommit {
262            r#type: typ.to_string(),
263            scope: None,
264            description: "test".to_string(),
265            body: None,
266            footers: vec![],
267            is_breaking: breaking,
268        }
269    }
270
271    fn commit_with_footer(typ: &str, footer_token: &str) -> ConventionalCommit {
272        ConventionalCommit {
273            r#type: typ.to_string(),
274            scope: None,
275            description: "test".to_string(),
276            body: None,
277            footers: vec![Footer {
278                token: footer_token.to_string(),
279                value: "some breaking change".to_string(),
280            }],
281            is_breaking: false,
282        }
283    }
284
285    #[test]
286    fn no_commits_returns_none() {
287        assert_eq!(determine_bump(&[]), None);
288    }
289
290    #[test]
291    fn non_bump_commits_return_none() {
292        let commits = vec![commit("chore", false), commit("docs", false)];
293        assert_eq!(determine_bump(&commits), None);
294    }
295
296    #[test]
297    fn fix_yields_patch() {
298        let commits = vec![commit("fix", false)];
299        assert_eq!(determine_bump(&commits), Some(BumpLevel::Patch));
300    }
301
302    #[test]
303    fn perf_yields_patch() {
304        let commits = vec![commit("perf", false)];
305        assert_eq!(determine_bump(&commits), Some(BumpLevel::Patch));
306    }
307
308    #[test]
309    fn feat_yields_minor() {
310        let commits = vec![commit("feat", false)];
311        assert_eq!(determine_bump(&commits), Some(BumpLevel::Minor));
312    }
313
314    #[test]
315    fn breaking_bang_yields_major() {
316        let commits = vec![commit("feat", true)];
317        assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
318    }
319
320    #[test]
321    fn breaking_footer_yields_major() {
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 breaking_change_hyphenated_footer() {
328        let commits = vec![commit_with_footer("fix", "BREAKING-CHANGE")];
329        assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
330    }
331
332    #[test]
333    fn highest_bump_wins() {
334        let commits = vec![commit("fix", false), commit("feat", false)];
335        assert_eq!(determine_bump(&commits), Some(BumpLevel::Minor));
336    }
337
338    #[test]
339    fn breaking_beats_all() {
340        let commits = vec![
341            commit("fix", false),
342            commit("feat", false),
343            commit("chore", true),
344        ];
345        assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
346    }
347
348    #[test]
349    fn apply_bump_patch() {
350        let v = semver::Version::new(1, 2, 3);
351        assert_eq!(
352            apply_bump(&v, BumpLevel::Patch),
353            semver::Version::new(1, 2, 4)
354        );
355    }
356
357    #[test]
358    fn apply_bump_minor() {
359        let v = semver::Version::new(1, 2, 3);
360        assert_eq!(
361            apply_bump(&v, BumpLevel::Minor),
362            semver::Version::new(1, 3, 0)
363        );
364    }
365
366    #[test]
367    fn apply_bump_major() {
368        let v = semver::Version::new(1, 2, 3);
369        assert_eq!(
370            apply_bump(&v, BumpLevel::Major),
371            semver::Version::new(2, 0, 0)
372        );
373    }
374
375    #[test]
376    fn apply_bump_clears_prerelease() {
377        let v = semver::Version::parse("1.2.3-rc.1").unwrap();
378        assert_eq!(
379            apply_bump(&v, BumpLevel::Patch),
380            semver::Version::new(1, 2, 4)
381        );
382    }
383
384    #[test]
385    fn apply_prerelease_new() {
386        let v = semver::Version::new(1, 0, 0);
387        let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
388        assert_eq!(next, semver::Version::parse("1.1.0-rc.0").unwrap());
389    }
390
391    #[test]
392    fn apply_prerelease_increment() {
393        let v = semver::Version::parse("1.1.0-rc.0").unwrap();
394        let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
395        assert_eq!(next, semver::Version::parse("1.1.0-rc.1").unwrap());
396    }
397
398    #[test]
399    fn apply_prerelease_different_tag() {
400        let v = semver::Version::parse("1.1.0-alpha.2").unwrap();
401        let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
402        // Different tag → bump normally and start at 0.
403        assert_eq!(next, semver::Version::parse("1.2.0-rc.0").unwrap());
404    }
405
406    #[test]
407    fn summarise_counts() {
408        let commits = vec![
409            commit("feat", false),
410            commit("feat", false),
411            commit("fix", false),
412            commit("chore", true),
413            commit("refactor", false),
414        ];
415        let s = summarise(&commits);
416        assert_eq!(s.feat_count, 2);
417        assert_eq!(s.fix_count, 1);
418        assert_eq!(s.breaking_count, 1);
419        assert_eq!(s.other_count, 2); // chore + refactor
420    }
421
422    #[test]
423    fn bump_level_ordering() {
424        assert!(BumpLevel::Major > BumpLevel::Minor);
425        assert!(BumpLevel::Minor > BumpLevel::Patch);
426    }
427
428    #[test]
429    fn replace_version_in_toml_basic() {
430        let input = r#"[package]
431name = "my-crate"
432version = "0.1.0"
433edition = "2021"
434"#;
435        let result = replace_version_in_toml(input, "1.0.0").unwrap();
436        assert!(result.contains("version = \"1.0.0\""));
437        assert!(result.contains("name = \"my-crate\""));
438        assert!(result.contains("edition = \"2021\""));
439    }
440
441    #[test]
442    fn replace_version_only_in_package_section() {
443        let input = r#"[package]
444name = "my-crate"
445version = "0.1.0"
446
447[dependencies]
448foo = { version = "1.0" }
449"#;
450        let result = replace_version_in_toml(input, "2.0.0").unwrap();
451        assert!(result.contains("[package]"));
452        assert!(result.contains("version = \"2.0.0\""));
453        // Dependency version should be unchanged.
454        assert!(result.contains("foo = { version = \"1.0\" }"));
455    }
456}