Skip to main content

use_git_tag/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned while parsing tag names.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum GitTagNameError {
10    /// The supplied tag name was empty.
11    Empty,
12    /// The supplied tag name used syntax this crate rejects.
13    InvalidName,
14    /// The supplied tag name was not version-like.
15    NotVersionLike,
16    /// The supplied tag kind label was not recognized.
17    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/// A validated tag name.
68#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
69pub struct GitTagName(String);
70
71impl GitTagName {
72    /// Creates a tag name from text.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`GitTagNameError`] when the tag name is empty or invalid.
77    pub fn new(value: impl AsRef<str>) -> Result<Self, GitTagNameError> {
78        validate_tag_name(value).map(Self)
79    }
80
81    /// Returns the tag text.
82    #[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/// Tag storage vocabulary.
117#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
118pub enum GitTagKind {
119    /// A lightweight tag label.
120    Lightweight,
121    /// An annotated tag label.
122    Annotated,
123}
124
125impl GitTagKind {
126    /// Returns the stable kind label.
127    #[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/// A version-like tag name, such as `v1.2.3`.
156#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
157pub struct VersionTagName(GitTagName);
158
159impl VersionTagName {
160    /// Creates a version-like tag name.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`GitTagNameError`] when the tag is invalid or not version-like.
165    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    /// Returns the tag text.
175    #[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}