#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(transparent)]
pub struct Scope(String);
impl Scope {
pub const MAX_LENGTH: usize = 30;
pub fn parse(value: impl Into<String>) -> Result<Self, ScopeError> {
let value: String = value.into().trim().to_owned();
if value.is_empty() {
return Ok(Self::empty());
}
if value.chars().count() > Self::MAX_LENGTH {
return Err(ScopeError::TooLong {
actual: value.chars().count(),
max: Self::MAX_LENGTH,
});
}
match lazy_regex::regex_find!(r"[^-a-zA-Z0-9_/]", &value) {
None => Ok(Self(value)),
Some(val) => val
.chars()
.next()
.map(ScopeError::InvalidCharacter)
.map(Err)
.unwrap_or_else(|| unreachable!("regex match is always non-empty")),
}
}
pub fn empty() -> Self {
Self(String::new())
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn header_segment(&self) -> String {
if self.is_empty() {
"".into()
} else {
format!("({self})")
}
}
pub fn header_segment_len(&self) -> usize {
if self.is_empty() {
0
} else {
self.0.chars().count() + 2
}
}
}
impl std::fmt::Display for Scope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<str> for Scope {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ScopeError {
#[error("Invalid character '{0}' in scope (allowed: a-z, A-Z, 0-9, -, _, /)")]
InvalidCharacter(char),
#[error("Scope too long ({actual} characters, maximum is {max})")]
TooLong { actual: usize, max: usize },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_alphanumeric_scope_accepted() {
let result = Scope::parse("cli");
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "cli");
}
#[test]
fn valid_uppercase_scope_accepted() {
let result = Scope::parse("CLI");
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "CLI");
}
#[test]
fn valid_mixed_case_scope_accepted() {
let result = Scope::parse("AuthModule");
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "AuthModule");
}
#[test]
fn valid_scope_with_numbers_accepted() {
let result = Scope::parse("api2");
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "api2");
}
#[test]
fn valid_scope_with_hyphens_accepted() {
let result = Scope::parse("user-auth");
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "user-auth");
}
#[test]
fn valid_scope_with_underscores_accepted() {
let result = Scope::parse("user_auth");
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "user_auth");
}
#[test]
fn valid_scope_with_slashes_accepted() {
let result = Scope::parse("PROJ-123/feature");
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "PROJ-123/feature");
}
#[test]
fn valid_jira_style_scope_accepted() {
let result = Scope::parse("TEAM-456/bugfix");
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "TEAM-456/bugfix");
}
#[test]
fn valid_scope_with_all_special_chars() {
let result = Scope::parse("my-scope_v2/test");
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "my-scope_v2/test");
}
#[test]
fn empty_string_returns_valid_empty_scope() {
let result = Scope::parse("");
assert!(result.is_ok());
let scope = result.unwrap();
assert!(scope.is_empty());
assert_eq!(scope.as_str(), "");
}
#[test]
fn whitespace_only_returns_valid_empty_scope() {
let result = Scope::parse(" ");
assert!(result.is_ok());
let scope = result.unwrap();
assert!(scope.is_empty());
assert_eq!(scope.as_str(), "");
}
#[test]
fn tabs_only_returns_valid_empty_scope() {
let result = Scope::parse("\t\t");
assert!(result.is_ok());
let scope = result.unwrap();
assert!(scope.is_empty());
}
#[test]
fn mixed_whitespace_returns_valid_empty_scope() {
let result = Scope::parse(" \t \n ");
assert!(result.is_ok());
let scope = result.unwrap();
assert!(scope.is_empty());
}
#[test]
fn leading_whitespace_trimmed() {
let result = Scope::parse(" cli");
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "cli");
}
#[test]
fn trailing_whitespace_trimmed() {
let result = Scope::parse("cli ");
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "cli");
}
#[test]
fn leading_and_trailing_whitespace_trimmed() {
let result = Scope::parse(" cli ");
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str(), "cli");
}
#[test]
fn space_in_scope_rejected() {
let result = Scope::parse("user auth");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter(' '));
}
#[test]
fn dot_rejected() {
let result = Scope::parse("user.auth");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('.'));
}
#[test]
fn colon_rejected() {
let result = Scope::parse("user:auth");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter(':'));
}
#[test]
fn parentheses_rejected() {
let result = Scope::parse("user(auth)");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('('));
}
#[test]
fn exclamation_rejected() {
let result = Scope::parse("breaking!");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('!'));
}
#[test]
fn at_symbol_rejected() {
let result = Scope::parse("user@domain");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('@'));
}
#[test]
fn hash_rejected() {
let result = Scope::parse("issue#123");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('#'));
}
#[test]
fn emoji_rejected() {
let result = Scope::parse("cli🚀");
assert!(result.is_err());
match result.unwrap_err() {
ScopeError::InvalidCharacter(c) => assert_eq!(c, '🚀'),
_ => panic!("Expected InvalidCharacter error"),
}
}
#[test]
fn first_invalid_character_reported() {
let result = Scope::parse("a.b:c");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('.'));
}
#[test]
fn thirty_characters_accepted() {
let scope_30 = "a".repeat(30);
let result = Scope::parse(&scope_30);
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str().len(), 30);
}
#[test]
fn thirty_one_characters_rejected() {
let scope_31 = "a".repeat(31);
let result = Scope::parse(&scope_31);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
ScopeError::TooLong {
actual: 31,
max: 30
}
);
}
#[test]
fn hundred_characters_rejected() {
let scope_100 = "a".repeat(100);
let result = Scope::parse(&scope_100);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
ScopeError::TooLong {
actual: 100,
max: 30
}
);
}
#[test]
fn length_checked_after_trimming() {
let scope_with_spaces = format!(" {} ", "a".repeat(30));
let result = Scope::parse(&scope_with_spaces);
assert!(result.is_ok());
assert_eq!(result.unwrap().as_str().len(), 30);
}
#[test]
fn max_length_constant_is_30() {
assert_eq!(Scope::MAX_LENGTH, 30);
}
#[test]
fn empty_constructor_creates_empty_scope() {
let scope = Scope::empty();
assert!(scope.is_empty());
assert_eq!(scope.as_str(), "");
}
#[test]
fn is_empty_returns_true_for_empty() {
let scope = Scope::parse("").unwrap();
assert!(scope.is_empty());
}
#[test]
fn is_empty_returns_false_for_non_empty() {
let scope = Scope::parse("cli").unwrap();
assert!(!scope.is_empty());
}
#[test]
fn as_str_returns_inner_string() {
let scope = Scope::parse("my-scope").unwrap();
assert_eq!(scope.as_str(), "my-scope");
}
#[test]
fn display_outputs_inner_string() {
let scope = Scope::parse("cli").unwrap();
assert_eq!(format!("{}", scope), "cli");
}
#[test]
fn display_empty_scope() {
let scope = Scope::empty();
assert_eq!(format!("{}", scope), "");
}
#[test]
fn scope_is_cloneable() {
let original = Scope::parse("cli").unwrap();
let cloned = original.clone();
assert_eq!(original, cloned);
}
#[test]
fn scope_equality() {
let scope1 = Scope::parse("cli").unwrap();
let scope2 = Scope::parse("cli").unwrap();
let scope3 = Scope::parse("api").unwrap();
assert_eq!(scope1, scope2);
assert_ne!(scope1, scope3);
}
#[test]
fn scope_has_debug() {
let scope = Scope::parse("cli").unwrap();
let debug_output = format!("{:?}", scope);
assert!(debug_output.contains("Scope"));
assert!(debug_output.contains("cli"));
}
#[test]
fn scope_as_ref_str() {
let scope = Scope::parse("cli").unwrap();
let s: &str = scope.as_ref();
assert_eq!(s, "cli");
}
#[test]
fn invalid_character_error_display() {
let err = ScopeError::InvalidCharacter('.');
let msg = format!("{}", err);
assert!(msg.contains("Invalid character"));
assert!(msg.contains("'.'"));
assert!(msg.contains("allowed: a-z, A-Z, 0-9, -, _, /"));
}
#[test]
fn too_long_error_display() {
let err = ScopeError::TooLong {
actual: 31,
max: 30,
};
let msg = format!("{}", err);
assert!(msg.contains("too long"));
assert!(msg.contains("31"));
assert!(msg.contains("30"));
}
#[test]
fn header_segment_empty_scope_returns_empty_string() {
assert_eq!(Scope::empty().header_segment(), "");
}
#[test]
fn header_segment_wraps_scope_in_parentheses() {
let scope = Scope::parse("auth").unwrap();
assert_eq!(scope.header_segment(), "(auth)");
}
#[test]
fn header_segment_various_scopes() {
assert_eq!(Scope::parse("cli").unwrap().header_segment(), "(cli)");
assert_eq!(
Scope::parse("user-auth").unwrap().header_segment(),
"(user-auth)"
);
assert_eq!(
Scope::parse("PROJ-123/feature").unwrap().header_segment(),
"(PROJ-123/feature)"
);
}
#[test]
fn header_segment_len_empty_scope_is_zero() {
assert_eq!(Scope::empty().header_segment_len(), 0);
}
#[test]
fn header_segment_len_includes_parentheses() {
let scope = Scope::parse("auth").unwrap();
assert_eq!(scope.header_segment_len(), 6);
}
#[test]
fn header_segment_len_equals_segment_chars_count() {
let values = ["cli", "user-auth", "PROJ-123/feature"];
for s in values {
let scope = Scope::parse(s).unwrap();
assert_eq!(
scope.header_segment_len(),
scope.header_segment().chars().count(),
"header_segment_len() should equal chars().count() for scope {:?}",
s
);
}
}
#[test]
fn length_limit_uses_char_count_not_byte_count() {
let input = "ñ".repeat(16);
assert_eq!(input.chars().count(), 16, "sanity: 16 chars");
assert_eq!(input.len(), 32, "sanity: 32 bytes");
let result = Scope::parse(&input);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
ScopeError::InvalidCharacter('ñ'),
"expected InvalidCharacter('ñ') for a 16-char / 32-byte input, not TooLong",
);
}
#[test]
fn too_long_error_actual_reports_char_count_not_byte_count() {
let input = "a".repeat(30) + "é";
assert_eq!(input.chars().count(), 31, "sanity: 31 chars");
assert_eq!(input.len(), 32, "sanity: 32 bytes");
let result = Scope::parse(&input);
assert_eq!(
result.unwrap_err(),
ScopeError::TooLong {
actual: 31,
max: 30
},
"actual should be the char count (31), not the byte count (32)",
);
}
}