use-git-remote 0.0.1

Primitive Git remote name 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 remote vocabulary.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitRemoteNameError {
    /// The supplied remote text was empty.
    Empty,
    /// The supplied remote text used syntax this crate rejects.
    InvalidName,
    /// The supplied remote-tracking ref missed a remote or branch part.
    MissingRemoteOrBranch,
    /// The supplied remote kind label was not recognized.
    UnknownKind,
}

impl fmt::Display for GitRemoteNameError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Git remote name cannot be empty"),
            Self::InvalidName => formatter.write_str("invalid Git remote name"),
            Self::MissingRemoteOrBranch => {
                formatter.write_str("remote-tracking ref must contain remote and branch names")
            },
            Self::UnknownKind => formatter.write_str("unknown Git remote kind"),
        }
    }
}

impl Error for GitRemoteNameError {}

fn validate_remote_name(value: impl AsRef<str>) -> Result<String, GitRemoteNameError> {
    let trimmed = value.as_ref().trim();

    if trimmed.is_empty() {
        return Err(GitRemoteNameError::Empty);
    }

    let invalid = trimmed.contains('/')
        || trimmed.starts_with('.')
        || trimmed.ends_with('.')
        || trimmed.contains("..")
        || trimmed.chars().any(|character| {
            character.is_ascii_control()
                || character.is_ascii_whitespace()
                || matches!(character, '~' | '^' | ':' | '?' | '*' | '[' | '\\')
        });

    if invalid {
        Err(GitRemoteNameError::InvalidName)
    } else {
        Ok(trimmed.to_string())
    }
}

/// A validated remote name.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitRemoteName(String);

impl GitRemoteName {
    /// Creates a remote name from text.
    ///
    /// # Errors
    ///
    /// Returns [`GitRemoteNameError`] when the remote name is empty or invalid.
    pub fn new(value: impl AsRef<str>) -> Result<Self, GitRemoteNameError> {
        validate_remote_name(value).map(Self)
    }

    /// Returns the conventional `origin` remote name.
    #[must_use]
    pub fn origin() -> Self {
        Self(String::from("origin"))
    }

    /// Returns the conventional `upstream` remote name.
    #[must_use]
    pub fn upstream() -> Self {
        Self(String::from("upstream"))
    }

    /// Returns true when this is `origin`.
    #[must_use]
    pub fn is_origin(&self) -> bool {
        self.as_str() == "origin"
    }

    /// Returns true when this is `upstream`.
    #[must_use]
    pub fn is_upstream(&self) -> bool {
        self.as_str() == "upstream"
    }

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

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

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

impl FromStr for GitRemoteName {
    type Err = GitRemoteNameError;

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

impl TryFrom<&str> for GitRemoteName {
    type Error = GitRemoteNameError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}

/// Remote kind vocabulary.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitRemoteKind {
    /// The primary `origin` remote label.
    Origin,
    /// An `upstream` remote label.
    Upstream,
    /// A mirror remote label.
    Mirror,
    /// Another remote role.
    Other,
}

impl GitRemoteKind {
    /// Returns the stable kind label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Origin => "origin",
            Self::Upstream => "upstream",
            Self::Mirror => "mirror",
            Self::Other => "other",
        }
    }
}

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

impl FromStr for GitRemoteKind {
    type Err = GitRemoteNameError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "origin" => Ok(Self::Origin),
            "upstream" => Ok(Self::Upstream),
            "mirror" => Ok(Self::Mirror),
            "other" => Ok(Self::Other),
            "" => Err(GitRemoteNameError::Empty),
            _ => Err(GitRemoteNameError::UnknownKind),
        }
    }
}

/// A remote ref name.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RemoteRefName(String);

impl RemoteRefName {
    /// Creates a remote ref name from text.
    ///
    /// # Errors
    ///
    /// Returns [`GitRemoteNameError`] when the remote ref is empty or invalid.
    pub fn new(value: impl AsRef<str>) -> Result<Self, GitRemoteNameError> {
        let trimmed = value.as_ref().trim();
        if trimmed.is_empty() {
            return Err(GitRemoteNameError::Empty);
        }
        if trimmed.contains(char::is_whitespace) || trimmed.contains("//") {
            return Err(GitRemoteNameError::InvalidName);
        }
        Ok(Self(trimmed.to_string()))
    }

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

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

impl FromStr for RemoteRefName {
    type Err = GitRemoteNameError;

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

/// A remote-tracking ref spelling such as `origin/main`.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RemoteTrackingRef(RemoteRefName);

impl RemoteTrackingRef {
    /// Creates a remote-tracking ref.
    ///
    /// # Errors
    ///
    /// Returns [`GitRemoteNameError`] when the ref is invalid or missing parts.
    pub fn new(value: impl AsRef<str>) -> Result<Self, GitRemoteNameError> {
        let value = value.as_ref().trim();
        let Some((remote, branch)) = value.split_once('/') else {
            return Err(GitRemoteNameError::MissingRemoteOrBranch);
        };

        if remote.is_empty() || branch.is_empty() {
            return Err(GitRemoteNameError::MissingRemoteOrBranch);
        }

        validate_remote_name(remote)?;
        RemoteRefName::new(value).map(Self)
    }

    /// Returns the remote name portion.
    #[must_use]
    pub fn remote(&self) -> Option<&str> {
        self.0.as_str().split_once('/').map(|(remote, _)| remote)
    }

    /// Returns the branch ref portion.
    #[must_use]
    pub fn branch(&self) -> Option<&str> {
        self.0.as_str().split_once('/').map(|(_, branch)| branch)
    }

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

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

impl FromStr for RemoteTrackingRef {
    type Err = GitRemoteNameError;

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

#[cfg(test)]
mod tests {
    use super::{GitRemoteKind, GitRemoteName, GitRemoteNameError, RemoteTrackingRef};

    #[test]
    fn models_common_remotes() {
        let origin = GitRemoteName::origin();
        let upstream = GitRemoteName::upstream();

        assert!(origin.is_origin());
        assert!(upstream.is_upstream());
        assert_eq!(GitRemoteKind::Mirror.to_string(), "mirror");
    }

    #[test]
    fn parses_remote_tracking_refs() -> Result<(), GitRemoteNameError> {
        let tracking = RemoteTrackingRef::new("origin/main")?;

        assert_eq!(tracking.remote(), Some("origin"));
        assert_eq!(tracking.branch(), Some("main"));
        Ok(())
    }

    #[test]
    fn rejects_invalid_remote_names() {
        assert_eq!(GitRemoteName::new(""), Err(GitRemoteNameError::Empty));
        assert_eq!(
            GitRemoteName::new("origin/main"),
            Err(GitRemoteNameError::InvalidName)
        );
        assert_eq!(
            RemoteTrackingRef::new("origin"),
            Err(GitRemoteNameError::MissingRemoteOrBranch)
        );
    }
}