semver_php/
version.rs

1use crate::error::{Result, SemverError};
2use std::{
3	cmp::Ordering,
4	fmt::{self, Display},
5	mem,
6};
7
8/// Stability levels in Composer semver, ordered from least to most stable.
9/// The ordering matters for comparison.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum Stability {
12	Dev,
13	Alpha,
14	Beta,
15	RC,
16	Stable,
17}
18
19impl Stability {
20	/// Parse stability from string, handling short forms (a, b, p, pl, rc).
21	/// Returns None if the string doesn't match a known stability.
22	#[must_use]
23	pub fn parse(s: &str) -> Option<Self> {
24		match s.to_lowercase().as_str() {
25			"rc" => Some(Self::RC),
26			"beta" | "b" => Some(Self::Beta),
27			"alpha" | "a" => Some(Self::Alpha),
28			"dev" => Some(Self::Dev),
29			"stable" | "patch" | "pl" | "p" => Some(Self::Stable), // patch is treated as stable
30			_ => None,
31		}
32	}
33
34	/// Normalize stability string to canonical form.
35	///
36	/// # Errors
37	/// Returns `SemverError::InvalidStability` if the string is not a valid stability.
38	pub fn normalize(s: &str) -> Result<Self> {
39		let lower = s.to_lowercase();
40		match lower.as_str() {
41			"stable" | "rc" | "beta" | "alpha" | "dev" => {
42				Self::parse(&lower).ok_or_else(|| SemverError::InvalidStability(s.to_string()))
43			},
44			_ => Err(SemverError::InvalidStability(s.to_string())),
45		}
46	}
47
48	/// Get the canonical string representation.
49	#[must_use]
50	pub const fn as_str(&self) -> &'static str {
51		match self {
52			Self::Dev => "dev",
53			Self::Alpha => "alpha",
54			Self::Beta => "beta",
55			Self::RC => "RC",
56			Self::Stable => "stable",
57		}
58	}
59
60	/// Get the numeric order for comparison (lower = less stable).
61	const fn order(self) -> i32 {
62		match self {
63			Self::Dev => -4,
64			Self::Alpha => -3,
65			Self::Beta => -2,
66			Self::RC => -1,
67			Self::Stable => 0,
68		}
69	}
70}
71
72impl PartialOrd for Stability {
73	fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
74		Some(self.cmp(other))
75	}
76}
77
78impl Ord for Stability {
79	fn cmp(&self, other: &Self) -> Ordering {
80		(*self).order().cmp(&(*other).order())
81	}
82}
83
84impl Display for Stability {
85	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86		write!(f, "{}", self.as_str())
87	}
88}
89
90/// Expand shorthand stability to full form.
91/// E.g., "a" -> "alpha", "b" -> "beta", "p" -> "patch", "rc" -> "RC"
92/// Returns a static string for known stabilities.
93#[must_use]
94pub fn expand_stability(stability: &str) -> &'static str {
95	match stability.to_lowercase().as_str() {
96		"a" | "alpha" => "alpha",
97		"b" | "beta" => "beta",
98		"p" | "pl" | "patch" => "patch",
99		"rc" => "RC",
100		"dev" => "dev",
101		// Unknown stability defaults to stable
102		_ => "stable",
103	}
104}
105
106/// Get stability order for PHP `version_compare` semantics.
107/// Returns numeric order where lower = less stable.
108#[must_use]
109pub fn stability_order(s: &str) -> i32 {
110	match s.to_lowercase().as_str() {
111		"dev" => -4,
112		"alpha" | "a" => -3,
113		"beta" | "b" => -2,
114		"rc" => -1,
115		"patch" | "pl" | "p" => 1,
116		_ => 0, // stable/numeric
117	}
118}
119
120/// Compare two version strings using PHP's `version_compare` semantics.
121/// This is critical for matching Composer's behavior.
122#[must_use]
123pub fn version_compare(a: &str, b: &str) -> Ordering {
124	let a_parts = split_version(a);
125	let b_parts = split_version(b);
126
127	let max_len = a_parts.len().max(b_parts.len());
128
129	for i in 0..max_len {
130		let a_part = a_parts.get(i).map_or("", String::as_str);
131		let b_part = b_parts.get(i).map_or("", String::as_str);
132
133		let cmp = compare_parts(a_part, b_part);
134		if cmp != Ordering::Equal {
135			return cmp;
136		}
137	}
138
139	Ordering::Equal
140}
141
142/// Split version string into parts for comparison.
143/// Splits on `.`, `-`, `_` characters.
144fn split_version(version: &str) -> Vec<String> {
145	let mut parts = Vec::new();
146	let mut current = String::new();
147
148	for ch in version.chars() {
149		if ch == '.' || ch == '-' || ch == '_' {
150			if !current.is_empty() {
151				parts.push(mem::take(&mut current));
152			}
153		} else {
154			current.push(ch);
155		}
156	}
157
158	if !current.is_empty() {
159		parts.push(current);
160	}
161
162	parts
163}
164
165/// Split a version part into stability prefix and numeric suffix.
166/// E.g., "b2" -> ("beta", Some(2)), "beta2" -> ("beta", Some(2)), "15" -> ("", Some(15))
167fn split_stability_and_number(s: &str) -> (&str, Option<i64>) {
168	// Check for known stability prefixes with optional numeric suffix
169	let lower = s.to_lowercase();
170
171	// Try to match stability prefixes
172	for (prefix, canonical) in &[
173		("alpha", "alpha"),
174		("beta", "beta"),
175		("patch", "patch"),
176		("dev", "dev"),
177		("rc", "RC"),
178		("pl", "patch"),
179		("a", "alpha"),
180		("b", "beta"),
181		("p", "patch"),
182	] {
183		if lower.starts_with(prefix) {
184			let rest = &s[prefix.len()..];
185			// Remove leading dots or hyphens from the number
186			let rest = rest.trim_start_matches(['.', '-']);
187			let num = rest.parse::<i64>().ok();
188			return (canonical, num);
189		}
190	}
191
192	// Not a stability prefix - try to parse as number
193	if let Ok(n) = s.parse::<i64>() {
194		return ("", Some(n));
195	}
196
197	// Plain string
198	(s, None)
199}
200
201/// Compare two version parts using PHP semantics.
202fn compare_parts(a: &str, b: &str) -> Ordering {
203	// Handle empty parts
204	if a.is_empty() && b.is_empty() {
205		return Ordering::Equal;
206	}
207	if a.is_empty() {
208		// Empty is less than any non-empty, unless b is a stability marker
209		let b_stability = stability_order(b);
210		if b_stability != 0 {
211			return Ordering::Greater; // "" > "dev", "" > "alpha", etc. but "" < "patch"
212		}
213		return Ordering::Less;
214	}
215	if b.is_empty() {
216		let a_stability = stability_order(a);
217		if a_stability != 0 {
218			return Ordering::Less;
219		}
220		return Ordering::Greater;
221	}
222
223	// Split into stability prefix and number
224	let (a_stability_str, a_num) = split_stability_and_number(a);
225	let (b_stability_str, b_num) = split_stability_and_number(b);
226
227	// If both have stability prefixes
228	let a_stability_ord = stability_order(a_stability_str);
229	let b_stability_ord = stability_order(b_stability_str);
230
231	// First compare stability types
232	if a_stability_ord != 0 || b_stability_ord != 0 {
233		// At least one is a stability marker
234		if a_stability_ord != b_stability_ord {
235			return a_stability_ord.cmp(&b_stability_ord);
236		}
237		// Same stability type - compare numbers
238		match (a_num, b_num) {
239			(Some(an), Some(bn)) => return an.cmp(&bn),
240			(Some(_), None) => return Ordering::Greater,
241			(None, Some(_)) => return Ordering::Less,
242			(None, None) => return Ordering::Equal,
243		}
244	}
245
246	// Both are numeric or plain strings
247	match (a_num, b_num) {
248		(Some(an), Some(bn)) => an.cmp(&bn),
249		(Some(_), None) => {
250			// a is numeric, b is not
251			Ordering::Greater
252		},
253		(None, Some(_)) => {
254			// b is numeric, a is not
255			Ordering::Less
256		},
257		(None, None) => {
258			// Both are plain strings - compare lexicographically
259			a.to_lowercase().cmp(&b.to_lowercase())
260		},
261	}
262}
263
264#[cfg(test)]
265mod tests {
266	use super::*;
267
268	#[test]
269	fn test_stability_ordering() {
270		assert!(Stability::Dev < Stability::Alpha);
271		assert!(Stability::Alpha < Stability::Beta);
272		assert!(Stability::Beta < Stability::RC);
273		assert!(Stability::RC < Stability::Stable);
274	}
275
276	#[test]
277	fn test_stability_parse() {
278		assert_eq!(Stability::parse("dev"), Some(Stability::Dev));
279		assert_eq!(Stability::parse("alpha"), Some(Stability::Alpha));
280		assert_eq!(Stability::parse("a"), Some(Stability::Alpha));
281		assert_eq!(Stability::parse("beta"), Some(Stability::Beta));
282		assert_eq!(Stability::parse("b"), Some(Stability::Beta));
283		assert_eq!(Stability::parse("rc"), Some(Stability::RC));
284		assert_eq!(Stability::parse("RC"), Some(Stability::RC));
285		assert_eq!(Stability::parse("stable"), Some(Stability::Stable));
286		assert_eq!(Stability::parse("patch"), Some(Stability::Stable));
287		assert_eq!(Stability::parse("invalid"), None);
288	}
289
290	#[test]
291	fn test_version_compare() {
292		assert_eq!(version_compare("1.0.0", "1.0.0"), Ordering::Equal);
293		assert_eq!(version_compare("1.0.0", "1.0.1"), Ordering::Less);
294		assert_eq!(version_compare("1.0.1", "1.0.0"), Ordering::Greater);
295		assert_eq!(version_compare("1.0", "1.0.0"), Ordering::Less);
296		assert_eq!(version_compare("1.0.0", "1.0"), Ordering::Greater);
297		assert_eq!(version_compare("1.0.0-dev", "1.0.0"), Ordering::Less);
298		assert_eq!(version_compare("1.0.0-alpha", "1.0.0-beta"), Ordering::Less);
299		assert_eq!(version_compare("1.0.0-RC", "1.0.0"), Ordering::Less);
300	}
301
302	#[test]
303	fn test_version_compare_short_forms() {
304		// Short stability forms should be equivalent to full forms
305		assert_eq!(version_compare("2.0-b2", "2.0-beta2"), Ordering::Equal);
306		assert_eq!(version_compare("2.0-a1", "2.0-alpha1"), Ordering::Equal);
307		assert_eq!(version_compare("2.0-RC1", "2.0-rc1"), Ordering::Equal);
308		assert_eq!(version_compare("3.0-b2", "3.0-beta2"), Ordering::Equal);
309
310		// Beta comes after alpha
311		assert_eq!(version_compare("2.0-alpha1", "2.0-beta1"), Ordering::Less);
312		assert_eq!(version_compare("2.0-a1", "2.0-b1"), Ordering::Less);
313
314		// Numeric suffixes matter
315		assert_eq!(version_compare("2.0-beta1", "2.0-beta2"), Ordering::Less);
316		assert_eq!(version_compare("2.0-b1", "2.0-b2"), Ordering::Less);
317	}
318
319	#[test]
320	fn test_expand_stability() {
321		assert_eq!(expand_stability("a"), "alpha");
322		assert_eq!(expand_stability("b"), "beta");
323		assert_eq!(expand_stability("p"), "patch");
324		assert_eq!(expand_stability("pl"), "patch");
325		assert_eq!(expand_stability("rc"), "RC");
326		assert_eq!(expand_stability("alpha"), "alpha");
327	}
328}