#![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;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitOidKind {
Sha1,
Sha256,
}
impl GitOidKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Sha1 => "sha1",
Self::Sha256 => "sha256",
}
}
#[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),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitOidParseError {
Empty,
InvalidLength(usize),
InvalidShortLength(usize),
NonHexCharacter { index: usize, character: char },
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())
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitOid {
value: String,
kind: GitOidKind,
}
impl GitOid {
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 })
}
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()))
}
}
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()))
}
}
#[must_use]
pub const fn kind(&self) -> GitOidKind {
self.kind
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.value
}
#[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)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ShortGitOid(String);
impl ShortGitOid {
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))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[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(())
}
}