1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[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#[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#[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#[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#[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#[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#[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}