From aec5e87b498ee922f04d424276a493f4debac894 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Thu, 5 Feb 2026 22:34:22 +0100 Subject: [PATCH] feat(Scope): implement Scope and tests --- Cargo.lock | 31 ++ Cargo.toml | 1 + src/commit/{types.rs => types/commit_type.rs} | 0 src/commit/types/mod.rs | 5 + src/commit/types/scope.rs | 443 ++++++++++++++++++ 5 files changed, 480 insertions(+) rename src/commit/{types.rs => types/commit_type.rs} (100%) create mode 100644 src/commit/types/mod.rs create mode 100644 src/commit/types/scope.rs diff --git a/Cargo.lock b/Cargo.lock index e2788f7..3a18bea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -407,10 +407,35 @@ dependencies = [ "clap", "git-conventional", "inquire", + "lazy-regex", "predicates", "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]] name = "libc" version = "0.2.180" @@ -598,6 +623,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.9" diff --git a/Cargo.toml b/Cargo.toml index df3132d..2172abf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ assert_fs = "1.1.3" clap = { version = "4.5.57", features = ["derive"] } git-conventional = "0.12.9" inquire = "0.9.2" +lazy-regex = { version = "3.5.1", features = ["lite"] } predicates = "3.1.3" thiserror = "2.0.18" diff --git a/src/commit/types.rs b/src/commit/types/commit_type.rs similarity index 100% rename from src/commit/types.rs rename to src/commit/types/commit_type.rs diff --git a/src/commit/types/mod.rs b/src/commit/types/mod.rs new file mode 100644 index 0000000..b57e687 --- /dev/null +++ b/src/commit/types/mod.rs @@ -0,0 +1,5 @@ +mod commit_type; +pub use commit_type::CommitType; + +mod scope; +pub use scope::{Scope, ScopeError}; diff --git a/src/commit/types/scope.rs b/src/commit/types/scope.rs new file mode 100644 index 0000000..c24b9e4 --- /dev/null +++ b/src/commit/types/scope.rs @@ -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) -> Result { + 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 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 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")); + } +}