1use std::cmp::Ordering;
11use std::fmt;
12use std::str::FromStr;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub struct NginxVersion {
20 pub major: u32,
21 pub minor: u32,
22 pub patch: u32,
23}
24
25impl NginxVersion {
26 pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
28 Self {
29 major,
30 minor,
31 patch,
32 }
33 }
34
35 pub fn parse(s: &str) -> Result<Self, NginxVersionParseError> {
37 let parts: Vec<&str> = s.split('.').collect();
38 if parts.len() != 3 {
39 return Err(NginxVersionParseError::InvalidFormat(s.to_string()));
40 }
41 let major = parts[0]
42 .parse::<u32>()
43 .map_err(|_| NginxVersionParseError::InvalidComponent(s.to_string()))?;
44 let minor = parts[1]
45 .parse::<u32>()
46 .map_err(|_| NginxVersionParseError::InvalidComponent(s.to_string()))?;
47 let patch = parts[2]
48 .parse::<u32>()
49 .map_err(|_| NginxVersionParseError::InvalidComponent(s.to_string()))?;
50 Ok(Self {
51 major,
52 minor,
53 patch,
54 })
55 }
56}
57
58impl Ord for NginxVersion {
59 fn cmp(&self, other: &Self) -> Ordering {
60 (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
61 }
62}
63
64impl PartialOrd for NginxVersion {
65 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
66 Some(self.cmp(other))
67 }
68}
69
70impl fmt::Display for NginxVersion {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
73 }
74}
75
76impl FromStr for NginxVersion {
77 type Err = NginxVersionParseError;
78
79 fn from_str(s: &str) -> Result<Self, Self::Err> {
80 Self::parse(s)
81 }
82}
83
84pub fn is_in_range(
95 version: &NginxVersion,
96 min: Option<&NginxVersion>,
97 max: Option<&NginxVersion>,
98) -> bool {
99 if let (Some(min), Some(max)) = (min, max) {
100 debug_assert!(
101 min <= max,
102 "is_in_range called with reversed bounds: min={} > max={}",
103 min,
104 max
105 );
106 }
107 if let Some(min) = min
108 && version < min
109 {
110 return false;
111 }
112 if let Some(max) = max
113 && version > max
114 {
115 return false;
116 }
117 true
118}
119
120pub fn format_range(min: Option<&str>, max: Option<&str>) -> Option<String> {
142 match (min, max) {
143 (Some(min), Some(max)) => Some(format!("nginx >={}, <={}", min, max)),
144 (Some(min), None) => Some(format!("nginx >={}", min)),
145 (None, Some(max)) => Some(format!("nginx <={}", max)),
146 (None, None) => None,
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum NginxVersionParseError {
153 InvalidFormat(String),
155 InvalidComponent(String),
157}
158
159impl fmt::Display for NginxVersionParseError {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 match self {
162 Self::InvalidFormat(s) => write!(
163 f,
164 "invalid nginx version '{}': expected major.minor.patch format",
165 s
166 ),
167 Self::InvalidComponent(s) => write!(
168 f,
169 "invalid nginx version '{}': components must be non-negative integers",
170 s
171 ),
172 }
173 }
174}
175
176impl std::error::Error for NginxVersionParseError {}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn parse_valid_versions() {
184 assert_eq!(
185 NginxVersion::parse("1.30.0").unwrap(),
186 NginxVersion::new(1, 30, 0)
187 );
188 assert_eq!(
189 NginxVersion::parse("0.0.1").unwrap(),
190 NginxVersion::new(0, 0, 1)
191 );
192 assert_eq!(
193 NginxVersion::parse("10.20.30").unwrap(),
194 NginxVersion::new(10, 20, 30)
195 );
196 }
197
198 #[test]
199 fn parse_rejects_two_components() {
200 assert!(matches!(
201 NginxVersion::parse("1.30"),
202 Err(NginxVersionParseError::InvalidFormat(_))
203 ));
204 }
205
206 #[test]
207 fn parse_rejects_four_components() {
208 assert!(matches!(
209 NginxVersion::parse("1.30.0.1"),
210 Err(NginxVersionParseError::InvalidFormat(_))
211 ));
212 }
213
214 #[test]
215 fn parse_rejects_v_prefix() {
216 assert!(matches!(
217 NginxVersion::parse("v1.30.0"),
218 Err(NginxVersionParseError::InvalidComponent(_))
219 ));
220 }
221
222 #[test]
223 fn parse_rejects_non_numeric_component() {
224 assert!(matches!(
225 NginxVersion::parse("1.30.x"),
226 Err(NginxVersionParseError::InvalidComponent(_))
227 ));
228 assert!(matches!(
229 NginxVersion::parse("1.a.0"),
230 Err(NginxVersionParseError::InvalidComponent(_))
231 ));
232 }
233
234 #[test]
235 fn parse_rejects_empty() {
236 assert!(NginxVersion::parse("").is_err());
237 }
238
239 #[test]
240 fn ordering() {
241 let v_1_30_0 = NginxVersion::new(1, 30, 0);
242 let v_1_30_1 = NginxVersion::new(1, 30, 1);
243 let v_1_31_0 = NginxVersion::new(1, 31, 0);
244 let v_2_0_0 = NginxVersion::new(2, 0, 0);
245 assert!(v_1_30_0 < v_1_30_1);
246 assert!(v_1_30_1 < v_1_31_0);
247 assert!(v_1_31_0 < v_2_0_0);
248 assert_eq!(v_1_30_0, NginxVersion::new(1, 30, 0));
249 }
250
251 #[test]
252 fn display() {
253 assert_eq!(NginxVersion::new(1, 30, 1).to_string(), "1.30.1");
254 }
255
256 #[test]
257 fn range_unbounded() {
258 let v = NginxVersion::new(1, 30, 0);
259 assert!(is_in_range(&v, None, None));
260 }
261
262 #[test]
263 fn range_min_only() {
264 let min = NginxVersion::new(1, 0, 0);
265 assert!(is_in_range(&NginxVersion::new(1, 0, 0), Some(&min), None));
266 assert!(is_in_range(&NginxVersion::new(2, 0, 0), Some(&min), None));
267 assert!(!is_in_range(&NginxVersion::new(0, 9, 0), Some(&min), None));
268 }
269
270 #[test]
271 fn range_max_only() {
272 let max = NginxVersion::new(1, 30, 0);
273 assert!(is_in_range(&NginxVersion::new(1, 30, 0), None, Some(&max)));
274 assert!(is_in_range(&NginxVersion::new(1, 0, 0), None, Some(&max)));
275 assert!(!is_in_range(&NginxVersion::new(1, 30, 1), None, Some(&max)));
276 assert!(!is_in_range(&NginxVersion::new(1, 31, 0), None, Some(&max)));
277 }
278
279 #[test]
280 fn range_both_bounds_inclusive() {
281 let min = NginxVersion::new(0, 6, 27);
282 let max = NginxVersion::new(1, 30, 0);
283 assert!(is_in_range(
284 &NginxVersion::new(0, 6, 27),
285 Some(&min),
286 Some(&max)
287 ));
288 assert!(is_in_range(
289 &NginxVersion::new(1, 30, 0),
290 Some(&min),
291 Some(&max)
292 ));
293 assert!(is_in_range(
294 &NginxVersion::new(1, 0, 0),
295 Some(&min),
296 Some(&max)
297 ));
298 assert!(!is_in_range(
299 &NginxVersion::new(0, 6, 26),
300 Some(&min),
301 Some(&max)
302 ));
303 assert!(!is_in_range(
304 &NginxVersion::new(1, 30, 1),
305 Some(&min),
306 Some(&max)
307 ));
308 }
309
310 #[test]
311 fn from_str_works() {
312 let v: NginxVersion = "1.30.1".parse().unwrap();
313 assert_eq!(v, NginxVersion::new(1, 30, 1));
314 }
315
316 #[test]
317 #[should_panic(expected = "reversed bounds")]
318 fn range_panics_on_reversed_bounds_in_debug() {
319 let min = NginxVersion::new(2, 0, 0);
323 let max = NginxVersion::new(1, 0, 0);
324 let v = NginxVersion::new(1, 5, 0);
325 is_in_range(&v, Some(&min), Some(&max));
326 }
327
328 #[test]
329 fn range_equal_bounds_is_allowed() {
330 let exact = NginxVersion::new(1, 30, 0);
332 assert!(is_in_range(
333 &NginxVersion::new(1, 30, 0),
334 Some(&exact),
335 Some(&exact)
336 ));
337 assert!(!is_in_range(
338 &NginxVersion::new(1, 30, 1),
339 Some(&exact),
340 Some(&exact)
341 ));
342 }
343
344 #[test]
345 fn format_range_both_bounds() {
346 assert_eq!(
347 format_range(Some("0.6.27"), Some("1.30.0")),
348 Some("nginx >=0.6.27, <=1.30.0".to_string())
349 );
350 }
351
352 #[test]
353 fn format_range_min_only() {
354 assert_eq!(
355 format_range(Some("1.0.0"), None),
356 Some("nginx >=1.0.0".to_string())
357 );
358 }
359
360 #[test]
361 fn format_range_max_only() {
362 assert_eq!(
363 format_range(None, Some("1.29.6")),
364 Some("nginx <=1.29.6".to_string())
365 );
366 }
367
368 #[test]
369 fn format_range_none() {
370 assert_eq!(format_range(None, None), None);
373 }
374}