Skip to main content

semver_diff/
lib.rs

1//! # semver-diff — the release-type difference between two versions
2//!
3//! Given two [`semver::Version`]s, report *what kind* of change separates them — a
4//! major, minor, or patch bump, or one of their prerelease variants. A faithful Rust
5//! port of [node-semver](https://github.com/npm/node-semver)'s `diff` (and the
6//! [`semver-diff`](https://www.npmjs.com/package/semver-diff) npm package), useful
7//! for update notifiers, changelog tooling, and dependency dashboards.
8//!
9//! ```
10//! use semver::Version;
11//! use semver_diff::{diff, Difference};
12//!
13//! assert_eq!(diff(&Version::new(1, 0, 0), &Version::new(1, 0, 1)), Some(Difference::Patch));
14//! assert_eq!(diff(&Version::new(1, 0, 0), &Version::new(2, 0, 0)), Some(Difference::Major));
15//! assert_eq!(diff(&Version::new(1, 2, 3), &Version::new(1, 2, 3)), None);
16//! ```
17//!
18//! Or work straight from strings with [`diff_str`]:
19//!
20//! ```
21//! assert_eq!(semver_diff::diff_str("1.0.0", "1.1.0-rc.1").unwrap(), Some(semver_diff::Difference::PreMinor));
22//! ```
23
24#![doc(html_root_url = "https://docs.rs/semver-diff/0.1.0")]
25
26// Compile-test the README's examples as part of `cargo test`.
27#[cfg(doctest)]
28#[doc = include_str!("../README.md")]
29struct ReadmeDoctests;
30
31use core::cmp::Ordering;
32use semver::{BuildMetadata, Version};
33
34/// The kind of change between two versions.
35///
36/// The `Pre*` variants mean the *higher* version is a prerelease. [`Difference::as_str`]
37/// renders the same lowercase names node-semver uses (`"major"`, `"premajor"`, …). The
38/// derived `Ord` follows declaration order (`Major < PreMajor < Minor < … < PreRelease`);
39/// it is a stable total order, not a ranking of "impact".
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
41pub enum Difference {
42    /// A major bump (`1.x.y` → `2.0.0`).
43    Major,
44    /// A major bump to a prerelease (`1.0.0` → `2.0.0-rc`).
45    PreMajor,
46    /// A minor bump (`1.0.x` → `1.1.0`).
47    Minor,
48    /// A minor bump to a prerelease (`1.0.0` → `1.1.0-rc`).
49    PreMinor,
50    /// A patch bump (`1.0.0` → `1.0.1`).
51    Patch,
52    /// A patch bump to a prerelease (`1.0.0` → `1.0.1-rc`).
53    PrePatch,
54    /// Only the prerelease identifiers differ (`1.0.0-a` → `1.0.0-b`).
55    PreRelease,
56}
57
58impl Difference {
59    /// The node-semver name for this difference (`"major"`, `"premajor"`, `"minor"`,
60    /// `"preminor"`, `"patch"`, `"prepatch"`, `"prerelease"`).
61    #[must_use]
62    pub const fn as_str(self) -> &'static str {
63        match self {
64            Difference::Major => "major",
65            Difference::PreMajor => "premajor",
66            Difference::Minor => "minor",
67            Difference::PreMinor => "preminor",
68            Difference::Patch => "patch",
69            Difference::PrePatch => "prepatch",
70            Difference::PreRelease => "prerelease",
71        }
72    }
73
74    /// Whether this is one of the prerelease variants (`PreMajor`, `PreMinor`,
75    /// `PrePatch`, `PreRelease`).
76    #[must_use]
77    pub const fn is_prerelease(self) -> bool {
78        matches!(
79            self,
80            Difference::PreMajor
81                | Difference::PreMinor
82                | Difference::PrePatch
83                | Difference::PreRelease
84        )
85    }
86}
87
88/// The release-type difference between `a` and `b`, or `None` if they compare equal.
89///
90/// The result is symmetric (`diff(a, b) == diff(b, a)`) and, like semver comparison,
91/// ignores build metadata. Mirrors node-semver's `diff`.
92///
93/// ```
94/// use semver::Version;
95/// use semver_diff::{diff, Difference};
96/// assert_eq!(diff(&Version::new(1, 0, 0), &Version::new(1, 1, 0)), Some(Difference::Minor));
97/// ```
98#[must_use]
99pub fn diff(a: &Version, b: &Version) -> Option<Difference> {
100    let ordering = cmp_ignoring_build(a, b);
101    if ordering == Ordering::Equal {
102        return None;
103    }
104
105    let (high, low) = if ordering == Ordering::Greater {
106        (a, b)
107    } else {
108        (b, a)
109    };
110    let high_has_pre = !high.pre.is_empty();
111    let low_has_pre = !low.pre.is_empty();
112
113    // Stabilizing a prerelease into a release. Following node-semver: a prerelease of
114    // a clean major (`X.0.0-pre`) becomes a major release; if the main version is
115    // otherwise unchanged, the prerelease's own shape decides; and if the main version
116    // *did* change, fall through to the standard component comparison below.
117    if low_has_pre && !high_has_pre {
118        if low.minor == 0 && low.patch == 0 {
119            return Some(Difference::Major);
120        }
121        if low.major == high.major && low.minor == high.minor && low.patch == high.patch {
122            if low.minor != 0 && low.patch == 0 {
123                return Some(Difference::Minor);
124            }
125            return Some(Difference::Patch);
126        }
127    }
128
129    // Otherwise, the most significant changed component decides, with a `pre` prefix
130    // when the higher version is itself a prerelease.
131    if a.major != b.major {
132        return Some(if high_has_pre {
133            Difference::PreMajor
134        } else {
135            Difference::Major
136        });
137    }
138    if a.minor != b.minor {
139        return Some(if high_has_pre {
140            Difference::PreMinor
141        } else {
142            Difference::Minor
143        });
144    }
145    if a.patch != b.patch {
146        return Some(if high_has_pre {
147            Difference::PrePatch
148        } else {
149            Difference::Patch
150        });
151    }
152
153    // Same major.minor.patch, but not equal → only the prerelease tags differ.
154    Some(Difference::PreRelease)
155}
156
157/// Order two versions by SemVer precedence, ignoring build metadata (which the
158/// `semver` crate otherwise treats as a tiebreaker, unlike node-semver and the spec).
159fn cmp_ignoring_build(a: &Version, b: &Version) -> Ordering {
160    if a.build == b.build {
161        a.cmp(b)
162    } else {
163        let a = Version {
164            build: BuildMetadata::EMPTY,
165            ..a.clone()
166        };
167        let b = Version {
168            build: BuildMetadata::EMPTY,
169            ..b.clone()
170        };
171        a.cmp(&b)
172    }
173}
174
175/// Parse `a` and `b` as [`semver::Version`]s and return their [`diff`].
176///
177/// # Errors
178///
179/// Returns the [`semver::Error`] from the first input that fails to parse.
180pub fn diff_str(a: &str, b: &str) -> Result<Option<Difference>, semver::Error> {
181    Ok(diff(&Version::parse(a)?, &Version::parse(b)?))
182}