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 GitTagNameError {
10 Empty,
12 InvalidName,
14 NotVersionLike,
16 UnknownKind,
18}
19
20impl fmt::Display for GitTagNameError {
21 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
22 match self {
23 Self::Empty => formatter.write_str("Git tag name cannot be empty"),
24 Self::InvalidName => formatter.write_str("invalid Git tag name"),
25 Self::NotVersionLike => formatter.write_str("Git tag name is not version-like"),
26 Self::UnknownKind => formatter.write_str("unknown Git tag kind"),
27 }
28 }
29}
30
31impl Error for GitTagNameError {}
32
33fn has_lock_suffix(value: &str) -> bool {
34 value
35 .get(value.len().saturating_sub(5)..)
36 .is_some_and(|suffix| suffix.eq_ignore_ascii_case(".lock"))
37}
38
39fn validate_tag_name(value: impl AsRef<str>) -> Result<String, GitTagNameError> {
40 let trimmed = value.as_ref().trim();
41
42 if trimmed.is_empty() {
43 return Err(GitTagNameError::Empty);
44 }
45
46 let invalid = trimmed.starts_with('/')
47 || trimmed.ends_with('/')
48 || trimmed.starts_with('.')
49 || trimmed.ends_with('.')
50 || has_lock_suffix(trimmed)
51 || trimmed.contains("//")
52 || trimmed.contains("..")
53 || trimmed.contains("@{")
54 || trimmed.chars().any(|character| {
55 character.is_ascii_control()
56 || character.is_ascii_whitespace()
57 || matches!(character, '~' | '^' | ':' | '?' | '*' | '[' | '\\')
58 });
59
60 if invalid {
61 Err(GitTagNameError::InvalidName)
62 } else {
63 Ok(trimmed.to_string())
64 }
65}
66
67#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
69pub struct GitTagName(String);
70
71impl GitTagName {
72 pub fn new(value: impl AsRef<str>) -> Result<Self, GitTagNameError> {
78 validate_tag_name(value).map(Self)
79 }
80
81 #[must_use]
83 pub fn as_str(&self) -> &str {
84 &self.0
85 }
86}
87
88impl AsRef<str> for GitTagName {
89 fn as_ref(&self) -> &str {
90 self.as_str()
91 }
92}
93
94impl fmt::Display for GitTagName {
95 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96 formatter.write_str(self.as_str())
97 }
98}
99
100impl FromStr for GitTagName {
101 type Err = GitTagNameError;
102
103 fn from_str(value: &str) -> Result<Self, Self::Err> {
104 Self::new(value)
105 }
106}
107
108impl TryFrom<&str> for GitTagName {
109 type Error = GitTagNameError;
110
111 fn try_from(value: &str) -> Result<Self, Self::Error> {
112 Self::new(value)
113 }
114}
115
116#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
118pub enum GitTagKind {
119 Lightweight,
121 Annotated,
123}
124
125impl GitTagKind {
126 #[must_use]
128 pub const fn as_str(self) -> &'static str {
129 match self {
130 Self::Lightweight => "lightweight",
131 Self::Annotated => "annotated",
132 }
133 }
134}
135
136impl fmt::Display for GitTagKind {
137 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
138 formatter.write_str(self.as_str())
139 }
140}
141
142impl FromStr for GitTagKind {
143 type Err = GitTagNameError;
144
145 fn from_str(value: &str) -> Result<Self, Self::Err> {
146 match value.trim().to_ascii_lowercase().as_str() {
147 "lightweight" | "light" => Ok(Self::Lightweight),
148 "annotated" | "annotation" => Ok(Self::Annotated),
149 "" => Err(GitTagNameError::Empty),
150 _ => Err(GitTagNameError::UnknownKind),
151 }
152 }
153}
154
155#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
157pub struct VersionTagName(GitTagName);
158
159impl VersionTagName {
160 pub fn new(value: impl AsRef<str>) -> Result<Self, GitTagNameError> {
166 let tag = GitTagName::new(value)?;
167 if is_version_like(tag.as_str()) {
168 Ok(Self(tag))
169 } else {
170 Err(GitTagNameError::NotVersionLike)
171 }
172 }
173
174 #[must_use]
176 pub fn as_str(&self) -> &str {
177 self.0.as_str()
178 }
179}
180
181impl fmt::Display for VersionTagName {
182 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183 formatter.write_str(self.as_str())
184 }
185}
186
187impl FromStr for VersionTagName {
188 type Err = GitTagNameError;
189
190 fn from_str(value: &str) -> Result<Self, Self::Err> {
191 Self::new(value)
192 }
193}
194
195fn is_version_like(value: &str) -> bool {
196 let trimmed = value.strip_prefix('v').unwrap_or(value);
197 let mut parts = trimmed.split('.');
198 let Some(major) = parts.next() else {
199 return false;
200 };
201 let Some(minor) = parts.next() else {
202 return false;
203 };
204
205 !major.is_empty()
206 && !minor.is_empty()
207 && major.chars().all(|character| character.is_ascii_digit())
208 && minor.chars().all(|character| character.is_ascii_digit())
209 && parts.all(|part| {
210 !part.is_empty() && part.chars().all(|character| character.is_ascii_digit())
211 })
212}
213
214#[cfg(test)]
215mod tests {
216 use super::{GitTagKind, GitTagName, GitTagNameError, VersionTagName};
217
218 #[test]
219 fn parses_tag_names() -> Result<(), GitTagNameError> {
220 let tag = GitTagName::new("v1.2.3")?;
221 let version = VersionTagName::new("v1.2.3")?;
222
223 assert_eq!(tag.as_str(), "v1.2.3");
224 assert_eq!(version.as_str(), "v1.2.3");
225 assert_eq!(GitTagKind::Annotated.to_string(), "annotated");
226 Ok(())
227 }
228
229 #[test]
230 fn rejects_invalid_tags() {
231 assert_eq!(GitTagName::new(""), Err(GitTagNameError::Empty));
232 assert_eq!(
233 GitTagName::new("bad tag"),
234 Err(GitTagNameError::InvalidName)
235 );
236 assert_eq!(
237 VersionTagName::new("release"),
238 Err(GitTagNameError::NotVersionLike)
239 );
240 }
241}