feat(Scope): implement Scope and tests
This commit is contained in:
5
src/commit/types/mod.rs
Normal file
5
src/commit/types/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod commit_type;
|
||||
pub use commit_type::CommitType;
|
||||
|
||||
mod scope;
|
||||
pub use scope::{Scope, ScopeError};
|
||||
443
src/commit/types/scope.rs
Normal file
443
src/commit/types/scope.rs
Normal file
@@ -0,0 +1,443 @@
|
||||
use lazy_regex::regex_find;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[repr(transparent)]
|
||||
pub struct Scope(String);
|
||||
|
||||
impl Scope {
|
||||
/// Maximum allowed length for a scope
|
||||
pub const MAX_LENGTH: usize = 50;
|
||||
|
||||
/// Parse and validate a scope string
|
||||
///
|
||||
/// # Validation
|
||||
/// - Trims leading/trailing whitespace
|
||||
/// - Empty/whitespace-only input returns empty Scope
|
||||
/// - Validates character set
|
||||
/// - Validates maximum length (50 chars)
|
||||
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.len() > Self::MAX_LENGTH {
|
||||
return Err(ScopeError::TooLong {
|
||||
actual: value.len(),
|
||||
max: Self::MAX_LENGTH,
|
||||
});
|
||||
}
|
||||
match lazy_regex::regex_find!(r"[^a-zA-Z0-9_/-]", &value) {
|
||||
Some(val) => Err(ScopeError::InvalidCharacter(val.chars().next().unwrap())),
|
||||
None => Ok(Self(value)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an empty scope (convenience constructor)
|
||||
pub fn empty() -> Self {
|
||||
Self(String::new())
|
||||
}
|
||||
|
||||
/// Returns true if the scope is empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0 == String::new()
|
||||
}
|
||||
|
||||
/// Returns the inner string slice
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// Error type for Scope validation failures
|
||||
#[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 that valid alphanumeric scope is accepted
|
||||
#[test]
|
||||
fn valid_alphanumeric_scope_accepted() {
|
||||
let result = Scope::parse("cli");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().as_str(), "cli");
|
||||
}
|
||||
|
||||
/// Test that valid scope with uppercase letters is accepted
|
||||
#[test]
|
||||
fn valid_uppercase_scope_accepted() {
|
||||
let result = Scope::parse("CLI");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().as_str(), "CLI");
|
||||
}
|
||||
|
||||
/// Test that valid scope with mixed case is accepted
|
||||
#[test]
|
||||
fn valid_mixed_case_scope_accepted() {
|
||||
let result = Scope::parse("AuthModule");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().as_str(), "AuthModule");
|
||||
}
|
||||
|
||||
/// Test that valid scope with numbers is accepted
|
||||
#[test]
|
||||
fn valid_scope_with_numbers_accepted() {
|
||||
let result = Scope::parse("api2");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().as_str(), "api2");
|
||||
}
|
||||
|
||||
/// Test that valid scope with hyphens is accepted
|
||||
#[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 that valid scope with underscores is accepted
|
||||
#[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 that valid scope with slashes is accepted (Jira refs)
|
||||
#[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 another Jira-style scope with slashes
|
||||
#[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 scope with all allowed special characters combined
|
||||
#[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 that empty string returns valid empty Scope
|
||||
#[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 that whitespace-only input returns valid empty Scope
|
||||
#[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 that tabs-only input returns valid empty Scope
|
||||
#[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 that mixed whitespace returns valid empty Scope
|
||||
#[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 that leading whitespace is trimmed
|
||||
#[test]
|
||||
fn leading_whitespace_trimmed() {
|
||||
let result = Scope::parse(" cli");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().as_str(), "cli");
|
||||
}
|
||||
|
||||
/// Test that trailing whitespace is trimmed
|
||||
#[test]
|
||||
fn trailing_whitespace_trimmed() {
|
||||
let result = Scope::parse("cli ");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().as_str(), "cli");
|
||||
}
|
||||
|
||||
/// Test that both leading and trailing whitespace is trimmed
|
||||
#[test]
|
||||
fn leading_and_trailing_whitespace_trimmed() {
|
||||
let result = Scope::parse(" cli ");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().as_str(), "cli");
|
||||
}
|
||||
|
||||
/// Test that spaces within scope are rejected
|
||||
#[test]
|
||||
fn space_in_scope_rejected() {
|
||||
let result = Scope::parse("user auth");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter(' '));
|
||||
}
|
||||
|
||||
/// Test that dot is rejected
|
||||
#[test]
|
||||
fn dot_rejected() {
|
||||
let result = Scope::parse("user.auth");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('.'));
|
||||
}
|
||||
|
||||
/// Test that colon is rejected
|
||||
#[test]
|
||||
fn colon_rejected() {
|
||||
let result = Scope::parse("user:auth");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter(':'));
|
||||
}
|
||||
|
||||
/// Test that parentheses are rejected
|
||||
#[test]
|
||||
fn parentheses_rejected() {
|
||||
let result = Scope::parse("user(auth)");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('('));
|
||||
}
|
||||
|
||||
/// Test that exclamation mark is rejected
|
||||
#[test]
|
||||
fn exclamation_rejected() {
|
||||
let result = Scope::parse("breaking!");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('!'));
|
||||
}
|
||||
|
||||
/// Test that @ symbol is rejected
|
||||
#[test]
|
||||
fn at_symbol_rejected() {
|
||||
let result = Scope::parse("user@domain");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('@'));
|
||||
}
|
||||
|
||||
/// Test that hash is rejected
|
||||
#[test]
|
||||
fn hash_rejected() {
|
||||
let result = Scope::parse("issue#123");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('#'));
|
||||
}
|
||||
|
||||
/// Test that emoji is rejected
|
||||
#[test]
|
||||
fn emoji_rejected() {
|
||||
let result = Scope::parse("cli🚀");
|
||||
assert!(result.is_err());
|
||||
// The error should contain the emoji character
|
||||
match result.unwrap_err() {
|
||||
ScopeError::InvalidCharacter(c) => assert_eq!(c, '🚀'),
|
||||
_ => panic!("Expected InvalidCharacter error"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that first invalid character is reported
|
||||
#[test]
|
||||
fn first_invalid_character_reported() {
|
||||
let result = Scope::parse("a.b:c");
|
||||
assert!(result.is_err());
|
||||
// Should report the first invalid character (dot)
|
||||
assert_eq!(result.unwrap_err(), ScopeError::InvalidCharacter('.'));
|
||||
}
|
||||
|
||||
/// Test that exactly 50 characters is accepted (boundary)
|
||||
#[test]
|
||||
fn fifty_characters_accepted() {
|
||||
let scope_50 = "a".repeat(50);
|
||||
let result = Scope::parse(&scope_50);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().as_str().len(), 50);
|
||||
}
|
||||
|
||||
/// Test that 51 characters is rejected
|
||||
#[test]
|
||||
fn fifty_one_characters_rejected() {
|
||||
let scope_51 = "a".repeat(51);
|
||||
let result = Scope::parse(&scope_51);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
ScopeError::TooLong {
|
||||
actual: 51,
|
||||
max: 50
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that 100 characters is rejected
|
||||
#[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: 50
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that length is checked after trimming
|
||||
#[test]
|
||||
fn length_checked_after_trimming() {
|
||||
// 50 chars + leading/trailing spaces = should be valid after trim
|
||||
let scope_with_spaces = format!(" {} ", "a".repeat(50));
|
||||
let result = Scope::parse(&scope_with_spaces);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().as_str().len(), 50);
|
||||
}
|
||||
|
||||
/// Test MAX_LENGTH constant is 50
|
||||
#[test]
|
||||
fn max_length_constant_is_50() {
|
||||
assert_eq!(Scope::MAX_LENGTH, 50);
|
||||
}
|
||||
|
||||
/// Test that empty() creates an empty Scope
|
||||
#[test]
|
||||
fn empty_constructor_creates_empty_scope() {
|
||||
let scope = Scope::empty();
|
||||
assert!(scope.is_empty());
|
||||
assert_eq!(scope.as_str(), "");
|
||||
}
|
||||
|
||||
/// Test is_empty() returns true for empty scope
|
||||
#[test]
|
||||
fn is_empty_returns_true_for_empty() {
|
||||
let scope = Scope::parse("").unwrap();
|
||||
assert!(scope.is_empty());
|
||||
}
|
||||
|
||||
/// Test is_empty() returns false for non-empty scope
|
||||
#[test]
|
||||
fn is_empty_returns_false_for_non_empty() {
|
||||
let scope = Scope::parse("cli").unwrap();
|
||||
assert!(!scope.is_empty());
|
||||
}
|
||||
|
||||
/// Test as_str() returns inner string
|
||||
#[test]
|
||||
fn as_str_returns_inner_string() {
|
||||
let scope = Scope::parse("my-scope").unwrap();
|
||||
assert_eq!(scope.as_str(), "my-scope");
|
||||
}
|
||||
|
||||
/// Test Display trait implementation
|
||||
#[test]
|
||||
fn display_outputs_inner_string() {
|
||||
let scope = Scope::parse("cli").unwrap();
|
||||
assert_eq!(format!("{}", scope), "cli");
|
||||
}
|
||||
|
||||
/// Test Display for empty scope
|
||||
#[test]
|
||||
fn display_empty_scope() {
|
||||
let scope = Scope::empty();
|
||||
assert_eq!(format!("{}", scope), "");
|
||||
}
|
||||
|
||||
/// Test Clone trait
|
||||
#[test]
|
||||
fn scope_is_cloneable() {
|
||||
let original = Scope::parse("cli").unwrap();
|
||||
let cloned = original.clone();
|
||||
assert_eq!(original, cloned);
|
||||
}
|
||||
|
||||
/// Test PartialEq trait
|
||||
#[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 Debug trait
|
||||
#[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 AsRef<str> trait
|
||||
#[test]
|
||||
fn scope_as_ref_str() {
|
||||
let scope = Scope::parse("cli").unwrap();
|
||||
let s: &str = scope.as_ref();
|
||||
assert_eq!(s, "cli");
|
||||
}
|
||||
|
||||
/// Test ScopeError::InvalidCharacter displays correctly
|
||||
#[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 ScopeError::TooLong displays correctly
|
||||
#[test]
|
||||
fn too_long_error_display() {
|
||||
let err = ScopeError::TooLong {
|
||||
actual: 51,
|
||||
max: 50,
|
||||
};
|
||||
let msg = format!("{}", err);
|
||||
assert!(msg.contains("too long"));
|
||||
assert!(msg.contains("51"));
|
||||
assert!(msg.contains("50"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user