use-git-refspec 0.0.1

Primitive Git refspec 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 refspec vocabulary.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RefspecParseError {
    /// The supplied refspec text was empty.
    Empty,
    /// The supplied refspec source was empty.
    EmptySource,
    /// The supplied refspec destination was empty.
    EmptyDestination,
    /// The supplied refspec contained more separators than this crate models.
    TooManySeparators,
    /// The supplied direction label was not recognized.
    UnknownDirection,
    /// The supplied mode label was not recognized.
    UnknownMode,
}

impl fmt::Display for RefspecParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Git refspec cannot be empty"),
            Self::EmptySource => formatter.write_str("Git refspec source cannot be empty"),
            Self::EmptyDestination => {
                formatter.write_str("Git refspec destination cannot be empty")
            },
            Self::TooManySeparators => {
                formatter.write_str("Git refspec contains too many separators")
            },
            Self::UnknownDirection => formatter.write_str("unknown Git refspec direction"),
            Self::UnknownMode => formatter.write_str("unknown Git refspec mode"),
        }
    }
}

impl Error for RefspecParseError {}

/// Refspec direction vocabulary.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RefspecDirection {
    /// Fetch refspec vocabulary.
    Fetch,
    /// Push refspec vocabulary.
    Push,
}

impl RefspecDirection {
    /// Returns the stable label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Fetch => "fetch",
            Self::Push => "push",
        }
    }
}

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

impl FromStr for RefspecDirection {
    type Err = RefspecParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "fetch" => Ok(Self::Fetch),
            "push" => Ok(Self::Push),
            "" => Err(RefspecParseError::Empty),
            _ => Err(RefspecParseError::UnknownDirection),
        }
    }
}

/// Refspec force mode vocabulary.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RefspecMode {
    /// A normal refspec.
    Normal,
    /// A force refspec prefixed by `+`.
    Force,
}

impl RefspecMode {
    /// Returns the stable label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Normal => "normal",
            Self::Force => "force",
        }
    }
}

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

impl FromStr for RefspecMode {
    type Err = RefspecParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "normal" => Ok(Self::Normal),
            "force" | "+" => Ok(Self::Force),
            "" => Err(RefspecParseError::Empty),
            _ => Err(RefspecParseError::UnknownMode),
        }
    }
}

fn non_empty(value: &str, error: RefspecParseError) -> Result<String, RefspecParseError> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        Err(error)
    } else {
        Ok(trimmed.to_string())
    }
}

/// Refspec source text.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RefspecSource(String);

impl RefspecSource {
    /// Creates a refspec source.
    ///
    /// # Errors
    ///
    /// Returns [`RefspecParseError::EmptySource`] when the source is empty.
    pub fn new(value: impl AsRef<str>) -> Result<Self, RefspecParseError> {
        non_empty(value.as_ref(), RefspecParseError::EmptySource).map(Self)
    }

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

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

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

/// Refspec destination text.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RefspecDestination(String);

impl RefspecDestination {
    /// Creates a refspec destination.
    ///
    /// # Errors
    ///
    /// Returns [`RefspecParseError::EmptyDestination`] when the destination is empty.
    pub fn new(value: impl AsRef<str>) -> Result<Self, RefspecParseError> {
        non_empty(value.as_ref(), RefspecParseError::EmptyDestination).map(Self)
    }

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

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

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

/// A lightweight refspec model.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitRefspec {
    source: RefspecSource,
    destination: Option<RefspecDestination>,
    direction: RefspecDirection,
    mode: RefspecMode,
}

impl GitRefspec {
    /// Creates a refspec from parts.
    #[must_use]
    pub const fn new(
        source: RefspecSource,
        destination: Option<RefspecDestination>,
        direction: RefspecDirection,
        mode: RefspecMode,
    ) -> Self {
        Self {
            source,
            destination,
            direction,
            mode,
        }
    }

    /// Parses a fetch-oriented refspec from text.
    ///
    /// # Errors
    ///
    /// Returns [`RefspecParseError`] when the refspec is empty or malformed.
    pub fn parse(value: impl AsRef<str>) -> Result<Self, RefspecParseError> {
        Self::parse_with_direction(value, RefspecDirection::Fetch)
    }

    /// Parses a refspec from text with explicit direction vocabulary.
    ///
    /// # Errors
    ///
    /// Returns [`RefspecParseError`] when the refspec is empty or malformed.
    pub fn parse_with_direction(
        value: impl AsRef<str>,
        direction: RefspecDirection,
    ) -> Result<Self, RefspecParseError> {
        let trimmed = value.as_ref().trim();
        if trimmed.is_empty() {
            return Err(RefspecParseError::Empty);
        }

        let (mode, body) = trimmed
            .strip_prefix('+')
            .map_or((RefspecMode::Normal, trimmed), |rest| {
                (RefspecMode::Force, rest)
            });

        if body.matches(':').count() > 1 {
            return Err(RefspecParseError::TooManySeparators);
        }

        let (source, destination) = match body.split_once(':') {
            Some((source, destination)) => (
                RefspecSource::new(source)?,
                Some(RefspecDestination::new(destination)?),
            ),
            None => (RefspecSource::new(body)?, None),
        };

        Ok(Self::new(source, destination, direction, mode))
    }

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

    /// Returns the destination side when present.
    #[must_use]
    pub const fn destination(&self) -> Option<&RefspecDestination> {
        self.destination.as_ref()
    }

    /// Returns the direction vocabulary.
    #[must_use]
    pub const fn direction(&self) -> RefspecDirection {
        self.direction
    }

    /// Returns the force mode vocabulary.
    #[must_use]
    pub const fn mode(&self) -> RefspecMode {
        self.mode
    }

    /// Returns true when either side contains `*`.
    #[must_use]
    pub fn is_wildcard(&self) -> bool {
        self.source.as_str().contains('*')
            || self
                .destination
                .as_ref()
                .is_some_and(|destination| destination.as_str().contains('*'))
    }
}

impl fmt::Display for GitRefspec {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.mode == RefspecMode::Force {
            formatter.write_str("+")?;
        }
        formatter.write_str(self.source.as_str())?;
        if let Some(destination) = &self.destination {
            write!(formatter, ":{destination}")?;
        }
        Ok(())
    }
}

impl FromStr for GitRefspec {
    type Err = RefspecParseError;

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

#[cfg(test)]
mod tests {
    use super::{GitRefspec, RefspecDirection, RefspecMode, RefspecParseError};

    #[test]
    fn parses_force_wildcard_refspec() -> Result<(), RefspecParseError> {
        let spec = GitRefspec::parse("+refs/heads/*:refs/remotes/origin/*")?;

        assert_eq!(spec.mode(), RefspecMode::Force);
        assert_eq!(spec.direction(), RefspecDirection::Fetch);
        assert!(spec.is_wildcard());
        assert_eq!(spec.to_string(), "+refs/heads/*:refs/remotes/origin/*");
        Ok(())
    }

    #[test]
    fn rejects_invalid_refspecs() {
        assert_eq!(GitRefspec::parse(""), Err(RefspecParseError::Empty));
        assert_eq!(GitRefspec::parse(":"), Err(RefspecParseError::EmptySource));
        assert_eq!(
            GitRefspec::parse("a:b:c"),
            Err(RefspecParseError::TooManySeparators)
        );
    }
}