Skip to main content

mago_php_version/
lib.rs

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