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::FalseCompoundTypeHint
280            | Feature::NullCompoundTypeHint
281            | Feature::CatchOptionalVariable => self.0 >= 0x08_00_00,
282            Feature::FinalConstants
283            | Feature::ReadonlyProperties
284            | Feature::Enums
285            | Feature::PureIntersectionTypes
286            | Feature::TentativeReturnTypes
287            | Feature::NeverTypeHint
288            | Feature::ClosureCreation
289            | Feature::ArrayUnpackingWithStringKeys
290            | Feature::SerializableRequiresMagicMethods => self.0 >= 0x08_01_00,
291            Feature::ConstantsInTraits
292            | Feature::StrSplitReturnsEmptyArray
293            | Feature::DisjunctiveNormalForm
294            | Feature::ReadonlyClasses
295            | Feature::NeverReturnTypeInArrowFunction
296            | Feature::PregCaptureOnlyNamedGroups
297            | Feature::TrueTypeHint
298            | Feature::FalseTypeHint
299            | Feature::NullTypeHint => self.0 >= 0x08_02_00,
300            Feature::JsonValidate
301            | Feature::TypedClassLikeConstants
302            | Feature::DateTimeExceptions
303            | Feature::OverrideAttribute
304            | Feature::DynamicClassConstantAccess
305            | Feature::ReadonlyAnonymousClasses
306            | Feature::ReadonlyPropertyReinitializationInClone => self.0 >= 0x08_03_00,
307            Feature::AsymmetricVisibility
308            | Feature::LazyObjects
309            | Feature::HighlightStringDoesNotReturnFalse
310            | Feature::PropertyHooks
311            | Feature::NewWithoutParentheses
312            | Feature::DeprecatedAttribute => self.0 >= 0x08_04_00,
313            Feature::ClosureInConstantExpressions
314            | Feature::ConstantAttributes
315            | Feature::NoDiscardAttribute
316            | Feature::VoidCast
317            | Feature::CloneWith
318            | Feature::AsymmetricVisibilityForStaticProperties
319            | Feature::ClosureCreationInConstantExpressions
320            | Feature::PipeOperator => self.0 >= 0x08_05_00,
321            Feature::CallableInstanceMethods
322            | Feature::LegacyConstructor
323            | Feature::UnsetCast
324            | Feature::CaseInsensitiveConstantNames
325            | Feature::ArrayFunctionsReturnNullWithNonArray
326            | Feature::SubstrReturnFalseInsteadOfEmptyString
327            | Feature::CurlUrlOptionCheckingFileSchemeWithOpenBasedir
328            | Feature::EmptyStringValidAliasForNoneInMbSubstituteCharacter
329            | Feature::NumericStringValidArgInMbSubstituteCharacter => self.0 < 0x08_00_00,
330            Feature::InterfaceConstantImplicitlyFinal => self.0 < 0x08_01_00,
331            Feature::PassNoneEncodings => self.0 < 0x07_03_00,
332            Feature::ImplicitlyNullableParameterTypes => self.0 < 0x09_00_00,
333            Feature::PartialFunctionApplication => self.0 >= 0x08_06_00,
334            _ => true,
335        }
336    }
337
338    /// Checks if a given [`Feature`] is deprecated in this PHP version.
339    ///
340    /// Returns `true` if the feature is *considered deprecated* at or above
341    /// certain version thresholds. The threshold logic is encoded within the `match`.
342    ///
343    /// # Examples
344    ///
345    /// ```
346    /// use mago_php_version::PHPVersion;
347    /// use mago_php_version::feature::Feature;
348    ///
349    /// let version = PHPVersion::new(8, 0, 0);
350    /// assert!(version.is_deprecated(Feature::RequiredParameterAfterOptional));
351    /// assert!(!version.is_deprecated(Feature::DynamicProperties)); // that is 8.2+
352    /// ```
353    #[inline]
354    #[must_use]
355    pub const fn is_deprecated(&self, feature: Feature) -> bool {
356        match feature {
357            Feature::DynamicProperties | Feature::CallStaticMethodOnTrait => self.0 >= 0x08_02_00,
358            Feature::ImplicitlyNullableParameterTypes => self.0 >= 0x08_04_00,
359            Feature::RequiredParameterAfterOptionalUnionOrMixed => self.0 >= 0x08_03_00,
360            Feature::RequiredParameterAfterOptionalNullableAndDefaultNull => self.0 >= 0x08_01_00,
361            Feature::RequiredParameterAfterOptional => self.0 >= 0x08_00_00,
362            Feature::SwitchSemicolonSeparators => self.0 >= 0x08_05_00,
363            _ => false,
364        }
365    }
366
367    /// Converts this `PHPVersion` into a raw version ID (e.g. `80400` for `8.4.0`).
368    ///
369    /// This is the inverse of [`from_version_id`].
370    ///
371    /// # Examples
372    ///
373    /// ```
374    /// use mago_php_version::PHPVersion;
375    ///
376    /// let version = PHPVersion::new(8, 4, 0);
377    /// assert_eq!(version.to_version_id(), 0x080400);
378    /// ```
379    #[inline]
380    #[must_use]
381    pub const fn to_version_id(&self) -> u32 {
382        self.0
383    }
384}
385
386impl PHPVersionRange {
387    /// Represents the range of PHP versions from 7.0.0 to 7.99.99.
388    pub const PHP7: PHPVersionRange = Self::between(PHPVersion::new(7, 0, 0), PHPVersion::new(7, 99, 99));
389
390    /// Represents the range of PHP versions from 8.0.0 to 8.99.99.
391    pub const PHP8: PHPVersionRange = Self::between(PHPVersion::new(8, 0, 0), PHPVersion::new(8, 99, 99));
392
393    /// Creates a new `PHPVersionRange` that includes all versions.
394    #[inline]
395    #[must_use]
396    pub const fn any() -> Self {
397        Self { min: None, max: None }
398    }
399
400    /// Creates a new `PHPVersionRange` that includes all versions up to (and including) the specified version.
401    #[inline]
402    #[must_use]
403    pub const fn until(version: PHPVersion) -> Self {
404        Self { min: None, max: Some(version) }
405    }
406
407    /// Creates a new `PHPVersionRange` that includes all versions from (and including) the specified version.
408    #[inline]
409    #[must_use]
410    pub const fn from(version: PHPVersion) -> Self {
411        Self { min: Some(version), max: None }
412    }
413
414    /// Creates a new `PHPVersionRange` that includes all versions between (and including) the specified minimum and maximum versions.
415    #[inline]
416    #[must_use]
417    pub const fn between(min: PHPVersion, max: PHPVersion) -> Self {
418        Self { min: Some(min), max: Some(max) }
419    }
420
421    /// Checks if this version range supports the given `PHPVersion`.
422    #[inline]
423    #[must_use]
424    pub const fn includes(&self, version: PHPVersion) -> bool {
425        if let Some(min) = self.min
426            && version.0 < min.0
427        {
428            return false;
429        }
430
431        if let Some(max) = self.max
432            && version.0 > max.0
433        {
434            return false;
435        }
436
437        true
438    }
439}
440
441impl std::default::Default for PHPVersion {
442    #[inline]
443    fn default() -> Self {
444        Self::LATEST
445    }
446}
447
448impl std::fmt::Display for PHPVersion {
449    #[inline]
450    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
451        write!(f, "{}.{}.{}", self.major(), self.minor(), self.patch())
452    }
453}
454
455#[cfg(feature = "serde")]
456impl serde::Serialize for PHPVersion {
457    #[inline]
458    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
459    where
460        S: serde::Serializer,
461    {
462        serializer.serialize_str(&self.to_string())
463    }
464}
465
466#[cfg(feature = "serde")]
467impl<'de> serde::Deserialize<'de> for PHPVersion {
468    #[inline]
469    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
470    where
471        D: serde::Deserializer<'de>,
472    {
473        let s = String::deserialize(deserializer)?;
474
475        s.parse().map_err(serde::de::Error::custom)
476    }
477}
478
479impl FromStr for PHPVersion {
480    type Err = ParsingError;
481
482    #[inline]
483    fn from_str(s: &str) -> Result<Self, Self::Err> {
484        if s.is_empty() {
485            return Err(ParsingError::InvalidFormat);
486        }
487
488        let parts = s.split('.').collect::<Vec<_>>();
489        match parts.len() {
490            1 => {
491                let major = parts[0].parse()?;
492
493                Ok(Self::new(major, 0, 0))
494            }
495            2 => {
496                let major = parts[0].parse()?;
497                let minor = parts[1].parse()?;
498
499                Ok(Self::new(major, minor, 0))
500            }
501            3 => {
502                let major = parts[0].parse()?;
503                let minor = parts[1].parse()?;
504                let patch = parts[2].parse()?;
505
506                Ok(Self::new(major, minor, patch))
507            }
508            _ => Err(ParsingError::InvalidFormat),
509        }
510    }
511}
512
513#[cfg(test)]
514#[allow(clippy::unwrap_used)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn test_version() {
520        let version = PHPVersion::new(7, 4, 0);
521        assert_eq!(version.major(), 7);
522        assert_eq!(version.minor(), 4);
523        assert_eq!(version.patch(), 0);
524    }
525
526    #[test]
527    fn test_display() {
528        let version = PHPVersion::new(7, 4, 0);
529        assert_eq!(version.to_string(), "7.4.0");
530    }
531
532    #[test]
533    fn test_from_str_single_segment() {
534        let v: PHPVersion = "7".parse().unwrap();
535        assert_eq!(v.major(), 7);
536        assert_eq!(v.minor(), 0);
537        assert_eq!(v.patch(), 0);
538        assert_eq!(v.to_string(), "7.0.0");
539    }
540
541    #[test]
542    fn test_from_str_two_segments() {
543        let v: PHPVersion = "7.4".parse().unwrap();
544        assert_eq!(v.major(), 7);
545        assert_eq!(v.minor(), 4);
546        assert_eq!(v.patch(), 0);
547        assert_eq!(v.to_string(), "7.4.0");
548    }
549
550    #[test]
551    fn test_from_str_three_segments() {
552        let v: PHPVersion = "8.1.2".parse().unwrap();
553        assert_eq!(v.major(), 8);
554        assert_eq!(v.minor(), 1);
555        assert_eq!(v.patch(), 2);
556        assert_eq!(v.to_string(), "8.1.2");
557    }
558
559    #[test]
560    fn test_from_str_invalid() {
561        let err = "7.4.0.1".parse::<PHPVersion>().unwrap_err();
562        assert_eq!(format!("{err}"), "Invalid version format, expected 'major.minor.patch'.");
563
564        let err = "".parse::<PHPVersion>().unwrap_err();
565        assert_eq!(format!("{err}"), "Invalid version format, expected 'major.minor.patch'.");
566
567        let err = "foo.4.0".parse::<PHPVersion>().unwrap_err();
568        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
569
570        let err = "7.foo.0".parse::<PHPVersion>().unwrap_err();
571        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
572
573        let err = "7.4.foo".parse::<PHPVersion>().unwrap_err();
574        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
575    }
576
577    #[test]
578    fn test_is_supported_features_before_8() {
579        let v_7_4_0 = PHPVersion::new(7, 4, 0);
580
581        assert!(v_7_4_0.is_supported(Feature::NullCoalesceAssign));
582        assert!(!v_7_4_0.is_supported(Feature::NamedArguments));
583
584        assert!(v_7_4_0.is_supported(Feature::CallableInstanceMethods));
585        assert!(v_7_4_0.is_supported(Feature::LegacyConstructor));
586    }
587
588    #[test]
589    fn test_is_supported_features_8_0_0() {
590        let v_8_0_0 = PHPVersion::new(8, 0, 0);
591
592        assert!(v_8_0_0.is_supported(Feature::NamedArguments));
593        assert!(!v_8_0_0.is_supported(Feature::CallableInstanceMethods));
594    }
595
596    #[test]
597    fn test_is_deprecated_features() {
598        let v_7_4_0 = PHPVersion::new(7, 4, 0);
599        assert!(!v_7_4_0.is_deprecated(Feature::DynamicProperties));
600        assert!(!v_7_4_0.is_deprecated(Feature::RequiredParameterAfterOptional));
601
602        let v_8_0_0 = PHPVersion::new(8, 0, 0);
603        assert!(v_8_0_0.is_deprecated(Feature::RequiredParameterAfterOptional));
604        assert!(!v_8_0_0.is_deprecated(Feature::DynamicProperties));
605
606        let v_8_2_0 = PHPVersion::new(8, 2, 0);
607        assert!(v_8_2_0.is_deprecated(Feature::DynamicProperties));
608    }
609
610    #[cfg(feature = "serde")]
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    #[cfg(feature = "serde")]
619    #[test]
620    fn test_serde_deserialize() {
621        let json = "\"7.4.0\"";
622        let v: PHPVersion = serde_json::from_str(json).unwrap();
623        assert_eq!(v.major(), 7);
624        assert_eq!(v.minor(), 4);
625        assert_eq!(v.patch(), 0);
626
627        let json = "\"7.4\"";
628        let v: PHPVersion = serde_json::from_str(json).unwrap();
629        assert_eq!(v.major(), 7);
630        assert_eq!(v.minor(), 4);
631        assert_eq!(v.patch(), 0);
632    }
633
634    #[cfg(feature = "serde")]
635    #[test]
636    fn test_serde_round_trip() {
637        let original = PHPVersion::new(8, 1, 5);
638        let serialized = serde_json::to_string(&original).unwrap();
639        let deserialized: PHPVersion = serde_json::from_str(&serialized).unwrap();
640        assert_eq!(original, deserialized);
641        assert_eq!(serialized, "\"8.1.5\"");
642    }
643}