Skip to main content

mago_php_version/
lib.rs

1use std::str::FromStr;
2
3use schemars::JsonSchema;
4use serde::Deserialize;
5use serde::Deserializer;
6use serde::Serialize;
7use serde::Serializer;
8
9use crate::error::ParsingError;
10use crate::feature::Feature;
11
12pub mod error;
13pub mod feature;
14
15/// Represents a PHP version in `(major, minor, patch)` format,
16/// packed internally into a single `u32` for easy comparison.
17///
18/// # Examples
19///
20/// ```
21/// use mago_php_version::PHPVersion;
22///
23/// let version = PHPVersion::new(8, 4, 0);
24/// assert_eq!(version.major(), 8);
25/// assert_eq!(version.minor(), 4);
26/// assert_eq!(version.patch(), 0);
27/// assert_eq!(version.to_version_id(), 0x08_04_00);
28/// assert_eq!(version.to_string(), "8.4.0");
29/// ```
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, JsonSchema)]
31#[schemars(with = "String")]
32#[repr(transparent)]
33pub struct PHPVersion(u32);
34
35/// Represents a range of PHP versions, defined by a minimum and maximum version.
36///
37/// This is useful for specifying compatibility ranges, such as "supports PHP 7.0 to 7.4".
38///
39/// # Examples
40///
41/// ```
42/// use mago_php_version::PHPVersion;
43/// use mago_php_version::PHPVersionRange;
44///
45/// let range = PHPVersionRange::between(PHPVersion::new(7, 0, 0), PHPVersion::new(7, 4, 99));
46///
47/// assert!(range.includes(PHPVersion::new(7, 2, 0))); // true
48/// assert!(!range.includes(PHPVersion::new(8, 0, 0))); // false
49/// ```
50#[derive(Debug, PartialEq, Eq, Ord, Copy, Clone, PartialOrd, Deserialize, Serialize, Default, Hash, JsonSchema)]
51pub struct PHPVersionRange {
52    pub min: Option<PHPVersion>,
53    pub max: Option<PHPVersion>,
54}
55
56impl PHPVersion {
57    /// The PHP 7.0 version.
58    pub const PHP70: PHPVersion = PHPVersion::new(7, 0, 0);
59
60    /// The PHP 7.1 version.
61    pub const PHP71: PHPVersion = PHPVersion::new(7, 1, 0);
62
63    /// The PHP 7.2 version.
64    pub const PHP72: PHPVersion = PHPVersion::new(7, 2, 0);
65
66    /// The PHP 7.3 version.
67    pub const PHP73: PHPVersion = PHPVersion::new(7, 3, 0);
68
69    /// The PHP 7.4 version.
70    pub const PHP74: PHPVersion = PHPVersion::new(7, 4, 0);
71
72    /// The PHP 8.0 version.
73    pub const PHP80: PHPVersion = PHPVersion::new(8, 0, 0);
74
75    /// The PHP 8.1 version.
76    pub const PHP81: PHPVersion = PHPVersion::new(8, 1, 0);
77
78    /// The PHP 8.2 version.
79    pub const PHP82: PHPVersion = PHPVersion::new(8, 2, 0);
80
81    /// The PHP 8.3 version.
82    pub const PHP83: PHPVersion = PHPVersion::new(8, 3, 0);
83
84    /// The PHP 8.4 version.
85    pub const PHP84: PHPVersion = PHPVersion::new(8, 4, 0);
86
87    /// The PHP 8.5 version.
88    pub const PHP85: PHPVersion = PHPVersion::new(8, 5, 0);
89
90    /// The PHP 8.6 version.
91    pub const PHP86: PHPVersion = PHPVersion::new(8, 6, 0);
92
93    /// Represents the latest stable PHP version actively supported or targeted by this crate.
94    ///
95    /// **Warning:** The specific PHP version this constant points to (e.g., `PHPVersion::PHP84`)
96    /// is subject to change frequently, potentially even in **minor or patch releases**
97    /// of this crate, as new PHP versions are released and our support baseline updates.
98    ///
99    /// **Do NOT rely on this constant having a fixed value across different crate versions.**
100    /// It is intended for features that should target "the most current PHP we know of now."
101    pub const LATEST: PHPVersion = PHPVersion::PHP85;
102
103    /// Represents an upcoming, future, or "next" PHP version that this crate is
104    /// anticipating or for which experimental support might be in development.
105    ///
106    /// **Warning:** The specific PHP version this constant points to (e.g., `PHPVersion::PHP85`)
107    /// is highly volatile and **WILL CHANGE frequently**, potentially even in **minor or patch
108    /// releases** of this crate, reflecting shifts in PHP's release cycle or our development focus.
109    ///
110    /// **Do NOT rely on this constant having a fixed value across different crate versions.**
111    /// Use with caution, primarily for internal or forward-looking features.
112    pub const NEXT: PHPVersion = PHPVersion::PHP86;
113
114    /// Creates a new `PHPVersion` from the provided `major`, `minor`, and `patch` values.
115    ///
116    /// The internal representation packs these three components into a single `u32`
117    /// for efficient comparisons.
118    ///
119    /// # Examples
120    ///
121    /// ```
122    /// use mago_php_version::PHPVersion;
123    ///
124    /// let version = PHPVersion::new(8, 1, 3);
125    /// assert_eq!(version.major(), 8);
126    /// assert_eq!(version.minor(), 1);
127    /// assert_eq!(version.patch(), 3);
128    /// ```
129    #[inline]
130    #[must_use]
131    pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
132        Self((major << 16) | (minor << 8) | patch)
133    }
134
135    /// Creates a `PHPVersion` directly from a raw version ID (e.g. `80400` for `8.4.0`).
136    ///
137    /// This can be useful if you already have the numeric form. The higher bits represent
138    /// the major version, the next bits represent minor, and the lowest bits represent patch.
139    ///
140    /// # Examples
141    ///
142    /// ```
143    /// use mago_php_version::PHPVersion;
144    ///
145    /// // "8.4.0" => 0x080400 in hex, which is 525312 in decimal
146    /// let version = PHPVersion::from_version_id(0x080400);
147    /// assert_eq!(version.to_string(), "8.4.0");
148    /// ```
149    #[inline]
150    #[must_use]
151    pub const fn from_version_id(version_id: u32) -> Self {
152        Self(version_id)
153    }
154
155    /// Returns the **major** component of the PHP version.
156    ///
157    /// # Examples
158    ///
159    /// ```
160    /// use mago_php_version::PHPVersion;
161    ///
162    /// let version = PHPVersion::new(8, 2, 0);
163    /// assert_eq!(version.major(), 8);
164    /// ```
165    #[inline]
166    #[must_use]
167    pub const fn major(&self) -> u32 {
168        self.0 >> 16
169    }
170
171    /// Returns the **minor** component of the PHP version.
172    ///
173    /// # Examples
174    ///
175    /// ```
176    /// use mago_php_version::PHPVersion;
177    ///
178    /// let version = PHPVersion::new(8, 2, 0);
179    /// assert_eq!(version.minor(), 2);
180    /// ```
181    #[inline]
182    #[must_use]
183    pub const fn minor(&self) -> u32 {
184        (self.0 >> 8) & 0xff
185    }
186
187    /// Returns the **patch** component of the PHP version.
188    ///
189    /// # Examples
190    ///
191    /// ```
192    /// use mago_php_version::PHPVersion;
193    ///
194    /// let version = PHPVersion::new(8, 1, 13);
195    /// assert_eq!(version.patch(), 13);
196    /// ```
197    #[inline]
198    #[must_use]
199    pub const fn patch(&self) -> u32 {
200        self.0 & 0xff
201    }
202
203    /// Determines if this version is **at least** `major.minor.patch`.
204    ///
205    /// Returns `true` if `self >= (major.minor.patch)`.
206    ///
207    /// # Examples
208    ///
209    /// ```
210    /// use mago_php_version::PHPVersion;
211    ///
212    /// let version = PHPVersion::new(8, 0, 0);
213    /// assert!(version.is_at_least(8, 0, 0));
214    /// assert!(version.is_at_least(7, 4, 30)); // 8.0.0 is newer than 7.4.30
215    /// assert!(!version.is_at_least(8, 1, 0));
216    /// ```
217    #[inline]
218    #[must_use]
219    pub const fn is_at_least(&self, major: u32, minor: u32, patch: u32) -> bool {
220        self.0 >= ((major << 16) | (minor << 8) | patch)
221    }
222
223    /// Checks if a given [`Feature`] is supported by this PHP version.
224    ///
225    /// The logic is based on version thresholds (e.g. `>= 8.0.0` or `< 8.0.0`).
226    /// Each `Feature` variant corresponds to a behavior introduced, removed, or changed
227    /// at a particular version boundary.
228    ///
229    /// # Examples
230    ///
231    /// ```
232    /// use mago_php_version::PHPVersion;
233    /// use mago_php_version::feature::Feature;
234    ///
235    /// let version = PHPVersion::new(7, 4, 0);
236    /// assert!(version.is_supported(Feature::NullCoalesceAssign));
237    /// assert!(!version.is_supported(Feature::NamedArguments));
238    /// ```
239    #[inline]
240    #[must_use]
241    pub const fn is_supported(&self, feature: Feature) -> bool {
242        match feature {
243            Feature::NullableTypeHint
244            | Feature::IterableTypeHint
245            | Feature::VoidTypeHint
246            | Feature::ClassLikeConstantVisibilityModifiers
247            | Feature::CatchUnionType => self.0 >= 0x07_01_00,
248            Feature::TrailingCommaInListSyntax
249            | Feature::ParameterTypeWidening
250            | Feature::AllUnicodeScalarCodePointsInMbSubstituteCharacter => self.0 >= 0x07_02_00,
251            Feature::ListReferenceAssignment | Feature::TrailingCommaInFunctionCalls => self.0 >= 0x07_03_00,
252            Feature::NullCoalesceAssign
253            | Feature::ParameterContravariance
254            | Feature::ReturnCovariance
255            | Feature::PregUnmatchedAsNull
256            | Feature::ArrowFunctions
257            | Feature::NumericLiteralSeparator
258            | Feature::TypedProperties => self.0 >= 0x07_04_00,
259            Feature::NonCapturingCatches
260            | Feature::NativeUnionTypes
261            | Feature::LessOverriddenParametersWithVariadic
262            | Feature::ThrowExpression
263            | Feature::ClassConstantOnExpression
264            | Feature::PromotedProperties
265            | Feature::NamedArguments
266            | Feature::ThrowsTypeErrorForInternalFunctions
267            | Feature::ThrowsValueErrorForInternalFunctions
268            | Feature::HHPrintfSpecifier
269            | Feature::StricterRoundFunctions
270            | Feature::ThrowsOnInvalidMbStringEncoding
271            | Feature::WarnsAboutFinalPrivateMethods
272            | Feature::CastsNumbersToStringsOnLooseComparison
273            | Feature::NonNumericStringAndIntegerIsFalseOnLooseComparison
274            | Feature::AbstractTraitMethods
275            | Feature::StaticReturnTypeHint
276            | Feature::AccessClassOnObject
277            | Feature::Attributes
278            | Feature::MixedTypeHint
279            | Feature::MatchExpression
280            | Feature::NullSafeOperator
281            | Feature::TrailingCommaInClosureUseList
282            | Feature::FalseCompoundTypeHint
283            | Feature::NullCompoundTypeHint
284            | Feature::CatchOptionalVariable => self.0 >= 0x08_00_00,
285            Feature::FinalConstants
286            | Feature::ReadonlyProperties
287            | Feature::Enums
288            | Feature::PureIntersectionTypes
289            | Feature::TentativeReturnTypes
290            | Feature::NeverTypeHint
291            | Feature::ClosureCreation
292            | Feature::ArrayUnpackingWithStringKeys
293            | Feature::SerializableRequiresMagicMethods => self.0 >= 0x08_01_00,
294            Feature::ConstantsInTraits
295            | Feature::StrSplitReturnsEmptyArray
296            | Feature::DisjunctiveNormalForm
297            | Feature::ReadonlyClasses
298            | Feature::NeverReturnTypeInArrowFunction
299            | Feature::PregCaptureOnlyNamedGroups
300            | Feature::TrueTypeHint
301            | Feature::FalseTypeHint
302            | Feature::NullTypeHint => self.0 >= 0x08_02_00,
303            Feature::JsonValidate
304            | Feature::TypedClassLikeConstants
305            | Feature::DateTimeExceptions
306            | Feature::OverrideAttribute
307            | Feature::DynamicClassConstantAccess
308            | Feature::ReadonlyAnonymousClasses
309            | Feature::ReadonlyPropertyReinitializationInClone => self.0 >= 0x08_03_00,
310            Feature::AsymmetricVisibility
311            | Feature::LazyObjects
312            | Feature::HighlightStringDoesNotReturnFalse
313            | Feature::PropertyHooks
314            | Feature::NewWithoutParentheses
315            | Feature::DeprecatedAttribute => self.0 >= 0x08_04_00,
316            Feature::ClosureInConstantExpressions
317            | Feature::ConstantAttributes
318            | Feature::NoDiscardAttribute
319            | Feature::VoidCast
320            | Feature::CloneWith
321            | Feature::AsymmetricVisibilityForStaticProperties
322            | Feature::ClosureCreationInConstantExpressions
323            | Feature::PipeOperator => self.0 >= 0x08_05_00,
324            Feature::CallableInstanceMethods
325            | Feature::LegacyConstructor
326            | Feature::UnsetCast
327            | Feature::CaseInsensitiveConstantNames
328            | Feature::ArrayFunctionsReturnNullWithNonArray
329            | Feature::SubstrReturnFalseInsteadOfEmptyString
330            | Feature::CurlUrlOptionCheckingFileSchemeWithOpenBasedir
331            | Feature::EmptyStringValidAliasForNoneInMbSubstituteCharacter
332            | Feature::NumericStringValidArgInMbSubstituteCharacter => self.0 < 0x08_00_00,
333            Feature::InterfaceConstantImplicitlyFinal => self.0 < 0x08_01_00,
334            Feature::PassNoneEncodings => self.0 < 0x07_03_00,
335            Feature::ImplicitlyNullableParameterTypes => self.0 < 0x09_00_00,
336            Feature::PartialFunctionApplication => self.0 >= 0x08_06_00,
337            _ => true,
338        }
339    }
340
341    /// Checks if a given [`Feature`] is deprecated in this PHP version.
342    ///
343    /// Returns `true` if the feature is *considered deprecated* at or above
344    /// certain version thresholds. The threshold logic is encoded within the `match`.
345    ///
346    /// # Examples
347    ///
348    /// ```
349    /// use mago_php_version::PHPVersion;
350    /// use mago_php_version::feature::Feature;
351    ///
352    /// let version = PHPVersion::new(8, 0, 0);
353    /// assert!(version.is_deprecated(Feature::RequiredParameterAfterOptional));
354    /// assert!(!version.is_deprecated(Feature::DynamicProperties)); // that is 8.2+
355    /// ```
356    #[inline]
357    #[must_use]
358    pub const fn is_deprecated(&self, feature: Feature) -> bool {
359        match feature {
360            Feature::DynamicProperties | Feature::CallStaticMethodOnTrait => self.0 >= 0x08_02_00,
361            Feature::ImplicitlyNullableParameterTypes => self.0 >= 0x08_04_00,
362            Feature::RequiredParameterAfterOptionalUnionOrMixed => self.0 >= 0x08_03_00,
363            Feature::RequiredParameterAfterOptionalNullableAndDefaultNull => self.0 >= 0x08_01_00,
364            Feature::RequiredParameterAfterOptional => self.0 >= 0x08_00_00,
365            Feature::SwitchSemicolonSeparators => self.0 >= 0x08_05_00,
366            _ => false,
367        }
368    }
369
370    /// Converts this `PHPVersion` into a raw version ID (e.g. `80400` for `8.4.0`).
371    ///
372    /// This is the inverse of [`from_version_id`].
373    ///
374    /// # Examples
375    ///
376    /// ```
377    /// use mago_php_version::PHPVersion;
378    ///
379    /// let version = PHPVersion::new(8, 4, 0);
380    /// assert_eq!(version.to_version_id(), 0x080400);
381    /// ```
382    #[inline]
383    #[must_use]
384    pub const fn to_version_id(&self) -> u32 {
385        self.0
386    }
387}
388
389impl PHPVersionRange {
390    /// Represents the range of PHP versions from 7.0.0 to 7.99.99.
391    pub const PHP7: PHPVersionRange = Self::between(PHPVersion::new(7, 0, 0), PHPVersion::new(7, 99, 99));
392
393    /// Represents the range of PHP versions from 8.0.0 to 8.99.99.
394    pub const PHP8: PHPVersionRange = Self::between(PHPVersion::new(8, 0, 0), PHPVersion::new(8, 99, 99));
395
396    /// Creates a new `PHPVersionRange` that includes all versions.
397    #[inline]
398    #[must_use]
399    pub const fn any() -> Self {
400        Self { min: None, max: None }
401    }
402
403    /// Creates a new `PHPVersionRange` that includes all versions up to (and including) the specified version.
404    #[inline]
405    #[must_use]
406    pub const fn until(version: PHPVersion) -> Self {
407        Self { min: None, max: Some(version) }
408    }
409
410    /// Creates a new `PHPVersionRange` that includes all versions from (and including) the specified version.
411    #[inline]
412    #[must_use]
413    pub const fn from(version: PHPVersion) -> Self {
414        Self { min: Some(version), max: None }
415    }
416
417    /// Creates a new `PHPVersionRange` that includes all versions between (and including) the specified minimum and maximum versions.
418    #[inline]
419    #[must_use]
420    pub const fn between(min: PHPVersion, max: PHPVersion) -> Self {
421        Self { min: Some(min), max: Some(max) }
422    }
423
424    /// Checks if this version range supports the given `PHPVersion`.
425    #[inline]
426    #[must_use]
427    pub const fn includes(&self, version: PHPVersion) -> bool {
428        if let Some(min) = self.min
429            && version.0 < min.0
430        {
431            return false;
432        }
433
434        if let Some(max) = self.max
435            && version.0 > max.0
436        {
437            return false;
438        }
439
440        true
441    }
442}
443
444impl std::default::Default for PHPVersion {
445    #[inline]
446    fn default() -> Self {
447        Self::LATEST
448    }
449}
450
451impl std::fmt::Display for PHPVersion {
452    #[inline]
453    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
454        write!(f, "{}.{}.{}", self.major(), self.minor(), self.patch())
455    }
456}
457
458impl Serialize for PHPVersion {
459    #[inline]
460    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
461    where
462        S: Serializer,
463    {
464        serializer.serialize_str(&self.to_string())
465    }
466}
467
468impl<'de> Deserialize<'de> for PHPVersion {
469    #[inline]
470    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
471    where
472        D: Deserializer<'de>,
473    {
474        let s = String::deserialize(deserializer)?;
475
476        s.parse().map_err(serde::de::Error::custom)
477    }
478}
479
480impl FromStr for PHPVersion {
481    type Err = ParsingError;
482
483    #[inline]
484    fn from_str(s: &str) -> Result<Self, Self::Err> {
485        if s.is_empty() {
486            return Err(ParsingError::InvalidFormat);
487        }
488
489        let parts = s.split('.').collect::<Vec<_>>();
490        match parts.len() {
491            1 => {
492                let major = parts[0].parse()?;
493
494                Ok(Self::new(major, 0, 0))
495            }
496            2 => {
497                let major = parts[0].parse()?;
498                let minor = parts[1].parse()?;
499
500                Ok(Self::new(major, minor, 0))
501            }
502            3 => {
503                let major = parts[0].parse()?;
504                let minor = parts[1].parse()?;
505                let patch = parts[2].parse()?;
506
507                Ok(Self::new(major, minor, patch))
508            }
509            _ => Err(ParsingError::InvalidFormat),
510        }
511    }
512}
513
514#[cfg(test)]
515#[allow(clippy::unwrap_used)]
516mod tests {
517    use super::*;
518
519    #[test]
520    fn test_version() {
521        let version = PHPVersion::new(7, 4, 0);
522        assert_eq!(version.major(), 7);
523        assert_eq!(version.minor(), 4);
524        assert_eq!(version.patch(), 0);
525    }
526
527    #[test]
528    fn test_display() {
529        let version = PHPVersion::new(7, 4, 0);
530        assert_eq!(version.to_string(), "7.4.0");
531    }
532
533    #[test]
534    fn test_from_str_single_segment() {
535        let v: PHPVersion = "7".parse().unwrap();
536        assert_eq!(v.major(), 7);
537        assert_eq!(v.minor(), 0);
538        assert_eq!(v.patch(), 0);
539        assert_eq!(v.to_string(), "7.0.0");
540    }
541
542    #[test]
543    fn test_from_str_two_segments() {
544        let v: PHPVersion = "7.4".parse().unwrap();
545        assert_eq!(v.major(), 7);
546        assert_eq!(v.minor(), 4);
547        assert_eq!(v.patch(), 0);
548        assert_eq!(v.to_string(), "7.4.0");
549    }
550
551    #[test]
552    fn test_from_str_three_segments() {
553        let v: PHPVersion = "8.1.2".parse().unwrap();
554        assert_eq!(v.major(), 8);
555        assert_eq!(v.minor(), 1);
556        assert_eq!(v.patch(), 2);
557        assert_eq!(v.to_string(), "8.1.2");
558    }
559
560    #[test]
561    fn test_from_str_invalid() {
562        let err = "7.4.0.1".parse::<PHPVersion>().unwrap_err();
563        assert_eq!(format!("{err}"), "Invalid version format, expected 'major.minor.patch'.");
564
565        let err = "".parse::<PHPVersion>().unwrap_err();
566        assert_eq!(format!("{err}"), "Invalid version format, expected 'major.minor.patch'.");
567
568        let err = "foo.4.0".parse::<PHPVersion>().unwrap_err();
569        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
570
571        let err = "7.foo.0".parse::<PHPVersion>().unwrap_err();
572        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
573
574        let err = "7.4.foo".parse::<PHPVersion>().unwrap_err();
575        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
576    }
577
578    #[test]
579    fn test_is_supported_features_before_8() {
580        let v_7_4_0 = PHPVersion::new(7, 4, 0);
581
582        assert!(v_7_4_0.is_supported(Feature::NullCoalesceAssign));
583        assert!(!v_7_4_0.is_supported(Feature::NamedArguments));
584
585        assert!(v_7_4_0.is_supported(Feature::CallableInstanceMethods));
586        assert!(v_7_4_0.is_supported(Feature::LegacyConstructor));
587    }
588
589    #[test]
590    fn test_is_supported_features_8_0_0() {
591        let v_8_0_0 = PHPVersion::new(8, 0, 0);
592
593        assert!(v_8_0_0.is_supported(Feature::NamedArguments));
594        assert!(!v_8_0_0.is_supported(Feature::CallableInstanceMethods));
595    }
596
597    #[test]
598    fn test_is_deprecated_features() {
599        let v_7_4_0 = PHPVersion::new(7, 4, 0);
600        assert!(!v_7_4_0.is_deprecated(Feature::DynamicProperties));
601        assert!(!v_7_4_0.is_deprecated(Feature::RequiredParameterAfterOptional));
602
603        let v_8_0_0 = PHPVersion::new(8, 0, 0);
604        assert!(v_8_0_0.is_deprecated(Feature::RequiredParameterAfterOptional));
605        assert!(!v_8_0_0.is_deprecated(Feature::DynamicProperties));
606
607        let v_8_2_0 = PHPVersion::new(8, 2, 0);
608        assert!(v_8_2_0.is_deprecated(Feature::DynamicProperties));
609    }
610
611    #[test]
612    fn test_serde_serialize() {
613        let v_7_4_0 = PHPVersion::new(7, 4, 0);
614        let json = serde_json::to_string(&v_7_4_0).unwrap();
615        assert_eq!(json, "\"7.4.0\"");
616    }
617
618    #[test]
619    fn test_serde_deserialize() {
620        let json = "\"7.4.0\"";
621        let v: PHPVersion = serde_json::from_str(json).unwrap();
622        assert_eq!(v.major(), 7);
623        assert_eq!(v.minor(), 4);
624        assert_eq!(v.patch(), 0);
625
626        let json = "\"7.4\"";
627        let v: PHPVersion = serde_json::from_str(json).unwrap();
628        assert_eq!(v.major(), 7);
629        assert_eq!(v.minor(), 4);
630        assert_eq!(v.patch(), 0);
631    }
632
633    #[test]
634    fn test_serde_round_trip() {
635        let original = PHPVersion::new(8, 1, 5);
636        let serialized = serde_json::to_string(&original).unwrap();
637        let deserialized: PHPVersion = serde_json::from_str(&serialized).unwrap();
638        assert_eq!(original, deserialized);
639        assert_eq!(serialized, "\"8.1.5\"");
640    }
641}