use-git-revision 0.0.1

Primitive Git revision selector 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 revision vocabulary.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RevisionParseError {
    /// The supplied revision text was empty.
    Empty,
    /// The supplied range side was empty.
    EmptyRangeSide,
    /// The supplied suffix number was zero.
    ZeroSuffixCount,
    /// The supplied range kind label was not recognized.
    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())
    }
}

/// A revision selector classification.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RevisionSelector {
    /// `HEAD` selector.
    Head,
    /// A branch selector.
    Branch(String),
    /// A tag selector.
    Tag(String),
    /// A full ref selector.
    Ref(String),
    /// Object identifier text.
    Oid(String),
    /// Other selector text.
    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),
        }
    }
}

/// A revision suffix such as `^` or `~2`.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RevisionSuffix {
    /// First parent suffix, displayed as `^`.
    Parent,
    /// Numbered parent suffix, displayed as `^n`.
    ParentNumber(u32),
    /// Ancestor suffix, displayed as `~n`.
    Ancestor(u32),
}

impl RevisionSuffix {
    /// Creates a numbered parent suffix.
    ///
    /// # Errors
    ///
    /// Returns [`RevisionParseError::ZeroSuffixCount`] when `number` is zero.
    pub const fn parent_number(number: u32) -> Result<Self, RevisionParseError> {
        if number == 0 {
            Err(RevisionParseError::ZeroSuffixCount)
        } else {
            Ok(Self::ParentNumber(number))
        }
    }

    /// Creates an ancestor suffix.
    ///
    /// # Errors
    ///
    /// Returns [`RevisionParseError::ZeroSuffixCount`] when `count` is zero.
    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}"),
        }
    }
}

/// Revision range spelling.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RevisionRangeKind {
    /// Two-dot range, displayed as `A..B`.
    TwoDot,
    /// Three-dot range, displayed as `A...B`.
    ThreeDot,
}

impl RevisionRangeKind {
    /// Returns the separator used by this range kind.
    #[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),
        }
    }
}

/// A lightweight revision selector text.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitRevision(String);

impl GitRevision {
    /// Creates a revision selector from text.
    ///
    /// # Errors
    ///
    /// Returns [`RevisionParseError::Empty`] when the selector is empty.
    pub fn new(value: impl AsRef<str>) -> Result<Self, RevisionParseError> {
        non_empty(value, RevisionParseError::Empty).map(Self)
    }

    /// Returns the `HEAD` selector.
    #[must_use]
    pub fn head() -> Self {
        Self(String::from("HEAD"))
    }

    /// Returns a new selector with a suffix appended.
    #[must_use]
    pub fn with_suffix(&self, suffix: RevisionSuffix) -> Self {
        Self(format!("{}{suffix}", self.as_str()))
    }

    /// Returns a broad selector classification.
    #[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())
        }
    }

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

/// A revision range such as `A..B` or `A...B`.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RevisionRange {
    left: GitRevision,
    right: GitRevision,
    kind: RevisionRangeKind,
}

impl RevisionRange {
    /// Creates a revision range.
    ///
    /// # Errors
    ///
    /// Returns [`RevisionParseError::EmptyRangeSide`] when either side is empty.
    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 })
    }

    /// Returns the left side.
    #[must_use]
    pub const fn left(&self) -> &GitRevision {
        &self.left
    }

    /// Returns the right side.
    #[must_use]
    pub const fn right(&self) -> &GitRevision {
        &self.right
    }

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