Skip to main content

razeri_plugin_contract/
engine_version.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! **A version of an engine**, and the platform name that carries one.
6//!
7//! A platform is an engine at a version: `postgres`, or `postgres@17`. The
8//! engine's name comes first, and after the last `@` the version the design is
9//! for. A version is digits, with dots where the vendor uses them (`8.4`), and
10//! letters only after the last digit, where the vendor's own name has them
11//! (`23ai`).
12//!
13//! Two spellings are refused before anything else reads them, because the
14//! scans that keep private names out of a release would refuse them too:
15//!
16//! - a dot before letters, `oracle@23.ai`, is the shape of a mail address, and
17//!   the accepted spelling is shown: `oracle@23ai`;
18//! - a colon, `postgres:17`, is the shape of a host and its port, and the
19//!   accepted spelling is shown: `postgres@17`.
20
21use std::cmp::Ordering;
22use std::fmt;
23use std::hash::{Hash, Hasher};
24
25/// The most characters a version is written in.
26const LONGEST: usize = 16;
27
28/// The most numbers a version holds: `2.00.088` is three.
29const MOST_NUMBERS: usize = 4;
30
31/// **A version of an engine**: `17`, `8.4`, `2022`, `23ai`.
32///
33/// Ordered by its numbers, the missing ones read as zero, so `8.4` is newer
34/// than `8` and the same as `8.4.0`. The letters after the last digit are
35/// part of how the vendor writes it and not of its order: `23ai` and `23` are
36/// one version, and a dialect that declares `23ai` answers to `oracle@23` too.
37///
38/// It is displayed as it was written, so a refusal and a manifest say the
39/// version in the user's words.
40///
41/// ```
42/// use razeri_plugin_contract::EngineVersion;
43///
44/// let floor = EngineVersion::declared("15");
45/// let asked: EngineVersion = "17".parse().unwrap();
46/// assert!(floor < asked);
47/// assert_eq!(asked.to_string(), "17");
48/// assert_eq!("23ai".parse::<EngineVersion>().unwrap(), "23".parse().unwrap());
49/// ```
50#[derive(Clone, Copy)]
51pub struct EngineVersion {
52    written: [u8; LONGEST],
53    length: usize,
54    numbers: [u32; MOST_NUMBERS],
55}
56
57/// **Why a spelling is not a version**, with the spelling that is, where there
58/// is one.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum EngineVersionError {
61    /// Nothing after the `@`.
62    Empty,
63    /// A colon: the shape of a host and its port.
64    Colon {
65        /// The version with the colon taken out, or `None` when nothing is
66        /// left.
67        accepted: Option<String>,
68    },
69    /// Letters after a dot: the shape of a mail address.
70    DotBeforeLetters {
71        /// The same version with the dot before the letters taken out.
72        accepted: String,
73    },
74    /// Anything else that is not digits, dots and trailing letters.
75    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/// What [`EngineVersion::read`] found wrong, before the sentence is written.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107enum Fault {
108    Empty,
109    Colon,
110    DotBeforeLetters,
111    NotAVersion,
112}
113
114impl EngineVersion {
115    /// **A version a dialect declares**, read when the program is compiled.
116    ///
117    /// A spelling that is not a version stops the build, so a dialect's
118    /// declared list can be a `const`:
119    ///
120    /// ```
121    /// use razeri_plugin_contract::EngineVersion;
122    ///
123    /// const VERSIONS: &[EngineVersion] =
124    ///     &[EngineVersion::declared("15"), EngineVersion::declared("17")];
125    /// assert_eq!(VERSIONS[0].to_string(), "15");
126    /// ```
127    ///
128    /// # Panics
129    ///
130    /// When `spelling` is not a version; in a `const`, that is a compile
131    /// error.
132    #[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    /// Read a version written by a user.
141    ///
142    /// # Errors
143    ///
144    /// [`EngineVersionError`] naming what is wrong and, where there is one,
145    /// the spelling that is accepted.
146    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    /// The reading both [`Self::declared`] and [`Self::parse`] share. It
164    /// runs when a dialect is compiled, so what it says is wrong is a word,
165    /// and [`Self::parse`] writes the sentence.
166    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    /// The version as it was written.
225    #[must_use]
226    pub fn as_str(&self) -> &str {
227        // Only ASCII digits, dots and letters are ever written here.
228        std::str::from_utf8(&self.written[..self.length]).unwrap_or_default()
229    }
230
231    /// The numbers of the version, the missing ones zero: `8.4` is
232    /// `[8, 4, 0, 0]`.
233    #[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
291/// **A platform name, read into the engine and the version it is for.**
292///
293/// `postgres@17` is the engine `postgres` at version `17`; `postgres` is the
294/// engine with no version named. The name is split on its last `@`, so the
295/// engine's own name is never cut short.
296///
297/// Parameters:
298/// - `platform`: the name as a design or a `--target` writes it.
299///
300/// # Errors
301///
302/// [`EngineVersionError`] when the version is not one, and
303/// [`EngineVersionError::Colon`] for a colon anywhere in the name, with the
304/// spelling that is accepted: `postgres:17` reads as `postgres@17`.
305pub fn split_platform(platform: &str) -> Result<(&str, Option<EngineVersion>), EngineVersionError> {
306    let platform = platform.trim();
307    if platform.contains(':') {
308        // `postgres:17` is the `@` written as a host's port; the fix is the
309        // `@`, where that reads as a version.
310        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
332/// `23.ai` written `23ai`: every dot that comes before a letter taken out.
333fn 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}