#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchContext {
branch_id: Option<String>,
}
impl BranchContext {
pub const MAX_NAME_LEN: usize = 64;
pub fn main() -> Self {
Self { branch_id: None }
}
pub fn branch(name: &str) -> Self {
assert!(
Self::is_valid_name(name),
"Invalid branch name: '{}'. Must be 1-{} chars, alphanumeric/hyphen/underscore/dot only.",
name,
Self::MAX_NAME_LEN
);
Self {
branch_id: Some(name.to_string()),
}
}
pub fn try_branch(name: &str) -> Option<Self> {
if Self::is_valid_name(name) {
Some(Self {
branch_id: Some(name.to_string()),
})
} else {
None
}
}
pub fn parse_header(value: Option<&str>) -> Result<Self, String> {
match value {
None => Ok(Self::main()),
Some(name) if name.is_empty() || name.eq_ignore_ascii_case("main") => Ok(Self::main()),
Some(name) if Self::is_valid_name(name) => Ok(Self {
branch_id: Some(name.to_string()),
}),
Some(name) => Err(format!(
"Invalid branch name '{}'. Use 1-{} ASCII alphanumeric/._- characters",
name,
Self::MAX_NAME_LEN
)),
}
}
pub fn from_header(value: Option<&str>) -> Self {
Self::parse_header(value).unwrap_or_else(|_| Self::main())
}
pub fn is_valid_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= Self::MAX_NAME_LEN
&& !name.starts_with('.')
&& !name.starts_with('-')
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
pub fn is_main(&self) -> bool {
self.branch_id.is_none()
}
pub fn has_branch(&self) -> bool {
self.branch_id.is_some()
}
pub fn branch_name(&self) -> Option<&str> {
self.branch_id.as_deref()
}
}
impl std::fmt::Display for BranchContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.branch_id {
Some(name) => write!(f, "BranchContext({})", name),
None => write!(f, "BranchContext(main)"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_main_branch() {
let ctx = BranchContext::main();
assert!(ctx.is_main());
assert!(!ctx.has_branch());
assert_eq!(ctx.branch_name(), None);
}
#[test]
fn test_named_branch() {
let ctx = BranchContext::branch("feature-auth");
assert!(!ctx.is_main());
assert!(ctx.has_branch());
assert_eq!(ctx.branch_name(), Some("feature-auth"));
}
#[test]
fn test_from_header() {
assert!(BranchContext::from_header(None).is_main());
assert!(BranchContext::from_header(Some("")).is_main());
assert!(BranchContext::from_header(Some("main")).is_main());
assert!(BranchContext::from_header(Some("MAIN")).is_main());
assert_eq!(
BranchContext::from_header(Some("feat-1")).branch_name(),
Some("feat-1")
);
}
#[test]
fn test_parse_header_strict_rejects_invalid() {
assert!(BranchContext::parse_header(Some("feat-1")).is_ok());
assert!(BranchContext::parse_header(Some("main")).is_ok());
assert!(BranchContext::parse_header(Some("MAIN")).is_ok());
assert!(BranchContext::parse_header(None).is_ok());
assert!(BranchContext::parse_header(Some("bad name")).is_err());
assert!(BranchContext::parse_header(Some("🚀")).is_err());
}
#[test]
fn test_display() {
assert_eq!(BranchContext::main().to_string(), "BranchContext(main)");
assert_eq!(
BranchContext::branch("dev").to_string(),
"BranchContext(dev)"
);
}
#[test]
fn test_equality() {
assert_eq!(BranchContext::main(), BranchContext::main());
assert_eq!(BranchContext::branch("a"), BranchContext::branch("a"));
assert_ne!(BranchContext::main(), BranchContext::branch("a"));
}
#[test]
fn test_valid_branch_names() {
assert!(BranchContext::is_valid_name("feature-auth"));
assert!(BranchContext::is_valid_name("dev"));
assert!(BranchContext::is_valid_name("release.1.0"));
assert!(BranchContext::is_valid_name("my_branch_2"));
assert!(BranchContext::is_valid_name("a")); }
#[test]
fn test_invalid_branch_names() {
assert!(!BranchContext::is_valid_name("")); assert!(!BranchContext::is_valid_name(".hidden")); assert!(!BranchContext::is_valid_name("-flag")); assert!(!BranchContext::is_valid_name("has space")); assert!(!BranchContext::is_valid_name("has;semicolon")); assert!(!BranchContext::is_valid_name("it's bad")); assert!(!BranchContext::is_valid_name("a/b")); assert!(!BranchContext::is_valid_name(&"x".repeat(65))); }
#[test]
fn test_try_branch() {
assert!(BranchContext::try_branch("valid-name").is_some());
assert!(BranchContext::try_branch("has;injection").is_none());
assert!(BranchContext::try_branch("").is_none());
}
#[test]
fn test_from_header_rejects_invalid() {
assert!(BranchContext::from_header(Some("has;semicolon")).is_main());
assert!(BranchContext::from_header(Some(".hidden")).is_main());
assert!(BranchContext::from_header(Some("' OR 1=1 --")).is_main());
assert_eq!(
BranchContext::from_header(Some("feat-1")).branch_name(),
Some("feat-1")
);
}
#[test]
#[should_panic(expected = "Invalid branch name")]
fn test_branch_panics_on_invalid() {
let _ = BranchContext::branch("'; DROP TABLE users; --");
}
}