#![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 RevisionParseError {
Empty,
EmptyRangeSide,
ZeroSuffixCount,
UnknownRangeKind,
}
impl fmt::Display for RevisionParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Git revision cannot be empty"),
Self::EmptyRangeSide => formatter.write_str("Git revision range sides cannot be empty"),
Self::ZeroSuffixCount => {
formatter.write_str("Git revision suffix count cannot be zero")
},
Self::UnknownRangeKind => formatter.write_str("unknown Git revision range kind"),
}
}
}
impl Error for RevisionParseError {}
fn non_empty(
value: impl AsRef<str>,
error: RevisionParseError,
) -> Result<String, RevisionParseError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
Err(error)
} else {
Ok(trimmed.to_string())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RevisionSelector {
Head,
Branch(String),
Tag(String),
Ref(String),
Oid(String),
Other(String),
}
impl fmt::Display for RevisionSelector {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Head => formatter.write_str("HEAD"),
Self::Branch(value)
| Self::Tag(value)
| Self::Ref(value)
| Self::Oid(value)
| Self::Other(value) => formatter.write_str(value),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RevisionSuffix {
Parent,
ParentNumber(u32),
Ancestor(u32),
}
impl RevisionSuffix {
pub const fn parent_number(number: u32) -> Result<Self, RevisionParseError> {
if number == 0 {
Err(RevisionParseError::ZeroSuffixCount)
} else {
Ok(Self::ParentNumber(number))
}
}
pub const fn ancestor(count: u32) -> Result<Self, RevisionParseError> {
if count == 0 {
Err(RevisionParseError::ZeroSuffixCount)
} else {
Ok(Self::Ancestor(count))
}
}
}
impl fmt::Display for RevisionSuffix {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Parent => formatter.write_str("^"),
Self::ParentNumber(number) => write!(formatter, "^{number}"),
Self::Ancestor(count) => write!(formatter, "~{count}"),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RevisionRangeKind {
TwoDot,
ThreeDot,
}
impl RevisionRangeKind {
#[must_use]
pub const fn separator(self) -> &'static str {
match self {
Self::TwoDot => "..",
Self::ThreeDot => "...",
}
}
}
impl fmt::Display for RevisionRangeKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.separator())
}
}
impl FromStr for RevisionRangeKind {
type Err = RevisionParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim() {
".." | "two-dot" | "twodot" => Ok(Self::TwoDot),
"..." | "three-dot" | "threedot" => Ok(Self::ThreeDot),
"" => Err(RevisionParseError::Empty),
_ => Err(RevisionParseError::UnknownRangeKind),
}
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitRevision(String);
impl GitRevision {
pub fn new(value: impl AsRef<str>) -> Result<Self, RevisionParseError> {
non_empty(value, RevisionParseError::Empty).map(Self)
}
#[must_use]
pub fn head() -> Self {
Self(String::from("HEAD"))
}
#[must_use]
pub fn with_suffix(&self, suffix: RevisionSuffix) -> Self {
Self(format!("{}{suffix}", self.as_str()))
}
#[must_use]
pub fn selector(&self) -> RevisionSelector {
let value = self.as_str();
if value == "HEAD" {
RevisionSelector::Head
} else if let Some(branch) = value.strip_prefix("refs/heads/") {
RevisionSelector::Branch(branch.to_string())
} else if let Some(tag) = value.strip_prefix("refs/tags/") {
RevisionSelector::Tag(tag.to_string())
} else if value.starts_with("refs/") {
RevisionSelector::Ref(value.to_string())
} else if is_oid_like(value) {
RevisionSelector::Oid(value.to_string())
} else {
RevisionSelector::Other(value.to_string())
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for GitRevision {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for GitRevision {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for GitRevision {
type Err = RevisionParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RevisionRange {
left: GitRevision,
right: GitRevision,
kind: RevisionRangeKind,
}
impl RevisionRange {
pub fn new(
left: impl AsRef<str>,
right: impl AsRef<str>,
kind: RevisionRangeKind,
) -> Result<Self, RevisionParseError> {
let left = GitRevision(non_empty(left, RevisionParseError::EmptyRangeSide)?);
let right = GitRevision(non_empty(right, RevisionParseError::EmptyRangeSide)?);
Ok(Self { left, right, kind })
}
#[must_use]
pub const fn left(&self) -> &GitRevision {
&self.left
}
#[must_use]
pub const fn right(&self) -> &GitRevision {
&self.right
}
#[must_use]
pub const fn kind(&self) -> RevisionRangeKind {
self.kind
}
}
impl fmt::Display for RevisionRange {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"{}{}{}",
self.left,
self.kind.separator(),
self.right
)
}
}
fn is_oid_like(value: &str) -> bool {
matches!(value.len(), 40 | 64) && value.chars().all(|character| character.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::{
GitRevision, RevisionParseError, RevisionRange, RevisionRangeKind, RevisionSelector,
RevisionSuffix,
};
#[test]
fn models_head_and_suffixes() -> Result<(), RevisionParseError> {
let revision = GitRevision::head().with_suffix(RevisionSuffix::Ancestor(2));
assert_eq!(revision.as_str(), "HEAD~2");
assert_eq!(GitRevision::head().selector(), RevisionSelector::Head);
assert_eq!(RevisionSuffix::parent_number(2)?.to_string(), "^2");
Ok(())
}
#[test]
fn models_revision_ranges() -> Result<(), RevisionParseError> {
let range = RevisionRange::new("main", "feature/use-git", RevisionRangeKind::ThreeDot)?;
assert_eq!(range.to_string(), "main...feature/use-git");
assert_eq!(range.kind(), RevisionRangeKind::ThreeDot);
Ok(())
}
#[test]
fn rejects_empty_revisions() {
assert_eq!(GitRevision::new(""), Err(RevisionParseError::Empty));
assert_eq!(
RevisionRange::new("", "main", RevisionRangeKind::TwoDot),
Err(RevisionParseError::EmptyRangeSide)
);
}
}