#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitTagNameError {
Empty,
InvalidName,
NotVersionLike,
UnknownKind,
}
impl fmt::Display for GitTagNameError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Git tag name cannot be empty"),
Self::InvalidName => formatter.write_str("invalid Git tag name"),
Self::NotVersionLike => formatter.write_str("Git tag name is not version-like"),
Self::UnknownKind => formatter.write_str("unknown Git tag kind"),
}
}
}
impl Error for GitTagNameError {}
fn has_lock_suffix(value: &str) -> bool {
value
.get(value.len().saturating_sub(5)..)
.is_some_and(|suffix| suffix.eq_ignore_ascii_case(".lock"))
}
fn validate_tag_name(value: impl AsRef<str>) -> Result<String, GitTagNameError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
return Err(GitTagNameError::Empty);
}
let invalid = trimmed.starts_with('/')
|| trimmed.ends_with('/')
|| trimmed.starts_with('.')
|| trimmed.ends_with('.')
|| has_lock_suffix(trimmed)
|| trimmed.contains("//")
|| trimmed.contains("..")
|| trimmed.contains("@{")
|| trimmed.chars().any(|character| {
character.is_ascii_control()
|| character.is_ascii_whitespace()
|| matches!(character, '~' | '^' | ':' | '?' | '*' | '[' | '\\')
});
if invalid {
Err(GitTagNameError::InvalidName)
} else {
Ok(trimmed.to_string())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitTagName(String);
impl GitTagName {
pub fn new(value: impl AsRef<str>) -> Result<Self, GitTagNameError> {
validate_tag_name(value).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for GitTagName {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for GitTagName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for GitTagName {
type Err = GitTagNameError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
impl TryFrom<&str> for GitTagName {
type Error = GitTagNameError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitTagKind {
Lightweight,
Annotated,
}
impl GitTagKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Lightweight => "lightweight",
Self::Annotated => "annotated",
}
}
}
impl fmt::Display for GitTagKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for GitTagKind {
type Err = GitTagNameError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"lightweight" | "light" => Ok(Self::Lightweight),
"annotated" | "annotation" => Ok(Self::Annotated),
"" => Err(GitTagNameError::Empty),
_ => Err(GitTagNameError::UnknownKind),
}
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct VersionTagName(GitTagName);
impl VersionTagName {
pub fn new(value: impl AsRef<str>) -> Result<Self, GitTagNameError> {
let tag = GitTagName::new(value)?;
if is_version_like(tag.as_str()) {
Ok(Self(tag))
} else {
Err(GitTagNameError::NotVersionLike)
}
}
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl fmt::Display for VersionTagName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for VersionTagName {
type Err = GitTagNameError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
fn is_version_like(value: &str) -> bool {
let trimmed = value.strip_prefix('v').unwrap_or(value);
let mut parts = trimmed.split('.');
let Some(major) = parts.next() else {
return false;
};
let Some(minor) = parts.next() else {
return false;
};
!major.is_empty()
&& !minor.is_empty()
&& major.chars().all(|character| character.is_ascii_digit())
&& minor.chars().all(|character| character.is_ascii_digit())
&& parts.all(|part| {
!part.is_empty() && part.chars().all(|character| character.is_ascii_digit())
})
}
#[cfg(test)]
mod tests {
use super::{GitTagKind, GitTagName, GitTagNameError, VersionTagName};
#[test]
fn parses_tag_names() -> Result<(), GitTagNameError> {
let tag = GitTagName::new("v1.2.3")?;
let version = VersionTagName::new("v1.2.3")?;
assert_eq!(tag.as_str(), "v1.2.3");
assert_eq!(version.as_str(), "v1.2.3");
assert_eq!(GitTagKind::Annotated.to_string(), "annotated");
Ok(())
}
#[test]
fn rejects_invalid_tags() {
assert_eq!(GitTagName::new(""), Err(GitTagNameError::Empty));
assert_eq!(
GitTagName::new("bad tag"),
Err(GitTagNameError::InvalidName)
);
assert_eq!(
VersionTagName::new("release"),
Err(GitTagNameError::NotVersionLike)
);
}
}