use-git-branch 0.0.1

Primitive Git branch 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 branch names.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitBranchNameError {
    /// The supplied branch name was empty.
    Empty,
    /// The supplied branch name used syntax this crate rejects.
    InvalidName,
    /// A remote-tracking name did not include both remote and branch parts.
    MissingRemoteOrBranch,
}

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

impl Error for GitBranchNameError {}

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_branch_name(value: impl AsRef<str>) -> Result<String, GitBranchNameError> {
    let trimmed = value.as_ref().trim();

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

    let invalid = trimmed == "HEAD"
        || 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(GitBranchNameError::InvalidName)
    } else {
        Ok(trimmed.to_string())
    }
}

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

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

    /// Returns true for common mainline names.
    #[must_use]
    pub fn is_mainline(&self) -> bool {
        matches!(self.as_str(), "main" | "master" | "trunk")
    }

    /// Returns true for feature branch spellings.
    #[must_use]
    pub fn is_feature(&self) -> bool {
        self.as_str().starts_with("feature/") || self.as_str().starts_with("feat/")
    }

    /// Returns true for release branch spellings.
    #[must_use]
    pub fn is_release(&self) -> bool {
        self.as_str().starts_with("release/")
    }

    /// Returns true for hotfix branch spellings.
    #[must_use]
    pub fn is_hotfix(&self) -> bool {
        self.as_str().starts_with("hotfix/")
    }

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

    /// Consumes the branch name and returns owned text.
    #[must_use]
    pub fn into_string(self) -> String {
        self.0
    }
}

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

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

impl FromStr for GitBranchName {
    type Err = GitBranchNameError;

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

impl TryFrom<&str> for GitBranchName {
    type Error = GitBranchNameError;

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

/// A local branch name.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct LocalBranchName(GitBranchName);

impl LocalBranchName {
    /// Creates a local branch name.
    ///
    /// # Errors
    ///
    /// Returns [`GitBranchNameError`] when the branch name is invalid.
    pub fn new(value: impl AsRef<str>) -> Result<Self, GitBranchNameError> {
        GitBranchName::new(value).map(Self)
    }

    /// Returns the inner branch name.
    #[must_use]
    pub const fn branch(&self) -> &GitBranchName {
        &self.0
    }

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

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

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

impl FromStr for LocalBranchName {
    type Err = GitBranchNameError;

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

/// A remote-tracking branch spelling such as `origin/main`.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RemoteTrackingBranchName {
    value: String,
    remote: String,
    branch: GitBranchName,
}

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

        if remote.is_empty() || branch.is_empty() || remote.contains(char::is_whitespace) {
            return Err(GitBranchNameError::MissingRemoteOrBranch);
        }

        let branch = GitBranchName::new(branch)?;

        Ok(Self {
            value: value.to_string(),
            remote: remote.to_string(),
            branch,
        })
    }

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

    /// Returns the branch name portion.
    #[must_use]
    pub const fn branch(&self) -> &GitBranchName {
        &self.branch
    }

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

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

impl FromStr for RemoteTrackingBranchName {
    type Err = GitBranchNameError;

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

/// A conventional default branch name.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct DefaultBranchName(GitBranchName);

impl DefaultBranchName {
    /// Creates a default branch name.
    ///
    /// # Errors
    ///
    /// Returns [`GitBranchNameError`] when the name is invalid.
    pub fn new(value: impl AsRef<str>) -> Result<Self, GitBranchNameError> {
        GitBranchName::new(value).map(Self)
    }

    /// Returns `main` as a default branch name.
    #[must_use]
    pub fn main() -> Self {
        Self(GitBranchName(String::from("main")))
    }

    /// Returns `master` as a default branch name.
    #[must_use]
    pub fn master() -> Self {
        Self(GitBranchName(String::from("master")))
    }

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

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

#[cfg(test)]
mod tests {
    use super::{DefaultBranchName, GitBranchName, GitBranchNameError, RemoteTrackingBranchName};

    #[test]
    fn validates_branch_categories() -> Result<(), GitBranchNameError> {
        let feature = GitBranchName::new("feature/use-git")?;
        let release = GitBranchName::new("release/0.1")?;
        let hotfix = GitBranchName::new("hotfix/docs")?;

        assert!(feature.is_feature());
        assert!(release.is_release());
        assert!(hotfix.is_hotfix());
        assert!(DefaultBranchName::main().as_str() == "main");
        Ok(())
    }

    #[test]
    fn parses_remote_tracking_branch() -> Result<(), GitBranchNameError> {
        let branch = RemoteTrackingBranchName::new("origin/main")?;

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

    #[test]
    fn rejects_invalid_branch_names() {
        assert_eq!(GitBranchName::new(""), Err(GitBranchNameError::Empty));
        assert_eq!(
            GitBranchName::new("HEAD"),
            Err(GitBranchNameError::InvalidName)
        );
        assert_eq!(
            GitBranchName::new("feature//x"),
            Err(GitBranchNameError::InvalidName)
        );
    }
}