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}