use std::borrow::Cow;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Branch(Cow<'static, str>);
impl Branch {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn has_parents(&self) -> bool {
self.0.contains('/')
}
const fn is_forbidden_char(byte: u8) -> bool {
matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
}
const fn validate(input: &str) -> Result<(), BranchError> {
if input.is_empty() {
return Err(BranchError::Empty);
}
let bytes = input.as_bytes();
if bytes.len() == 1 && bytes[0] == b'@' {
return Err(BranchError::SingleAt);
}
if bytes[0] == b'-' {
return Err(BranchError::StartsWithDash);
}
if bytes[0] == b'.' {
return Err(BranchError::StartsWithDot);
}
if bytes[0] == b'/' {
return Err(BranchError::StartsWithSlash);
}
if bytes[bytes.len() - 1] == b'/' {
return Err(BranchError::EndsWithSlash);
}
if bytes[bytes.len() - 1] == b'.' {
return Err(BranchError::EndsWithDot);
}
if bytes.len() >= 5
&& bytes[bytes.len() - 5] == b'.'
&& bytes[bytes.len() - 4] == b'l'
&& bytes[bytes.len() - 3] == b'o'
&& bytes[bytes.len() - 2] == b'c'
&& bytes[bytes.len() - 1] == b'k'
{
return Err(BranchError::EndsWithLock);
}
let mut index = 0;
while index < bytes.len() {
let byte = bytes[index];
if byte < 0x20 || byte == 0x7f {
return Err(BranchError::ContainsControlCharacter);
}
if byte == b' ' {
return Err(BranchError::ContainsSpace);
}
if Self::is_forbidden_char(byte) {
return Err(BranchError::ContainsForbiddenCharacter);
}
if byte == b'.' && index + 1 < bytes.len() && bytes[index + 1] == b'.' {
return Err(BranchError::ContainsDoubleDot);
}
if byte == b'/' && index + 1 < bytes.len() && bytes[index + 1] == b'/' {
return Err(BranchError::ContainsDoubleSlash);
}
if byte == b'@' && index + 1 < bytes.len() && bytes[index + 1] == b'{' {
return Err(BranchError::ContainsAtBrace);
}
if byte == b'/' && index + 1 < bytes.len() && bytes[index + 1] == b'.' {
return Err(BranchError::ComponentStartsWithDot);
}
if byte == b'.'
&& index + 5 < bytes.len()
&& bytes[index + 1] == b'l'
&& bytes[index + 2] == b'o'
&& bytes[index + 3] == b'c'
&& bytes[index + 4] == b'k'
&& bytes[index + 5] == b'/'
{
return Err(BranchError::ComponentEndsWithLock);
}
index += 1;
}
Ok(())
}
#[must_use]
pub const fn from_static_or_panic(input: &'static str) -> Self {
assert!(Self::validate(input).is_ok(), "invalid branch name");
Self(Cow::Borrowed(input))
}
}
impl std::fmt::Display for Branch {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}", self.0)
}
}
impl AsRef<std::ffi::OsStr> for Branch {
fn as_ref(&self) -> &std::ffi::OsStr {
self.as_str().as_ref()
}
}
impl std::str::FromStr for Branch {
type Err = BranchError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::validate(input)?;
Ok(Self(Cow::Owned(input.to_string())))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
pub enum BranchError {
#[error("branch name cannot be empty")]
Empty,
#[error("branch name cannot be single '@'")]
SingleAt,
#[error("branch name cannot start with '-'")]
StartsWithDash,
#[error("branch name cannot start with '.'")]
StartsWithDot,
#[error("branch name cannot start with '/'")]
StartsWithSlash,
#[error("branch name cannot end with '/'")]
EndsWithSlash,
#[error("branch name cannot end with '.'")]
EndsWithDot,
#[error("branch name cannot end with '.lock'")]
EndsWithLock,
#[error("branch name cannot contain '..'")]
ContainsDoubleDot,
#[error("branch name cannot contain '//'")]
ContainsDoubleSlash,
#[error("branch name cannot contain '@{{'")]
ContainsAtBrace,
#[error("branch component cannot start with '.'")]
ComponentStartsWithDot,
#[error("branch component cannot end with '.lock'")]
ComponentEndsWithLock,
#[error("branch name cannot contain control characters")]
ContainsControlCharacter,
#[error("branch name cannot contain spaces")]
ContainsSpace,
#[error("branch name cannot contain forbidden characters (~^:?*[\\)")]
ContainsForbiddenCharacter,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_branch() {
assert!("main".parse::<Branch>().is_ok());
assert!("feature/login".parse::<Branch>().is_ok());
assert!("feature/deeply/nested/branch".parse::<Branch>().is_ok());
assert!("fix-123".parse::<Branch>().is_ok());
}
#[test]
fn test_has_parents() {
assert!(!Branch::from_static_or_panic("main").has_parents());
assert!(Branch::from_static_or_panic("feature/login").has_parents());
}
#[test]
fn test_empty() {
assert!(matches!("".parse::<Branch>(), Err(BranchError::Empty)));
}
#[test]
fn test_single_at() {
assert!(matches!("@".parse::<Branch>(), Err(BranchError::SingleAt)));
}
#[test]
fn test_starts_with_dash() {
assert!(matches!(
"-branch".parse::<Branch>(),
Err(BranchError::StartsWithDash)
));
}
#[test]
fn test_starts_with_dot() {
assert!(matches!(
".branch".parse::<Branch>(),
Err(BranchError::StartsWithDot)
));
}
#[test]
fn test_starts_with_slash() {
assert!(matches!(
"/branch".parse::<Branch>(),
Err(BranchError::StartsWithSlash)
));
}
#[test]
fn test_ends_with_slash() {
assert!(matches!(
"branch/".parse::<Branch>(),
Err(BranchError::EndsWithSlash)
));
}
#[test]
fn test_ends_with_dot() {
assert!(matches!(
"branch.".parse::<Branch>(),
Err(BranchError::EndsWithDot)
));
}
#[test]
fn test_ends_with_lock() {
assert!(matches!(
"branch.lock".parse::<Branch>(),
Err(BranchError::EndsWithLock)
));
}
#[test]
fn test_contains_double_dot() {
assert!(matches!(
"branch..name".parse::<Branch>(),
Err(BranchError::ContainsDoubleDot)
));
}
#[test]
fn test_contains_double_slash() {
assert!(matches!(
"feature//branch".parse::<Branch>(),
Err(BranchError::ContainsDoubleSlash)
));
}
#[test]
fn test_contains_at_brace() {
assert!(matches!(
"branch@{name".parse::<Branch>(),
Err(BranchError::ContainsAtBrace)
));
}
#[test]
fn test_component_starts_with_dot() {
assert!(matches!(
"feature/.hidden".parse::<Branch>(),
Err(BranchError::ComponentStartsWithDot)
));
assert!(matches!(
"a/b/.c/d".parse::<Branch>(),
Err(BranchError::ComponentStartsWithDot)
));
}
#[test]
fn test_component_ends_with_lock() {
assert!(matches!(
"feature/branch.lock/next".parse::<Branch>(),
Err(BranchError::ComponentEndsWithLock)
));
}
#[test]
fn test_contains_space() {
assert!(matches!(
"branch name".parse::<Branch>(),
Err(BranchError::ContainsSpace)
));
}
#[test]
fn test_contains_control_character() {
assert!(matches!(
"branch\x00name".parse::<Branch>(),
Err(BranchError::ContainsControlCharacter)
));
assert!(matches!(
"branch\tname".parse::<Branch>(),
Err(BranchError::ContainsControlCharacter)
));
}
#[test]
fn test_contains_forbidden_characters() {
assert!(matches!(
"branch~name".parse::<Branch>(),
Err(BranchError::ContainsForbiddenCharacter)
));
assert!(matches!(
"branch^name".parse::<Branch>(),
Err(BranchError::ContainsForbiddenCharacter)
));
assert!(matches!(
"branch:name".parse::<Branch>(),
Err(BranchError::ContainsForbiddenCharacter)
));
assert!(matches!(
"branch?name".parse::<Branch>(),
Err(BranchError::ContainsForbiddenCharacter)
));
assert!(matches!(
"branch*name".parse::<Branch>(),
Err(BranchError::ContainsForbiddenCharacter)
));
assert!(matches!(
"branch[name".parse::<Branch>(),
Err(BranchError::ContainsForbiddenCharacter)
));
assert!(matches!(
"branch\\name".parse::<Branch>(),
Err(BranchError::ContainsForbiddenCharacter)
));
}
#[test]
fn test_from_static_or_panic() {
let branch = Branch::from_static_or_panic("main");
assert_eq!(branch.as_str(), "main");
}
#[test]
fn test_display() {
let branch: Branch = "feature/test".parse().unwrap();
assert_eq!(format!("{branch}"), "feature/test");
}
#[test]
fn test_as_ref_os_str() {
use std::ffi::OsStr;
let branch: Branch = "main".parse().unwrap();
let os_str: &OsStr = branch.as_ref();
assert_eq!(os_str, "main");
}
}