feat(Scope): implement Scope and tests

This commit is contained in:
2026-02-05 22:34:22 +01:00
parent d60486a0be
commit aec5e87b49
5 changed files with 480 additions and 0 deletions

31
Cargo.lock generated
View File

@@ -407,10 +407,35 @@ dependencies = [
"clap", "clap",
"git-conventional", "git-conventional",
"inquire", "inquire",
"lazy-regex",
"predicates", "predicates",
"thiserror", "thiserror",
] ]
[[package]]
name = "lazy-regex"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5c13b6857ade4c8ee05c3c3dc97d2ab5415d691213825b90d3211c425c1f907"
dependencies = [
"lazy-regex-proc_macros",
"once_cell",
"regex",
"regex-lite",
]
[[package]]
name = "lazy-regex-proc_macros"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a95c68db5d41694cea563c86a4ba4dc02141c16ef64814108cb23def4d5438"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.180" version = "0.2.180"
@@ -598,6 +623,12 @@ dependencies = [
"regex-syntax", "regex-syntax",
] ]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.8.9" version = "0.8.9"

View File

@@ -21,6 +21,7 @@ assert_fs = "1.1.3"
clap = { version = "4.5.57", features = ["derive"] } clap = { version = "4.5.57", features = ["derive"] }
git-conventional = "0.12.9" git-conventional = "0.12.9"
inquire = "0.9.2" inquire = "0.9.2"
lazy-regex = { version = "3.5.1", features = ["lite"] }
predicates = "3.1.3" predicates = "3.1.3"
thiserror = "2.0.18" thiserror = "2.0.18"

5
src/commit/types/mod.rs Normal file
View 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
View 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"));
}
}