Skip to main content

atlas_local/models/
image_tag.rs

1use std::fmt::{Display, Formatter};
2
3use crate::models::MongoDBVersion;
4
5const PARSE_ERROR: &str = "Invalid image tag: expected 'preview', 'latest', semver (e.g. 8.2.4), or semver+timestamp (e.g. 8.2.4-20260217T084055Z)";
6const TIMESTAMP_ERROR: &str =
7    "Invalid timestamp: expected format YYYYMMDDTHHMMSSZ (e.g. 20260217T084055Z)";
8
9/// Timestamp suffix for semver+timestamp image tags: `YYYYMMDDTHHMMSSZ`
10#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ImageTimestamp(String);
13
14impl TryFrom<&str> for ImageTimestamp {
15    type Error = String;
16
17    fn try_from(s: &str) -> Result<Self, Self::Error> {
18        let b = s.as_bytes();
19        if b.len() != 16 {
20            return Err(TIMESTAMP_ERROR.to_string());
21        }
22        if !b[0..8].iter().all(|&c| c.is_ascii_digit())
23            || b[8] != b'T'
24            || !b[9..15].iter().all(|&c| c.is_ascii_digit())
25            || b[15] != b'Z'
26        {
27            return Err(TIMESTAMP_ERROR.to_string());
28        }
29        Ok(ImageTimestamp(s.to_string()))
30    }
31}
32
33impl Display for ImageTimestamp {
34    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
35        write!(f, "{}", self.0)
36    }
37}
38
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ImageTag {
42    Preview,
43    Latest,
44    Semver(MongoDBVersion),
45    /// Semver with timestamp suffix, e.g. `8.2.4-20260217T084055Z`
46    SemverTimestamp(MongoDBVersion, ImageTimestamp),
47}
48
49impl TryFrom<&str> for ImageTag {
50    type Error = String;
51
52    fn try_from(s: &str) -> Result<Self, Self::Error> {
53        let s = s.trim();
54        if s == "preview" {
55            return Ok(ImageTag::Preview);
56        }
57        if s == "latest" {
58            return Ok(ImageTag::Latest);
59        }
60        let Some((prefix, suffix)) = s.split_once('-') else {
61            return Ok(ImageTag::Semver(
62                MongoDBVersion::try_from(s).map_err(|_| PARSE_ERROR.to_string())?,
63            ));
64        };
65        let version = MongoDBVersion::try_from(prefix).map_err(|_| PARSE_ERROR.to_string())?;
66        let timestamp = ImageTimestamp::try_from(suffix)?;
67        Ok(ImageTag::SemverTimestamp(version, timestamp))
68    }
69}
70
71impl Display for ImageTag {
72    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
73        match self {
74            ImageTag::Preview => write!(f, "preview"),
75            ImageTag::Latest => write!(f, "latest"),
76            ImageTag::Semver(v) => write!(f, "{}", v),
77            ImageTag::SemverTimestamp(version, timestamp) => write!(f, "{}-{}", version, timestamp),
78        }
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn preview() {
88        let tag = ImageTag::try_from("preview").unwrap();
89        assert_eq!(tag, ImageTag::Preview);
90        assert_eq!(tag.to_string(), "preview");
91    }
92
93    #[test]
94    fn latest() {
95        let tag = ImageTag::try_from("latest").unwrap();
96        assert_eq!(tag, ImageTag::Latest);
97        assert_eq!(tag.to_string(), "latest");
98    }
99
100    #[test]
101    fn semver() {
102        let tag = ImageTag::try_from("8.0.0").unwrap();
103        assert!(matches!(tag, ImageTag::Semver(_)));
104        assert_eq!(tag.to_string(), "8.0.0");
105    }
106
107    #[test]
108    fn semver_timestamp() {
109        use crate::models::{MongoDBVersion, MongoDBVersionMajorMinorPatch};
110        let tag = ImageTag::try_from("8.2.4-20260217T084055Z").unwrap();
111        let expected_version = MongoDBVersion::MajorMinorPatch(MongoDBVersionMajorMinorPatch {
112            major: 8,
113            minor: 2,
114            patch: 4,
115        });
116        assert!(matches!(&tag, ImageTag::SemverTimestamp(_, _)));
117        assert_eq!(tag.to_string(), "8.2.4-20260217T084055Z");
118        if let ImageTag::SemverTimestamp(v, ts) = &tag {
119            assert_eq!(v, &expected_version);
120            assert_eq!(ts.to_string(), "20260217T084055Z");
121        }
122    }
123
124    #[test]
125    fn invalid() {
126        assert!(ImageTag::try_from("invalid").is_err());
127        assert!(ImageTag::try_from("1.2.3.4").is_err());
128    }
129
130    #[test]
131    fn semver_timestamp_invalid_timestamp_rejected() {
132        // Wrong length
133        assert!(ImageTag::try_from("8.2.4-20260217T08405").is_err()); // too short
134        assert!(ImageTag::try_from("8.2.4-20260217T0840550Z").is_err()); // too long
135        // Missing T
136        assert!(ImageTag::try_from("8.2.4-20260217084055Z").is_err());
137        // Non-digit in date or time
138        assert!(ImageTag::try_from("8.2.4-2026021XT084055Z").is_err());
139    }
140}