1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum OciTagError {
10 Empty,
11 TooLong,
12 InvalidStart,
13 InvalidCharacter,
14 NotVersionLike,
15 NotArchitectureLike,
16}
17
18impl fmt::Display for OciTagError {
19 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20 match self {
21 Self::Empty => formatter.write_str("OCI tag cannot be empty"),
22 Self::TooLong => formatter.write_str("OCI tag cannot exceed 128 characters"),
23 Self::InvalidStart => {
24 formatter.write_str("OCI tag must start with an ASCII word character")
25 },
26 Self::InvalidCharacter => formatter.write_str("OCI tag contains invalid characters"),
27 Self::NotVersionLike => formatter.write_str("OCI tag is not version-like"),
28 Self::NotArchitectureLike => formatter.write_str("OCI tag is not architecture-like"),
29 }
30 }
31}
32
33impl Error for OciTagError {}
34
35#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
37pub struct OciTag(String);
38
39impl OciTag {
40 pub fn new(value: impl AsRef<str>) -> Result<Self, OciTagError> {
42 let trimmed = value.as_ref().trim();
43 validate_tag(trimmed)?;
44 Ok(Self(trimmed.to_string()))
45 }
46
47 #[must_use]
49 pub fn latest() -> Self {
50 Self("latest".to_string())
51 }
52
53 #[must_use]
55 pub fn as_str(&self) -> &str {
56 &self.0
57 }
58
59 #[must_use]
61 pub fn is_latest(&self) -> bool {
62 self.as_str() == "latest"
63 }
64
65 #[must_use]
67 pub fn is_version_like(&self) -> bool {
68 is_version_like(self.as_str())
69 }
70
71 #[must_use]
73 pub fn is_architecture_like(&self) -> bool {
74 architecture_token(self.as_str()).is_some()
75 }
76}
77
78impl AsRef<str> for OciTag {
79 fn as_ref(&self) -> &str {
80 self.as_str()
81 }
82}
83
84impl fmt::Display for OciTag {
85 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86 formatter.write_str(self.as_str())
87 }
88}
89
90impl FromStr for OciTag {
91 type Err = OciTagError;
92
93 fn from_str(value: &str) -> Result<Self, Self::Err> {
94 Self::new(value)
95 }
96}
97
98impl TryFrom<&str> for OciTag {
99 type Error = OciTagError;
100
101 fn try_from(value: &str) -> Result<Self, Self::Error> {
102 Self::new(value)
103 }
104}
105
106#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
108pub struct VersionTag(OciTag);
109
110impl VersionTag {
111 pub fn new(value: impl AsRef<str>) -> Result<Self, OciTagError> {
113 let tag = OciTag::new(value)?;
114 if tag.is_version_like() {
115 Ok(Self(tag))
116 } else {
117 Err(OciTagError::NotVersionLike)
118 }
119 }
120
121 #[must_use]
123 pub fn as_str(&self) -> &str {
124 self.0.as_str()
125 }
126}
127
128impl fmt::Display for VersionTag {
129 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130 formatter.write_str(self.as_str())
131 }
132}
133
134#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
136pub struct ArchitectureTag(OciTag);
137
138impl ArchitectureTag {
139 pub fn new(value: impl AsRef<str>) -> Result<Self, OciTagError> {
141 let tag = OciTag::new(value)?;
142 if tag.is_architecture_like() {
143 Ok(Self(tag))
144 } else {
145 Err(OciTagError::NotArchitectureLike)
146 }
147 }
148
149 #[must_use]
151 pub fn as_str(&self) -> &str {
152 self.0.as_str()
153 }
154}
155
156impl fmt::Display for ArchitectureTag {
157 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
158 formatter.write_str(self.as_str())
159 }
160}
161
162#[must_use]
164pub fn is_valid_oci_tag(value: impl AsRef<str>) -> bool {
165 validate_tag(value.as_ref().trim()).is_ok()
166}
167
168fn validate_tag(value: &str) -> Result<(), OciTagError> {
169 if value.is_empty() {
170 return Err(OciTagError::Empty);
171 }
172 if value.len() > 128 {
173 return Err(OciTagError::TooLong);
174 }
175 let mut chars = value.chars();
176 let Some(first) = chars.next() else {
177 return Err(OciTagError::Empty);
178 };
179 if !(first.is_ascii_alphanumeric() || first == '_') {
180 return Err(OciTagError::InvalidStart);
181 }
182 if chars.any(|character| {
183 !(character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-'))
184 }) {
185 return Err(OciTagError::InvalidCharacter);
186 }
187 Ok(())
188}
189
190fn is_version_like(value: &str) -> bool {
191 let value = value.strip_prefix('v').unwrap_or(value);
192 let core = value.split_once('-').map_or(value, |(core, _)| core);
193 let mut parts = core.split('.');
194 matches!(
195 (parts.next(), parts.next(), parts.next(), parts.next()),
196 (Some(major), Some(minor), Some(patch), None)
197 if is_digits(major) && is_digits(minor) && is_digits(patch)
198 )
199}
200
201fn architecture_token(value: &str) -> Option<&str> {
202 value.split(['-', '_', '.']).find(|part| {
203 matches!(
204 *part,
205 "amd64" | "arm64" | "arm" | "386" | "ppc64le" | "riscv64" | "s390x" | "wasm"
206 )
207 })
208}
209
210fn is_digits(value: &str) -> bool {
211 !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
212}
213
214#[cfg(test)]
215mod tests {
216 use super::{ArchitectureTag, OciTag, OciTagError, VersionTag, is_valid_oci_tag};
217
218 #[test]
219 fn validates_and_classifies_tags() -> Result<(), Box<dyn std::error::Error>> {
220 let tag: OciTag = "v1.2.3-arm64".parse()?;
221
222 assert!(tag.is_version_like());
223 assert!(tag.is_architecture_like());
224 assert!(OciTag::latest().is_latest());
225 assert!(is_valid_oci_tag("_dev"));
226 assert_eq!(OciTag::new("-bad"), Err(OciTagError::InvalidStart));
227 assert_eq!(VersionTag::new("release"), Err(OciTagError::NotVersionLike));
228 assert_eq!(
229 ArchitectureTag::new("v1.2.3"),
230 Err(OciTagError::NotArchitectureLike)
231 );
232 Ok(())
233 }
234}