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    #[must_use]
218    pub const fn is_at_least(&self, major: u32, minor: u32, patch: u32) -> bool {
219        self.0 >= ((major << 16) | (minor << 8) | patch)
220    }
221
222    /// Checks if a given [`Feature`] is supported by this PHP version.
223    ///
224    /// The logic is based on version thresholds (e.g. `>= 8.0.0` or `< 8.0.0`).
225    /// Each `Feature` variant corresponds to a behavior introduced, removed, or changed
226    /// at a particular version boundary.
227    ///
228    /// # Examples
229    ///
230    /// ```
231    /// use mago_php_version::PHPVersion;
232    /// use mago_php_version::feature::Feature;
233    ///
234    /// let version = PHPVersion::new(7, 4, 0);
235    /// assert!(version.is_supported(Feature::NullCoalesceAssign));
236    /// assert!(!version.is_supported(Feature::NamedArguments));
237    /// ```
238    #[must_use]
239    pub const fn is_supported(&self, feature: Feature) -> bool {
240        match feature {
241            Feature::NullableTypeHint
242            | Feature::IterableTypeHint
243            | Feature::VoidTypeHint
244            | Feature::ClassLikeConstantVisibilityModifiers
245            | Feature::CatchUnionType => self.0 >= 0x07_01_00,
246            Feature::TrailingCommaInListSyntax
247            | Feature::ParameterTypeWidening
248            | Feature::AllUnicodeScalarCodePointsInMbSubstituteCharacter => self.0 >= 0x07_02_00,
249            Feature::ListReferenceAssignment | Feature::TrailingCommaInFunctionCalls => self.0 >= 0x07_03_00,
250            Feature::NullCoalesceAssign
251            | Feature::ParameterContravariance
252            | Feature::ReturnCovariance
253            | Feature::PregUnmatchedAsNull
254            | Feature::ArrowFunctions
255            | Feature::NumericLiteralSeparator
256            | Feature::TypedProperties => self.0 >= 0x07_04_00,
257            Feature::NonCapturingCatches
258            | Feature::NativeUnionTypes
259            | Feature::LessOverriddenParametersWithVariadic
260            | Feature::ThrowExpression
261            | Feature::ClassConstantOnExpression
262            | Feature::PromotedProperties
263            | Feature::NamedArguments
264            | Feature::ThrowsTypeErrorForInternalFunctions
265            | Feature::ThrowsValueErrorForInternalFunctions
266            | Feature::HHPrintfSpecifier
267            | Feature::StricterRoundFunctions
268            | Feature::ThrowsOnInvalidMbStringEncoding
269            | Feature::WarnsAboutFinalPrivateMethods
270            | Feature::CastsNumbersToStringsOnLooseComparison
271            | Feature::NonNumericStringAndIntegerIsFalseOnLooseComparison
272            | Feature::AbstractTraitMethods
273            | Feature::StaticReturnTypeHint
274            | Feature::AccessClassOnObject
275            | Feature::Attributes
276            | Feature::MixedTypeHint
277            | Feature::MatchExpression
278            | Feature::NullSafeOperator
279            | Feature::TrailingCommaInClosureUseList
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    #[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    #[must_use]
380    pub const fn to_version_id(&self) -> u32 {
381        self.0
382    }
383}
384
385impl PHPVersionRange {
386    /// Represents the range of PHP versions from 7.0.0 to 7.99.99.
387    pub const PHP7: PHPVersionRange = Self::between(PHPVersion::new(7, 0, 0), PHPVersion::new(7, 99, 99));
388
389    /// Represents the range of PHP versions from 8.0.0 to 8.99.99.
390    pub const PHP8: PHPVersionRange = Self::between(PHPVersion::new(8, 0, 0), PHPVersion::new(8, 99, 99));
391
392    /// Creates a new `PHPVersionRange` that includes all versions.
393    #[must_use]
394    pub const fn any() -> Self {
395        Self { min: None, max: None }
396    }
397
398    /// Creates a new `PHPVersionRange` that includes all versions up to (and including) the specified version.
399    #[must_use]
400    pub const fn until(version: PHPVersion) -> Self {
401        Self { min: None, max: Some(version) }
402    }
403
404    /// Creates a new `PHPVersionRange` that includes all versions from (and including) the specified version.
405    #[must_use]
406    pub const fn from(version: PHPVersion) -> Self {
407        Self { min: Some(version), max: None }
408    }
409
410    /// Creates a new `PHPVersionRange` that includes all versions between (and including) the specified minimum and maximum versions.
411    #[must_use]
412    pub const fn between(min: PHPVersion, max: PHPVersion) -> Self {
413        Self { min: Some(min), max: Some(max) }
414    }
415
416    /// Checks if this version range supports the given `PHPVersion`.
417    #[inline]
418    #[must_use]
419    pub const fn includes(&self, version: PHPVersion) -> bool {
420        if let Some(min) = self.min
421            && version.0 < min.0
422        {
423            return false;
424        }
425
426        if let Some(max) = self.max
427            && version.0 > max.0
428        {
429            return false;
430        }
431
432        true
433    }
434}
435
436impl std::default::Default for PHPVersion {
437    fn default() -> Self {
438        Self::LATEST
439    }
440}
441
442impl std::fmt::Display for PHPVersion {
443    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
444        write!(f, "{}.{}.{}", self.major(), self.minor(), self.patch())
445    }
446}
447
448impl Serialize for PHPVersion {
449    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
450    where
451        S: Serializer,
452    {
453        serializer.serialize_str(&self.to_string())
454    }
455}
456
457impl<'de> Deserialize<'de> for PHPVersion {
458    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
459    where
460        D: Deserializer<'de>,
461    {
462        let s = String::deserialize(deserializer)?;
463
464        s.parse().map_err(serde::de::Error::custom)
465    }
466}
467
468impl FromStr for PHPVersion {
469    type Err = ParsingError;
470
471    fn from_str(s: &str) -> Result<Self, Self::Err> {
472        if s.is_empty() {
473            return Err(ParsingError::InvalidFormat);
474        }
475
476        let parts = s.split('.').collect::<Vec<_>>();
477        match parts.len() {
478            1 => {
479                let major = parts[0].parse()?;
480
481                Ok(Self::new(major, 0, 0))
482            }
483            2 => {
484                let major = parts[0].parse()?;
485                let minor = parts[1].parse()?;
486
487                Ok(Self::new(major, minor, 0))
488            }
489            3 => {
490                let major = parts[0].parse()?;
491                let minor = parts[1].parse()?;
492                let patch = parts[2].parse()?;
493
494                Ok(Self::new(major, minor, patch))
495            }
496            _ => Err(ParsingError::InvalidFormat),
497        }
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn test_version() {
507        let version = PHPVersion::new(7, 4, 0);
508        assert_eq!(version.major(), 7);
509        assert_eq!(version.minor(), 4);
510        assert_eq!(version.patch(), 0);
511    }
512
513    #[test]
514    fn test_display() {
515        let version = PHPVersion::new(7, 4, 0);
516        assert_eq!(version.to_string(), "7.4.0");
517    }
518
519    #[test]
520    fn test_from_str_single_segment() {
521        let v: PHPVersion = "7".parse().unwrap();
522        assert_eq!(v.major(), 7);
523        assert_eq!(v.minor(), 0);
524        assert_eq!(v.patch(), 0);
525        assert_eq!(v.to_string(), "7.0.0");
526    }
527
528    #[test]
529    fn test_from_str_two_segments() {
530        let v: PHPVersion = "7.4".parse().unwrap();
531        assert_eq!(v.major(), 7);
532        assert_eq!(v.minor(), 4);
533        assert_eq!(v.patch(), 0);
534        assert_eq!(v.to_string(), "7.4.0");
535    }
536
537    #[test]
538    fn test_from_str_three_segments() {
539        let v: PHPVersion = "8.1.2".parse().unwrap();
540        assert_eq!(v.major(), 8);
541        assert_eq!(v.minor(), 1);
542        assert_eq!(v.patch(), 2);
543        assert_eq!(v.to_string(), "8.1.2");
544    }
545
546    #[test]
547    fn test_from_str_invalid() {
548        let err = "7.4.0.1".parse::<PHPVersion>().unwrap_err();
549        assert_eq!(format!("{err}"), "Invalid version format, expected 'major.minor.patch'.");
550
551        let err = "".parse::<PHPVersion>().unwrap_err();
552        assert_eq!(format!("{err}"), "Invalid version format, expected 'major.minor.patch'.");
553
554        let err = "foo.4.0".parse::<PHPVersion>().unwrap_err();
555        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
556
557        let err = "7.foo.0".parse::<PHPVersion>().unwrap_err();
558        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
559
560        let err = "7.4.foo".parse::<PHPVersion>().unwrap_err();
561        assert_eq!(format!("{err}"), "Failed to parse integer component of version: invalid digit found in string.");
562    }
563
564    #[test]
565    fn test_is_supported_features_before_8() {
566        let v_7_4_0 = PHPVersion::new(7, 4, 0);
567
568        assert!(v_7_4_0.is_supported(Feature::NullCoalesceAssign));
569        assert!(!v_7_4_0.is_supported(Feature::NamedArguments));
570
571        assert!(v_7_4_0.is_supported(Feature::CallableInstanceMethods));
572        assert!(v_7_4_0.is_supported(Feature::LegacyConstructor));
573    }
574
575    #[test]
576    fn test_is_supported_features_8_0_0() {
577        let v_8_0_0 = PHPVersion::new(8, 0, 0);
578
579        assert!(v_8_0_0.is_supported(Feature::NamedArguments));
580        assert!(!v_8_0_0.is_supported(Feature::CallableInstanceMethods));
581    }
582
583    #[test]
584    fn test_is_deprecated_features() {
585        let v_7_4_0 = PHPVersion::new(7, 4, 0);
586        assert!(!v_7_4_0.is_deprecated(Feature::DynamicProperties));
587        assert!(!v_7_4_0.is_deprecated(Feature::RequiredParameterAfterOptional));
588
589        let v_8_0_0 = PHPVersion::new(8, 0, 0);
590        assert!(v_8_0_0.is_deprecated(Feature::RequiredParameterAfterOptional));
591        assert!(!v_8_0_0.is_deprecated(Feature::DynamicProperties));
592
593        let v_8_2_0 = PHPVersion::new(8, 2, 0);
594        assert!(v_8_2_0.is_deprecated(Feature::DynamicProperties));
595    }
596
597    #[test]
598    fn test_serde_serialize() {
599        let v_7_4_0 = PHPVersion::new(7, 4, 0);
600        let json = serde_json::to_string(&v_7_4_0).unwrap();
601        assert_eq!(json, "\"7.4.0\"");
602    }
603
604    #[test]
605    fn test_serde_deserialize() {
606        let json = "\"7.4.0\"";
607        let v: PHPVersion = serde_json::from_str(json).unwrap();
608        assert_eq!(v.major(), 7);
609        assert_eq!(v.minor(), 4);
610        assert_eq!(v.patch(), 0);
611
612        let json = "\"7.4\"";
613        let v: PHPVersion = serde_json::from_str(json).unwrap();
614        assert_eq!(v.major(), 7);
615        assert_eq!(v.minor(), 4);
616        assert_eq!(v.patch(), 0);
617    }
618
619    #[test]
620    fn test_serde_round_trip() {
621        let original = PHPVersion::new(8, 1, 5);
622        let serialized = serde_json::to_string(&original).unwrap();
623        let deserialized: PHPVersion = serde_json::from_str(&serialized).unwrap();
624        assert_eq!(original, deserialized);
625        assert_eq!(serialized, "\"8.1.5\"");
626    }
627}