Skip to main content

use_php_version/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// PHP major version component.
8#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub struct PhpMajorVersion(u16);
10
11impl PhpMajorVersion {
12    pub const fn new(value: u16) -> Result<Self, PhpVersionParseError> {
13        if value == 0 {
14            Err(PhpVersionParseError::InvalidVersion)
15        } else {
16            Ok(Self(value))
17        }
18    }
19
20    pub const fn get(self) -> u16 {
21        self.0
22    }
23}
24
25/// PHP minor version component.
26#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
27pub struct PhpMinorVersion(u16);
28
29impl PhpMinorVersion {
30    pub const fn new(value: u16) -> Self {
31        Self(value)
32    }
33
34    pub const fn get(self) -> u16 {
35        self.0
36    }
37}
38
39/// PHP patch version component.
40#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
41pub struct PhpPatchVersion(u16);
42
43impl PhpPatchVersion {
44    pub const fn new(value: u16) -> Self {
45        Self(value)
46    }
47
48    pub const fn get(self) -> u16 {
49        self.0
50    }
51}
52
53/// Lightweight PHP version metadata.
54#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
55pub struct PhpVersion {
56    major: PhpMajorVersion,
57    minor: Option<PhpMinorVersion>,
58    patch: Option<PhpPatchVersion>,
59    suffix: Option<String>,
60}
61
62impl PhpVersion {
63    pub fn new(
64        major: u16,
65        minor: Option<u16>,
66        patch: Option<u16>,
67    ) -> Result<Self, PhpVersionParseError> {
68        if minor.is_none() && patch.is_some() {
69            return Err(PhpVersionParseError::InvalidVersion);
70        }
71
72        Ok(Self {
73            major: PhpMajorVersion::new(major)?,
74            minor: minor.map(PhpMinorVersion::new),
75            patch: patch.map(PhpPatchVersion::new),
76            suffix: None,
77        })
78    }
79
80    pub const fn major(&self) -> u16 {
81        self.major.get()
82    }
83
84    pub const fn minor(&self) -> Option<u16> {
85        match self.minor {
86            Some(value) => Some(value.get()),
87            None => None,
88        }
89    }
90
91    pub const fn patch(&self) -> Option<u16> {
92        match self.patch {
93            Some(value) => Some(value.get()),
94            None => None,
95        }
96    }
97
98    pub fn suffix(&self) -> Option<&str> {
99        self.suffix.as_deref()
100    }
101
102    pub const fn is_php8_or_newer(&self) -> bool {
103        self.major() >= 8
104    }
105}
106
107impl fmt::Display for PhpVersion {
108    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109        write!(formatter, "{}", self.major())?;
110        if let Some(minor) = self.minor() {
111            write!(formatter, ".{minor}")?;
112        }
113        if let Some(patch) = self.patch() {
114            write!(formatter, ".{patch}")?;
115        }
116        if let Some(suffix) = self.suffix() {
117            formatter.write_str(suffix)?;
118        }
119        Ok(())
120    }
121}
122
123impl FromStr for PhpVersion {
124    type Err = PhpVersionParseError;
125
126    fn from_str(input: &str) -> Result<Self, Self::Err> {
127        parse_php_version(input)
128    }
129}
130
131/// PHP minor release branch metadata such as `8.3`.
132#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
133pub struct PhpVersionBranch {
134    major: PhpMajorVersion,
135    minor: PhpMinorVersion,
136}
137
138impl PhpVersionBranch {
139    pub fn new(major: u16, minor: u16) -> Result<Self, PhpVersionParseError> {
140        Ok(Self {
141            major: PhpMajorVersion::new(major)?,
142            minor: PhpMinorVersion::new(minor),
143        })
144    }
145
146    pub fn from_version(version: &PhpVersion) -> Result<Self, PhpVersionParseError> {
147        let Some(minor) = version.minor() else {
148            return Err(PhpVersionParseError::InvalidVersion);
149        };
150        Self::new(version.major(), minor)
151    }
152
153    pub const fn major(self) -> u16 {
154        self.major.get()
155    }
156
157    pub const fn minor(self) -> u16 {
158        self.minor.get()
159    }
160}
161
162impl fmt::Display for PhpVersionBranch {
163    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
164        write!(formatter, "{}.{}", self.major(), self.minor())
165    }
166}
167
168/// Static support phase labels for PHP version metadata.
169#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
170pub enum PhpSupportPhase {
171    Active,
172    Security,
173    EndOfLife,
174    Unknown,
175}
176
177impl PhpSupportPhase {
178    pub const fn as_str(self) -> &'static str {
179        match self {
180            Self::Active => "active",
181            Self::Security => "security",
182            Self::EndOfLife => "end-of-life",
183            Self::Unknown => "unknown",
184        }
185    }
186}
187
188impl fmt::Display for PhpSupportPhase {
189    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
190        formatter.write_str(self.as_str())
191    }
192}
193
194impl FromStr for PhpSupportPhase {
195    type Err = PhpVersionParseError;
196
197    fn from_str(input: &str) -> Result<Self, Self::Err> {
198        match normalized_label(input)?.as_str() {
199            "active" => Ok(Self::Active),
200            "security" | "securityonly" => Ok(Self::Security),
201            "endoflife" | "eol" => Ok(Self::EndOfLife),
202            "unknown" => Ok(Self::Unknown),
203            _ => Err(PhpVersionParseError::UnknownLabel),
204        }
205    }
206}
207
208/// Error returned when PHP version metadata cannot be parsed.
209#[derive(Clone, Copy, Debug, Eq, PartialEq)]
210pub enum PhpVersionParseError {
211    Empty,
212    InvalidNumber,
213    InvalidVersion,
214    TooManyComponents,
215    UnknownLabel,
216}
217
218impl fmt::Display for PhpVersionParseError {
219    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
220        match self {
221            Self::Empty => formatter.write_str("PHP version metadata cannot be empty"),
222            Self::InvalidNumber => formatter.write_str("PHP version contains an invalid number"),
223            Self::InvalidVersion => formatter.write_str("PHP version has an invalid shape"),
224            Self::TooManyComponents => formatter.write_str("PHP version has too many components"),
225            Self::UnknownLabel => formatter.write_str("unknown PHP version metadata label"),
226        }
227    }
228}
229
230impl Error for PhpVersionParseError {}
231
232pub fn parse_php_version(input: &str) -> Result<PhpVersion, PhpVersionParseError> {
233    let trimmed = input.trim();
234    if trimmed.is_empty() {
235        return Err(PhpVersionParseError::Empty);
236    }
237
238    let suffix_start = trimmed.char_indices().find_map(|(index, character)| {
239        (!character.is_ascii_digit() && character != '.').then_some(index)
240    });
241    let (core, suffix) = match suffix_start {
242        Some(index) => (&trimmed[..index], Some(trimmed[index..].to_string())),
243        None => (trimmed, None),
244    };
245    if core.is_empty() || core.ends_with('.') {
246        return Err(PhpVersionParseError::InvalidVersion);
247    }
248
249    let parts = core.split('.').collect::<Vec<_>>();
250    if parts.len() > 3 {
251        return Err(PhpVersionParseError::TooManyComponents);
252    }
253    if parts.iter().any(|part| part.is_empty()) {
254        return Err(PhpVersionParseError::InvalidVersion);
255    }
256
257    let major = parts[0]
258        .parse::<u16>()
259        .map_err(|_| PhpVersionParseError::InvalidNumber)?;
260    let minor = parts
261        .get(1)
262        .map(|part| {
263            part.parse::<u16>()
264                .map_err(|_| PhpVersionParseError::InvalidNumber)
265        })
266        .transpose()?;
267    let patch = parts
268        .get(2)
269        .map(|part| {
270            part.parse::<u16>()
271                .map_err(|_| PhpVersionParseError::InvalidNumber)
272        })
273        .transpose()?;
274
275    let mut version = PhpVersion::new(major, minor, patch)?;
276    version.suffix = suffix;
277    Ok(version)
278}
279
280fn normalized_label(input: &str) -> Result<String, PhpVersionParseError> {
281    let trimmed = input.trim();
282    if trimmed.is_empty() {
283        Err(PhpVersionParseError::Empty)
284    } else {
285        Ok(trimmed.to_ascii_lowercase().replace(['-', '_', ' '], ""))
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::{PhpSupportPhase, PhpVersion, PhpVersionBranch, PhpVersionParseError};
292
293    #[test]
294    fn parses_version_and_branch() -> Result<(), PhpVersionParseError> {
295        let version: PhpVersion = "8.3.2RC1".parse()?;
296        let branch = PhpVersionBranch::from_version(&version)?;
297
298        assert_eq!(version.major(), 8);
299        assert_eq!(version.minor(), Some(3));
300        assert_eq!(version.patch(), Some(2));
301        assert_eq!(version.suffix(), Some("RC1"));
302        assert_eq!(branch.to_string(), "8.3");
303        assert!(version.is_php8_or_newer());
304        Ok(())
305    }
306
307    #[test]
308    fn parses_support_phase_labels() -> Result<(), PhpVersionParseError> {
309        assert_eq!(
310            "security-only".parse::<PhpSupportPhase>()?,
311            PhpSupportPhase::Security
312        );
313        assert_eq!(PhpSupportPhase::EndOfLife.to_string(), "end-of-life");
314        Ok(())
315    }
316}