use-git-oid 0.0.1

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

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

const SHA1_HEX_LEN: usize = 40;
const SHA256_HEX_LEN: usize = 64;
const MIN_SHORT_HEX_LEN: usize = 4;

/// Supported `Git` object identifier text shapes.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitOidKind {
    /// A 40-character `SHA-1` object identifier.
    Sha1,
    /// A 64-character `SHA-256` object identifier shape.
    Sha256,
}

impl GitOidKind {
    /// Returns the canonical label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Sha1 => "sha1",
            Self::Sha256 => "sha256",
        }
    }

    /// Returns the full hexadecimal length for this identifier kind.
    #[must_use]
    pub const fn hex_len(self) -> usize {
        match self {
            Self::Sha1 => SHA1_HEX_LEN,
            Self::Sha256 => SHA256_HEX_LEN,
        }
    }
}

impl fmt::Display for GitOidKind {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for GitOidKind {
    type Err = GitOidParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "sha1" | "sha-1" => Ok(Self::Sha1),
            "sha256" | "sha-256" => Ok(Self::Sha256),
            "" => Err(GitOidParseError::Empty),
            _ => Err(GitOidParseError::UnknownKind),
        }
    }
}

/// Error returned while parsing object identifier text.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitOidParseError {
    /// The supplied identifier text was empty.
    Empty,
    /// The full identifier length was not a supported shape.
    InvalidLength(usize),
    /// The short identifier length was outside the accepted range.
    InvalidShortLength(usize),
    /// The identifier contained a non-hexadecimal character.
    NonHexCharacter { index: usize, character: char },
    /// The object identifier kind label was not recognized.
    UnknownKind,
}

impl fmt::Display for GitOidParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Git object identifier cannot be empty"),
            Self::InvalidLength(length) => write!(
                formatter,
                "Git object identifier length must be 40 or 64 hex characters, got {length}"
            ),
            Self::InvalidShortLength(length) => write!(
                formatter,
                "short Git object identifier length must be between 4 and 64 hex characters, got {length}"
            ),
            Self::NonHexCharacter { index, character } => {
                write!(
                    formatter,
                    "invalid hex character `{character}` at index {index}"
                )
            },
            Self::UnknownKind => formatter.write_str("unknown Git object identifier kind"),
        }
    }
}

impl Error for GitOidParseError {}

fn normalized_hex(value: &str) -> Result<String, GitOidParseError> {
    let trimmed = value.trim();

    if trimmed.is_empty() {
        return Err(GitOidParseError::Empty);
    }

    for (index, character) in trimmed.chars().enumerate() {
        if !character.is_ascii_hexdigit() {
            return Err(GitOidParseError::NonHexCharacter { index, character });
        }
    }

    Ok(trimmed.to_ascii_lowercase())
}

/// A full `Git` object identifier.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitOid {
    value: String,
    kind: GitOidKind,
}

impl GitOid {
    /// Creates a full object identifier from hexadecimal text.
    ///
    /// # Errors
    ///
    /// Returns [`GitOidParseError`] when the text is empty, has an unsupported
    /// length, or contains non-hexadecimal characters.
    pub fn new(value: impl AsRef<str>) -> Result<Self, GitOidParseError> {
        let value = normalized_hex(value.as_ref())?;
        let kind = match value.len() {
            SHA1_HEX_LEN => GitOidKind::Sha1,
            SHA256_HEX_LEN => GitOidKind::Sha256,
            length => return Err(GitOidParseError::InvalidLength(length)),
        };

        Ok(Self { value, kind })
    }

    /// Creates a `SHA-1` object identifier.
    ///
    /// # Errors
    ///
    /// Returns [`GitOidParseError`] when validation fails or the length is not 40.
    pub fn sha1(value: impl AsRef<str>) -> Result<Self, GitOidParseError> {
        let oid = Self::new(value)?;
        if oid.kind == GitOidKind::Sha1 {
            Ok(oid)
        } else {
            Err(GitOidParseError::InvalidLength(oid.value.len()))
        }
    }

    /// Creates a `SHA-256`-shaped object identifier.
    ///
    /// # Errors
    ///
    /// Returns [`GitOidParseError`] when validation fails or the length is not 64.
    pub fn sha256(value: impl AsRef<str>) -> Result<Self, GitOidParseError> {
        let oid = Self::new(value)?;
        if oid.kind == GitOidKind::Sha256 {
            Ok(oid)
        } else {
            Err(GitOidParseError::InvalidLength(oid.value.len()))
        }
    }

    /// Returns the identifier kind inferred from its length.
    #[must_use]
    pub const fn kind(&self) -> GitOidKind {
        self.kind
    }

    /// Returns the identifier text.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.value
    }

    /// Consumes the identifier and returns the owned text.
    #[must_use]
    pub fn into_string(self) -> String {
        self.value
    }
}

impl AsRef<str> for GitOid {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

impl fmt::Display for GitOid {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for GitOid {
    type Err = GitOidParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        Self::new(value)
    }
}

impl TryFrom<&str> for GitOid {
    type Error = GitOidParseError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}

/// A short object identifier prefix.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ShortGitOid(String);

impl ShortGitOid {
    /// Creates a short object identifier prefix from hexadecimal text.
    ///
    /// # Errors
    ///
    /// Returns [`GitOidParseError`] when the text is empty, too short, too long,
    /// or contains non-hexadecimal characters.
    pub fn new(value: impl AsRef<str>) -> Result<Self, GitOidParseError> {
        let value = normalized_hex(value.as_ref())?;
        let length = value.len();

        if !(MIN_SHORT_HEX_LEN..=SHA256_HEX_LEN).contains(&length) {
            return Err(GitOidParseError::InvalidShortLength(length));
        }

        Ok(Self(value))
    }

    /// Returns the identifier prefix text.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Returns the best known object identifier kind from the prefix length.
    #[must_use]
    pub const fn kind_hint(&self) -> Option<GitOidKind> {
        match self.0.len() {
            SHA1_HEX_LEN => Some(GitOidKind::Sha1),
            SHA256_HEX_LEN => Some(GitOidKind::Sha256),
            _ => None,
        }
    }
}

impl AsRef<str> for ShortGitOid {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

impl fmt::Display for ShortGitOid {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for ShortGitOid {
    type Err = GitOidParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        Self::new(value)
    }
}

impl TryFrom<&str> for ShortGitOid {
    type Error = GitOidParseError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}

#[cfg(test)]
mod tests {
    use super::{GitOid, GitOidKind, GitOidParseError, ShortGitOid};

    #[test]
    fn parses_sha1_oid() -> Result<(), GitOidParseError> {
        let oid = GitOid::new("0123456789ABCDEF0123456789abcdef01234567")?;

        assert_eq!(oid.kind(), GitOidKind::Sha1);
        assert_eq!(oid.as_str(), "0123456789abcdef0123456789abcdef01234567");
        Ok(())
    }

    #[test]
    fn parses_sha256_oid() -> Result<(), GitOidParseError> {
        let oid = GitOid::new("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")?;

        assert_eq!(oid.kind(), GitOidKind::Sha256);
        Ok(())
    }

    #[test]
    fn rejects_invalid_oids() {
        assert_eq!(GitOid::new(""), Err(GitOidParseError::Empty));
        assert_eq!(GitOid::new("abc"), Err(GitOidParseError::InvalidLength(3)));
        assert_eq!(
            GitOid::new("0123456789abcdef0123456789abcdef0123456z"),
            Err(GitOidParseError::NonHexCharacter {
                index: 39,
                character: 'z'
            })
        );
    }

    #[test]
    fn parses_short_oid() -> Result<(), GitOidParseError> {
        let oid = ShortGitOid::new("AbCd")?;

        assert_eq!(oid.as_str(), "abcd");
        assert_eq!(oid.kind_hint(), None);
        Ok(())
    }
}