atlas_local/models/
image_tag.rs1use 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#[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 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 assert!(ImageTag::try_from("8.2.4-20260217T08405").is_err()); assert!(ImageTag::try_from("8.2.4-20260217T0840550Z").is_err()); assert!(ImageTag::try_from("8.2.4-20260217084055Z").is_err());
137 assert!(ImageTag::try_from("8.2.4-2026021XT084055Z").is_err());
139 }
140}