#![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 GitRefParseError {
Empty,
InvalidName,
EmptyDetachedTarget,
}
impl fmt::Display for GitRefParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Git ref name cannot be empty"),
Self::InvalidName => formatter.write_str("invalid Git ref name"),
Self::EmptyDetachedTarget => {
formatter.write_str("detached HEAD target cannot be empty")
},
}
}
}
impl Error for GitRefParseError {}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitRefKind {
Head,
Branch,
Tag,
Remote,
Other,
}
impl GitRefKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Head => "head",
Self::Branch => "branch",
Self::Tag => "tag",
Self::Remote => "remote",
Self::Other => "other",
}
}
}
impl fmt::Display for GitRefKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
fn ref_kind(value: &str) -> GitRefKind {
if value == "HEAD" {
GitRefKind::Head
} else if value.starts_with("refs/heads/") {
GitRefKind::Branch
} else if value.starts_with("refs/tags/") {
GitRefKind::Tag
} else if value.starts_with("refs/remotes/") {
GitRefKind::Remote
} else {
GitRefKind::Other
}
}
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_ref_name(value: impl AsRef<str>) -> Result<String, GitRefParseError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
return Err(GitRefParseError::Empty);
}
if trimmed == "HEAD" {
return Ok(trimmed.to_string());
}
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, '~' | '^' | ':' | '?' | '*' | '[' | '\\')
})
|| trimmed.split('/').any(|component| component.ends_with('.'));
if invalid {
Err(GitRefParseError::InvalidName)
} else {
Ok(trimmed.to_string())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitRefName {
value: String,
kind: GitRefKind,
}
impl GitRefName {
pub fn new(value: impl AsRef<str>) -> Result<Self, GitRefParseError> {
let value = validate_ref_name(value)?;
let kind = ref_kind(&value);
Ok(Self { value, kind })
}
#[must_use]
pub const fn kind(&self) -> GitRefKind {
self.kind
}
#[must_use]
pub const fn is_head(&self) -> bool {
matches!(self.kind, GitRefKind::Head)
}
#[must_use]
pub const fn is_branch(&self) -> bool {
matches!(self.kind, GitRefKind::Branch)
}
#[must_use]
pub const fn is_tag(&self) -> bool {
matches!(self.kind, GitRefKind::Tag)
}
#[must_use]
pub const fn is_remote(&self) -> bool {
matches!(self.kind, GitRefKind::Remote)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.value
}
}
impl AsRef<str> for GitRefName {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for GitRefName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for GitRefName {
type Err = GitRefParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
impl TryFrom<&str> for GitRefName {
type Error = GitRefParseError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitRef(GitRefName);
impl GitRef {
#[must_use]
pub const fn from_name(name: GitRefName) -> Self {
Self(name)
}
pub fn new(value: impl AsRef<str>) -> Result<Self, GitRefParseError> {
GitRefName::new(value).map(Self)
}
#[must_use]
pub const fn name(&self) -> &GitRefName {
&self.0
}
#[must_use]
pub const fn kind(&self) -> GitRefKind {
self.0.kind()
}
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl AsRef<str> for GitRef {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for GitRef {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for GitRef {
type Err = GitRefParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SymbolicRef {
target: GitRefName,
}
impl SymbolicRef {
#[must_use]
pub const fn new(target: GitRefName) -> Self {
Self { target }
}
pub fn parse(value: impl AsRef<str>) -> Result<Self, GitRefParseError> {
GitRefName::new(value).map(Self::new)
}
#[must_use]
pub const fn target(&self) -> &GitRefName {
&self.target
}
#[must_use]
pub fn as_str(&self) -> &str {
self.target.as_str()
}
}
impl fmt::Display for SymbolicRef {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for SymbolicRef {
type Err = GitRefParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::parse(value)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GitHead {
Symbolic(SymbolicRef),
Detached(String),
Unborn,
}
impl GitHead {
#[must_use]
pub const fn symbolic(target: GitRefName) -> Self {
Self::Symbolic(SymbolicRef::new(target))
}
pub fn detached(value: impl AsRef<str>) -> Result<Self, GitRefParseError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
Err(GitRefParseError::EmptyDetachedTarget)
} else {
Ok(Self::Detached(trimmed.to_string()))
}
}
#[must_use]
pub const fn is_symbolic(&self) -> bool {
matches!(self, Self::Symbolic(_))
}
#[must_use]
pub const fn is_detached(&self) -> bool {
matches!(self, Self::Detached(_))
}
#[must_use]
pub const fn symbolic_ref(&self) -> Option<&SymbolicRef> {
match self {
Self::Symbolic(symbolic) => Some(symbolic),
Self::Detached(_) | Self::Unborn => None,
}
}
}
#[cfg(test)]
mod tests {
use super::{GitHead, GitRefKind, GitRefName, GitRefParseError, SymbolicRef};
#[test]
fn classifies_common_refs() -> Result<(), GitRefParseError> {
let branch = GitRefName::new("refs/heads/main")?;
let tag = GitRefName::new("refs/tags/v1.0.0")?;
let remote = GitRefName::new("refs/remotes/origin/main")?;
assert_eq!(branch.kind(), GitRefKind::Branch);
assert_eq!(tag.kind(), GitRefKind::Tag);
assert_eq!(remote.kind(), GitRefKind::Remote);
Ok(())
}
#[test]
fn models_symbolic_head() -> Result<(), GitRefParseError> {
let symbolic = SymbolicRef::parse("refs/heads/main")?;
let head = GitHead::Symbolic(symbolic);
assert!(head.is_symbolic());
assert_eq!(
head.symbolic_ref().map(SymbolicRef::as_str),
Some("refs/heads/main")
);
Ok(())
}
#[test]
fn rejects_invalid_refs() {
assert_eq!(GitRefName::new(""), Err(GitRefParseError::Empty));
assert_eq!(
GitRefName::new("refs/heads/main.lock"),
Err(GitRefParseError::InvalidName)
);
assert_eq!(
GitRefName::new("refs/heads/with space"),
Err(GitRefParseError::InvalidName)
);
}
}