use-git-tag 0.0.1

Primitive Git tag name vocabulary for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

/// Error returned while parsing tag names.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitTagNameError {
    /// The supplied tag name was empty.
    Empty,
    /// The supplied tag name used syntax this crate rejects.
    InvalidName,
    /// The supplied tag name was not version-like.
    NotVersionLike,
    /// The supplied tag kind label was not recognized.
    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())
    }
}

/// A validated tag name.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitTagName(String);

impl GitTagName {
    /// Creates a tag name from text.
    ///
    /// # Errors
    ///
    /// Returns [`GitTagNameError`] when the tag name is empty or invalid.
    pub fn new(value: impl AsRef<str>) -> Result<Self, GitTagNameError> {
        validate_tag_name(value).map(Self)
    }

    /// Returns the tag text.
    #[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)
    }
}

/// Tag storage vocabulary.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitTagKind {
    /// A lightweight tag label.
    Lightweight,
    /// An annotated tag label.
    Annotated,
}

impl GitTagKind {
    /// Returns the stable kind label.
    #[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),
        }
    }
}

/// A version-like tag name, such as `v1.2.3`.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct VersionTagName(GitTagName);

impl VersionTagName {
    /// Creates a version-like tag name.
    ///
    /// # Errors
    ///
    /// Returns [`GitTagNameError`] when the tag is invalid or not version-like.
    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)
        }
    }

    /// Returns the tag text.
    #[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)
        );
    }
}