1use std::fmt;
8use std::str::FromStr;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct PhpVersion {
12 major: u8,
13 minor: u8,
14}
15
16impl PhpVersion {
17 pub const LATEST: PhpVersion = PhpVersion::new(8, 5);
18
19 pub const fn new(major: u8, minor: u8) -> Self {
20 Self { major, minor }
21 }
22
23 pub const fn major(self) -> u8 {
24 self.major
25 }
26
27 pub const fn minor(self) -> u8 {
28 self.minor
29 }
30
31 pub const fn cache_byte(self) -> u8 {
36 (self.major << 4) | (self.minor & 0x0F)
37 }
38
39 pub fn includes_symbol(self, since: Option<&str>, removed: Option<&str>) -> bool {
49 let parse_php = |s: &str| -> Option<PhpVersion> {
50 let v = s.parse::<PhpVersion>().ok()?;
51 if v.major() >= 4 && v.major() <= PhpVersion::LATEST.major() {
55 Some(v)
56 } else {
57 None
58 }
59 };
60 if let Some(s) = since.and_then(parse_php) {
61 if self < s {
62 return false;
63 }
64 }
65 if let Some(r) = removed.and_then(parse_php) {
66 if self >= r {
67 return false;
68 }
69 }
70 true
71 }
72
73 pub fn in_range(self, from: Option<&str>, to_inclusive: Option<&str>) -> bool {
84 let parse = |s: &str| s.parse::<PhpVersion>().ok();
85 if let Some(f) = from.and_then(parse) {
86 if self < f {
87 return false;
88 }
89 }
90 if let Some(t) = to_inclusive.and_then(parse) {
91 if self > t {
92 return false;
93 }
94 }
95 true
96 }
97}
98
99impl Default for PhpVersion {
100 fn default() -> Self {
101 Self::LATEST
102 }
103}
104
105impl fmt::Display for PhpVersion {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 write!(f, "{}.{}", self.major, self.minor)
108 }
109}
110
111#[derive(Debug, thiserror::Error)]
112#[error("invalid PHP version `{0}`: expected `MAJOR.MINOR` (e.g. `8.2`)")]
113pub struct ParsePhpVersionError(pub String);
114
115impl FromStr for PhpVersion {
116 type Err = ParsePhpVersionError;
117
118 fn from_str(s: &str) -> Result<Self, Self::Err> {
119 let mut parts = s.trim().split('.');
120 let major = parts
121 .next()
122 .and_then(|p| p.parse::<u8>().ok())
123 .ok_or_else(|| ParsePhpVersionError(s.to_string()))?;
124 let minor = match parts.next() {
125 Some(p) => p
126 .parse::<u8>()
127 .map_err(|_| ParsePhpVersionError(s.to_string()))?,
128 None => 0,
129 };
130 Ok(Self::new(major, minor))
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn parses_major_minor() {
141 assert_eq!("8.2".parse::<PhpVersion>().unwrap(), PhpVersion::new(8, 2));
142 }
143
144 #[test]
145 fn parses_major_minor_patch() {
146 assert_eq!(
147 "8.3.7".parse::<PhpVersion>().unwrap(),
148 PhpVersion::new(8, 3)
149 );
150 }
151
152 #[test]
153 fn parses_major_only() {
154 assert_eq!("7".parse::<PhpVersion>().unwrap(), PhpVersion::new(7, 0));
155 }
156
157 #[test]
158 fn rejects_garbage() {
159 assert!("x.y".parse::<PhpVersion>().is_err());
160 assert!("8.x".parse::<PhpVersion>().is_err());
161 assert!("".parse::<PhpVersion>().is_err());
162 }
163
164 #[test]
165 fn ordered_by_major_then_minor() {
166 assert!(PhpVersion::new(8, 1) < PhpVersion::new(8, 2));
167 assert!(PhpVersion::new(7, 4) < PhpVersion::new(8, 0));
168 }
169
170 #[test]
171 fn displays_as_major_dot_minor() {
172 assert_eq!(PhpVersion::new(8, 3).to_string(), "8.3");
173 }
174
175 #[test]
176 fn includes_symbol_respects_since() {
177 assert!(!PhpVersion::new(7, 4).includes_symbol(Some("8.0"), None));
178 assert!(PhpVersion::new(8, 0).includes_symbol(Some("8.0"), None));
179 assert!(PhpVersion::new(8, 5).includes_symbol(Some("8.0"), None));
180 }
181
182 #[test]
183 fn includes_symbol_respects_removed() {
184 assert!(PhpVersion::new(7, 4).includes_symbol(None, Some("8.0")));
185 assert!(!PhpVersion::new(8, 0).includes_symbol(None, Some("8.0")));
186 assert!(!PhpVersion::new(8, 5).includes_symbol(None, Some("8.0")));
187 }
188
189 #[test]
190 fn includes_symbol_ignores_library_versions() {
191 assert!(PhpVersion::new(8, 5).includes_symbol(Some("9.12"), None));
194 assert!(PhpVersion::new(8, 5).includes_symbol(Some("1.17"), None));
197 assert!(PhpVersion::new(8, 5).includes_symbol(Some("12.0"), None));
198 }
199
200 #[test]
201 fn includes_symbol_ignores_garbage() {
202 assert!(PhpVersion::new(8, 5).includes_symbol(Some("PECL"), None));
203 assert!(PhpVersion::new(8, 5).includes_symbol(Some(""), None));
204 }
205
206 #[test]
207 fn in_range_inclusive_both_bounds() {
208 let v = PhpVersion::new;
209 assert!(v(7, 0).in_range(Some("7.0"), Some("8.0")));
211 assert!(v(8, 0).in_range(Some("7.0"), Some("8.0"))); assert!(v(7, 4).in_range(Some("7.0"), Some("8.0")));
213 assert!(!v(8, 1).in_range(Some("7.0"), Some("8.0")));
214 assert!(!v(6, 4).in_range(Some("7.0"), Some("8.0")));
215 }
216
217 #[test]
218 fn in_range_open_bounds() {
219 let v = PhpVersion::new;
220 assert!(!v(7, 4).in_range(Some("8.0"), None));
222 assert!(v(8, 0).in_range(Some("8.0"), None));
223 assert!(v(8, 5).in_range(Some("8.0"), None));
224 assert!(v(7, 4).in_range(None, Some("8.0")));
226 assert!(v(8, 0).in_range(None, Some("8.0")));
227 assert!(!v(8, 1).in_range(None, Some("8.0")));
228 assert!(v(7, 4).in_range(None, None));
230 }
231
232 #[test]
233 fn in_range_across_the_7_4_to_8_0_jump() {
234 let v = PhpVersion::new;
235 assert!(v(7, 4).in_range(Some("7.0"), Some("8.0")));
237 assert!(!v(7, 4).in_range(Some("8.1"), None));
238 assert!(!v(8, 1).in_range(Some("7.0"), Some("8.0")));
239 assert!(v(8, 1).in_range(Some("8.1"), None));
240 }
241
242 #[test]
243 fn in_range_ignores_unparseable_bounds() {
244 let v = PhpVersion::new(8, 2);
245 assert!(v.in_range(Some("garbage"), None));
246 assert!(v.in_range(None, Some("")));
247 assert!(v.in_range(Some("8.0.1"), Some("8.3.9")));
249 }
250}