feat: foundational domain types #1
31
Cargo.lock
generated
31
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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
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