#![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 GitRemoteNameError {
Empty,
InvalidName,
MissingRemoteOrBranch,
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())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitRemoteName(String);
impl GitRemoteName {
pub fn new(value: impl AsRef<str>) -> Result<Self, GitRemoteNameError> {
validate_remote_name(value).map(Self)
}
#[must_use]
pub fn origin() -> Self {
Self(String::from("origin"))
}
#[must_use]
pub fn upstream() -> Self {
Self(String::from("upstream"))
}
#[must_use]
pub fn is_origin(&self) -> bool {
self.as_str() == "origin"
}
#[must_use]
pub fn is_upstream(&self) -> bool {
self.as_str() == "upstream"
}
#[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)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitRemoteKind {
Origin,
Upstream,
Mirror,
Other,
}
impl GitRemoteKind {
#[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),
}
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RemoteRefName(String);
impl RemoteRefName {
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()))
}
#[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)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RemoteTrackingRef(RemoteRefName);
impl RemoteTrackingRef {
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)
}
#[must_use]
pub fn remote(&self) -> Option<&str> {
self.0.as_str().split_once('/').map(|(remote, _)| remote)
}
#[must_use]
pub fn branch(&self) -> Option<&str> {
self.0.as_str().split_once('/').map(|(_, branch)| branch)
}
#[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)
);
}
}