1use std::cmp::Ordering;
22use std::fmt;
23use std::hash::{Hash, Hasher};
24
25const LONGEST: usize = 16;
27
28const MOST_NUMBERS: usize = 4;
30
31#[derive(Clone, Copy)]
51pub struct EngineVersion {
52 written: [u8; LONGEST],
53 length: usize,
54 numbers: [u32; MOST_NUMBERS],
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum EngineVersionError {
61 Empty,
63 Colon {
65 accepted: Option<String>,
68 },
69 DotBeforeLetters {
71 accepted: String,
73 },
74 NotAVersion,
76}
77
78impl fmt::Display for EngineVersionError {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 match self {
81 Self::Empty => f.write_str("there is no version after the `@`"),
82 Self::Colon { accepted: Some(accepted) } => write!(
83 f,
84 "a version never holds a colon, which is how a host and its port are written; \
85 write `{accepted}`"
86 ),
87 Self::Colon { accepted: None } => f.write_str(
88 "a version never holds a colon, which is how a host and its port are written",
89 ),
90 Self::DotBeforeLetters { accepted } => write!(
91 f,
92 "a version has no dot before its letters, which is how a mail address is \
93 written; write `{accepted}`"
94 ),
95 Self::NotAVersion => f.write_str(
96 "a version is digits, with dots where the vendor uses them and letters only after \
97 the last digit, like `17`, `8.4` or `23ai`",
98 ),
99 }
100 }
101}
102
103impl std::error::Error for EngineVersionError {}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107enum Fault {
108 Empty,
109 Colon,
110 DotBeforeLetters,
111 NotAVersion,
112}
113
114impl EngineVersion {
115 #[must_use]
133 pub const fn declared(spelling: &str) -> Self {
134 match Self::read(spelling) {
135 Ok(version) => version,
136 Err(_) => panic!("a declared version is digits, dots and trailing letters"),
137 }
138 }
139
140 pub fn parse(spelling: &str) -> Result<Self, EngineVersionError> {
147 match Self::read(spelling) {
148 Ok(version) => Ok(version),
149 Err(Fault::Empty) => Err(EngineVersionError::Empty),
150 Err(Fault::Colon) => Err(EngineVersionError::Colon { accepted: None }),
151 Err(Fault::NotAVersion) => Err(EngineVersionError::NotAVersion),
152 Err(Fault::DotBeforeLetters) => {
153 let accepted = without_dots_before_letters(spelling);
154 if Self::read(&accepted).is_ok() {
155 Err(EngineVersionError::DotBeforeLetters { accepted })
156 } else {
157 Err(EngineVersionError::NotAVersion)
158 }
159 }
160 }
161 }
162
163 const fn read(spelling: &str) -> Result<Self, Fault> {
167 let bytes = spelling.as_bytes();
168 if bytes.is_empty() {
169 return Err(Fault::Empty);
170 }
171 let mut at = 0;
172 while at < bytes.len() {
173 if bytes[at] == b':' {
174 return Err(Fault::Colon);
175 }
176 at += 1;
177 }
178 if bytes.len() > LONGEST {
179 return Err(Fault::NotAVersion);
180 }
181 let mut written = [0u8; LONGEST];
182 let mut numbers = [0u32; MOST_NUMBERS];
183 let mut part = 0;
184 let mut digits = 0;
185 let mut lettered = false;
186 let mut at = 0;
187 while at < bytes.len() {
188 let byte = bytes[at];
189 written[at] = byte;
190 if byte.is_ascii_digit() {
191 if lettered || numbers[part] > (u32::MAX - 9) / 10 {
192 return Err(Fault::NotAVersion);
193 }
194 numbers[part] = numbers[part] * 10 + (byte - b'0') as u32;
195 digits += 1;
196 } else if byte == b'.' {
197 if digits == 0 || lettered {
198 return Err(Fault::NotAVersion);
199 }
200 if at + 1 < bytes.len() && bytes[at + 1].is_ascii_alphabetic() {
201 return Err(Fault::DotBeforeLetters);
202 }
203 part += 1;
204 digits = 0;
205 if part == MOST_NUMBERS {
206 return Err(Fault::NotAVersion);
207 }
208 } else if byte.is_ascii_alphabetic() {
209 if digits == 0 {
210 return Err(Fault::NotAVersion);
211 }
212 lettered = true;
213 } else {
214 return Err(Fault::NotAVersion);
215 }
216 at += 1;
217 }
218 if digits == 0 {
219 return Err(Fault::NotAVersion);
220 }
221 Ok(Self { written, length: bytes.len(), numbers })
222 }
223
224 #[must_use]
226 pub fn as_str(&self) -> &str {
227 std::str::from_utf8(&self.written[..self.length]).unwrap_or_default()
229 }
230
231 #[must_use]
234 pub const fn numbers(&self) -> [u32; MOST_NUMBERS] {
235 self.numbers
236 }
237}
238
239impl std::str::FromStr for EngineVersion {
240 type Err = EngineVersionError;
241
242 fn from_str(spelling: &str) -> Result<Self, Self::Err> {
243 Self::parse(spelling)
244 }
245}
246
247impl PartialEq for EngineVersion {
248 fn eq(&self, other: &Self) -> bool {
249 self.numbers == other.numbers
250 }
251}
252
253impl Eq for EngineVersion {}
254
255impl PartialOrd for EngineVersion {
256 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
257 Some(self.cmp(other))
258 }
259}
260
261impl Ord for EngineVersion {
262 fn cmp(&self, other: &Self) -> Ordering {
263 self.numbers.cmp(&other.numbers)
264 }
265}
266
267impl Hash for EngineVersion {
268 fn hash<H: Hasher>(&self, state: &mut H) {
269 self.numbers.hash(state);
270 }
271}
272
273impl fmt::Display for EngineVersion {
274 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275 f.write_str(self.as_str())
276 }
277}
278
279impl fmt::Debug for EngineVersion {
280 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281 write!(f, "EngineVersion({})", self.as_str())
282 }
283}
284
285impl serde::Serialize for EngineVersion {
286 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
287 serializer.serialize_str(self.as_str())
288 }
289}
290
291pub fn split_platform(platform: &str) -> Result<(&str, Option<EngineVersion>), EngineVersionError> {
306 let platform = platform.trim();
307 if platform.contains(':') {
308 let accepted = match platform.split_once(':') {
311 Some((engine, version))
312 if !engine.contains('@') && EngineVersion::read(version).is_ok() =>
313 {
314 Some(format!("{engine}@{version}"))
315 }
316 _ => None,
317 };
318 return Err(EngineVersionError::Colon { accepted });
319 }
320 let Some((engine, version)) = platform.rsplit_once('@') else {
321 return Ok((platform, None));
322 };
323 match EngineVersion::parse(version) {
324 Ok(version) => Ok((engine, Some(version))),
325 Err(EngineVersionError::DotBeforeLetters { accepted }) => {
326 Err(EngineVersionError::DotBeforeLetters { accepted: format!("{engine}@{accepted}") })
327 }
328 Err(other) => Err(other),
329 }
330}
331
332fn without_dots_before_letters(spelling: &str) -> String {
334 let mut out = String::with_capacity(spelling.len());
335 let mut chars = spelling.chars().peekable();
336 while let Some(c) = chars.next() {
337 if c == '.' && chars.peek().is_some_and(char::is_ascii_alphabetic) {
338 continue;
339 }
340 out.push(c);
341 }
342 out
343}
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn read(spelling: &str) -> EngineVersion {
349 EngineVersion::parse(spelling).unwrap_or_else(|e| panic!("{spelling}: {e}"))
350 }
351
352 #[test]
353 fn the_four_shapes_a_vendor_writes_are_versions_and_display_as_written() {
354 for spelling in ["17", "8.4", "2022", "23ai", "2.00.088"] {
355 assert_eq!(read(spelling).to_string(), spelling);
356 }
357 }
358
359 #[test]
360 fn versions_are_ordered_by_their_numbers() {
361 assert!(read("15") < read("16"));
362 assert!(read("16") < read("17"));
363 assert!(read("8") < read("8.4"));
364 assert!(read("8.4") < read("8.10"));
365 assert!(read("19") < read("23ai"));
366 assert_eq!(read("8.4"), read("8.4.0"));
367 }
368
369 #[test]
370 fn the_letters_are_how_the_vendor_writes_it_and_not_its_order() {
371 assert_eq!(read("23ai"), read("23"));
372 assert_eq!(read("23ai").to_string(), "23ai");
373 }
374
375 #[test]
376 fn a_declared_version_reads_the_same_as_a_written_one() {
377 const FLOOR: EngineVersion = EngineVersion::declared("15");
378 assert_eq!(FLOOR, read("15"));
379 assert_eq!(FLOOR.to_string(), "15");
380 }
381
382 #[test]
383 fn a_dot_before_letters_is_refused_showing_the_accepted_spelling() {
384 assert_eq!(
385 EngineVersion::parse("23.ai"),
386 Err(EngineVersionError::DotBeforeLetters { accepted: "23ai".to_owned() })
387 );
388 let said = EngineVersion::parse("23.ai").unwrap_err().to_string();
389 assert!(said.contains("`23ai`"), "{said}");
390 assert!(said.contains("mail address"), "{said}");
391 }
392
393 #[test]
394 fn a_colon_is_refused() {
395 assert!(matches!(EngineVersion::parse("17:5432"), Err(EngineVersionError::Colon { .. })));
396 }
397
398 #[test]
399 fn what_is_not_a_version_is_refused() {
400 for spelling in ["latest", "v17", "17-beta", "17 ", ".17", "17.", "1..2", "1.2.3.4.5"] {
401 assert_eq!(
402 EngineVersion::parse(spelling),
403 Err(EngineVersionError::NotAVersion),
404 "{spelling}"
405 );
406 }
407 assert_eq!(EngineVersion::parse(""), Err(EngineVersionError::Empty));
408 assert_eq!(
409 EngineVersion::parse("12345678901234567"),
410 Err(EngineVersionError::NotAVersion),
411 "longer than any vendor writes one"
412 );
413 }
414
415 #[test]
416 fn a_platform_name_splits_on_its_last_at() {
417 assert_eq!(split_platform("postgres"), Ok(("postgres", None)));
418 assert_eq!(split_platform("postgres@17"), Ok(("postgres", Some(read("17")))));
419 assert_eq!(split_platform(" oracle@23ai "), Ok(("oracle", Some(read("23ai")))));
420 assert_eq!(split_platform("my@db@8.4"), Ok(("my@db", Some(read("8.4")))));
421 }
422
423 #[test]
424 fn a_platform_name_with_a_colon_is_refused_showing_the_at() {
425 assert_eq!(
426 split_platform("postgres:17"),
427 Err(EngineVersionError::Colon { accepted: Some("postgres@17".to_owned()) })
428 );
429 assert!(matches!(split_platform("postgres@17:1"), Err(EngineVersionError::Colon { .. })));
430 }
431
432 #[test]
433 fn a_platform_name_with_a_dot_before_letters_is_refused_showing_the_accepted_name() {
434 assert_eq!(
435 split_platform("oracle@23.ai"),
436 Err(EngineVersionError::DotBeforeLetters { accepted: "oracle@23ai".to_owned() })
437 );
438 }
439
440 #[test]
441 fn a_version_serializes_as_it_was_written() {
442 let json = serde_json::to_string(&[read("15"), read("23ai")]).unwrap();
443 assert_eq!(json, r#"["15","23ai"]"#);
444 }
445}