add initial crates and apps
This commit is contained in:
parent
5c87ba3519
commit
1ca300098f
113 changed files with 28169 additions and 0 deletions
8
crates/chattyness-shared/src/lib.rs
Normal file
8
crates/chattyness-shared/src/lib.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
//! Shared utilities for chattyness.
|
||||
//!
|
||||
//! This crate provides common validation functions and utilities
|
||||
//! used across the application.
|
||||
|
||||
pub mod validation;
|
||||
|
||||
pub use validation::*;
|
||||
298
crates/chattyness-shared/src/validation.rs
Normal file
298
crates/chattyness-shared/src/validation.rs
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
//! Shared validation utilities.
|
||||
//!
|
||||
//! This module provides common validation functions and pre-compiled regex patterns
|
||||
//! for validating user input consistently across the application.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use chattyness_error::AppError;
|
||||
use regex::Regex;
|
||||
|
||||
// =============================================================================
|
||||
// Pre-compiled Regex Patterns
|
||||
// =============================================================================
|
||||
|
||||
/// Slug pattern: 1-50 characters, lowercase alphanumeric and hyphens.
|
||||
/// Must start and end with alphanumeric (unless 1-2 chars).
|
||||
static SLUG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$|^[a-z0-9]{1,2}$").expect("Invalid slug regex")
|
||||
});
|
||||
|
||||
/// Hex color pattern: #RRGGBB or #RRGGBBAA format.
|
||||
static HEX_COLOR_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$").expect("Invalid hex color regex")
|
||||
});
|
||||
|
||||
/// Username pattern: 3-30 characters, starts with lowercase letter,
|
||||
/// contains only lowercase letters, numbers, and underscores.
|
||||
static USERNAME_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9_]{2,29}$").expect("Invalid username regex"));
|
||||
|
||||
/// Email pattern: basic check for @ symbol with characters before and after.
|
||||
static EMAIL_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").expect("Invalid email regex"));
|
||||
|
||||
// =============================================================================
|
||||
// Validation Functions
|
||||
// =============================================================================
|
||||
|
||||
/// Validate a slug (URL-friendly identifier).
|
||||
///
|
||||
/// # Rules
|
||||
/// - 1-50 characters
|
||||
/// - Lowercase alphanumeric and hyphens only
|
||||
/// - Must start and end with alphanumeric (except for 1-2 character slugs)
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(())` if valid
|
||||
/// - `Err(AppError::Validation)` if invalid
|
||||
pub fn validate_slug(slug: &str) -> Result<(), AppError> {
|
||||
if !SLUG_REGEX.is_match(slug) {
|
||||
return Err(AppError::Validation(
|
||||
"Slug must be 1-50 characters, lowercase alphanumeric and hyphens only".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a slug is valid without returning an error.
|
||||
pub fn is_valid_slug(slug: &str) -> bool {
|
||||
SLUG_REGEX.is_match(slug)
|
||||
}
|
||||
|
||||
/// Validate a hex color string.
|
||||
///
|
||||
/// # Rules
|
||||
/// - Must match #RRGGBB or #RRGGBBAA format
|
||||
/// - Case insensitive for hex digits
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(())` if valid
|
||||
/// - `Err(AppError::Validation)` if invalid
|
||||
pub fn validate_hex_color(color: &str) -> Result<(), AppError> {
|
||||
if !HEX_COLOR_REGEX.is_match(color) {
|
||||
return Err(AppError::Validation(
|
||||
"Color must be a valid hex color (#RRGGBB or #RRGGBBAA)".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a hex color is valid without returning an error.
|
||||
pub fn is_valid_hex_color(color: &str) -> bool {
|
||||
HEX_COLOR_REGEX.is_match(color)
|
||||
}
|
||||
|
||||
/// Validate an optional hex color string.
|
||||
///
|
||||
/// Returns `Ok(())` if the color is `None` or a valid hex color.
|
||||
pub fn validate_optional_hex_color(color: Option<&str>) -> Result<(), AppError> {
|
||||
if let Some(c) = color {
|
||||
validate_hex_color(c)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a username.
|
||||
///
|
||||
/// # Rules
|
||||
/// - 3-30 characters
|
||||
/// - Must start with a lowercase letter
|
||||
/// - Can contain lowercase letters, numbers, and underscores
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(())` if valid
|
||||
/// - `Err(AppError::Validation)` if invalid
|
||||
pub fn validate_username(username: &str) -> Result<(), AppError> {
|
||||
if !USERNAME_REGEX.is_match(username) {
|
||||
return Err(AppError::Validation(
|
||||
"Username must be 3-30 characters, start with a letter, and contain only lowercase letters, numbers, and underscores".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a username is valid without returning an error.
|
||||
pub fn is_valid_username(username: &str) -> bool {
|
||||
USERNAME_REGEX.is_match(username)
|
||||
}
|
||||
|
||||
/// Validate an email address.
|
||||
///
|
||||
/// # Rules
|
||||
/// - Must contain @ with characters before and after
|
||||
/// - Must have a domain with at least one dot
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(())` if valid
|
||||
/// - `Err(AppError::Validation)` if invalid
|
||||
pub fn validate_email(email: &str) -> Result<(), AppError> {
|
||||
let email = email.trim();
|
||||
if email.is_empty() || !EMAIL_REGEX.is_match(email) {
|
||||
return Err(AppError::Validation("Invalid email address".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if an email is valid without returning an error.
|
||||
pub fn is_valid_email(email: &str) -> bool {
|
||||
let email = email.trim();
|
||||
!email.is_empty() && EMAIL_REGEX.is_match(email)
|
||||
}
|
||||
|
||||
/// Validate that a string is not empty after trimming.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(())` if the string is non-empty
|
||||
/// - `Err(AppError::Validation)` with the provided field name if empty
|
||||
pub fn validate_non_empty(value: &str, field_name: &str) -> Result<(), AppError> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(AppError::Validation(format!(
|
||||
"{} cannot be empty",
|
||||
field_name
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that a string length is within bounds.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(())` if length is within bounds
|
||||
/// - `Err(AppError::Validation)` if too short or too long
|
||||
pub fn validate_length(value: &str, field_name: &str, min: usize, max: usize) -> Result<(), AppError> {
|
||||
let len = value.len();
|
||||
if len < min || len > max {
|
||||
return Err(AppError::Validation(format!(
|
||||
"{} must be {}-{} characters",
|
||||
field_name, min, max
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that a number is within a range.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(())` if the number is within the range
|
||||
/// - `Err(AppError::Validation)` if out of range
|
||||
pub fn validate_range<T: std::cmp::PartialOrd + std::fmt::Display>(
|
||||
value: T,
|
||||
field_name: &str,
|
||||
min: T,
|
||||
max: T,
|
||||
) -> Result<(), AppError> {
|
||||
if value < min || value > max {
|
||||
return Err(AppError::Validation(format!(
|
||||
"{} must be between {} and {}",
|
||||
field_name, min, max
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a password meets minimum requirements.
|
||||
///
|
||||
/// # Rules
|
||||
/// - At least 8 characters
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(())` if valid
|
||||
/// - `Err(AppError::Validation)` if too short
|
||||
pub fn validate_password(password: &str) -> Result<(), AppError> {
|
||||
if password.len() < 8 {
|
||||
return Err(AppError::Validation(
|
||||
"Password must be at least 8 characters".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that two passwords match.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(())` if passwords match
|
||||
/// - `Err(AppError::Validation)` if they don't match
|
||||
pub fn validate_passwords_match(password: &str, confirm: &str) -> Result<(), AppError> {
|
||||
if password != confirm {
|
||||
return Err(AppError::Validation("Passwords do not match".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_slug() {
|
||||
// Valid slugs
|
||||
assert!(validate_slug("a").is_ok());
|
||||
assert!(validate_slug("ab").is_ok());
|
||||
assert!(validate_slug("abc").is_ok());
|
||||
assert!(validate_slug("my-realm").is_ok());
|
||||
assert!(validate_slug("realm123").is_ok());
|
||||
assert!(validate_slug("my-awesome-realm-2024").is_ok());
|
||||
|
||||
// Invalid slugs
|
||||
assert!(validate_slug("").is_err());
|
||||
assert!(validate_slug("-starts-with-dash").is_err());
|
||||
assert!(validate_slug("ends-with-dash-").is_err());
|
||||
assert!(validate_slug("HAS-UPPERCASE").is_err());
|
||||
assert!(validate_slug("has spaces").is_err());
|
||||
assert!(validate_slug("has_underscore").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_hex_color() {
|
||||
// Valid colors
|
||||
assert!(validate_hex_color("#ffffff").is_ok());
|
||||
assert!(validate_hex_color("#FFFFFF").is_ok());
|
||||
assert!(validate_hex_color("#000000").is_ok());
|
||||
assert!(validate_hex_color("#ff5733").is_ok());
|
||||
assert!(validate_hex_color("#FF573380").is_ok()); // With alpha
|
||||
|
||||
// Invalid colors
|
||||
assert!(validate_hex_color("").is_err());
|
||||
assert!(validate_hex_color("ffffff").is_err()); // Missing #
|
||||
assert!(validate_hex_color("#fff").is_err()); // Too short
|
||||
assert!(validate_hex_color("#fffffff").is_err()); // 7 chars
|
||||
assert!(validate_hex_color("#gggggg").is_err()); // Invalid hex
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_username() {
|
||||
// Valid usernames
|
||||
assert!(validate_username("abc").is_ok());
|
||||
assert!(validate_username("user123").is_ok());
|
||||
assert!(validate_username("my_user_name").is_ok());
|
||||
|
||||
// Invalid usernames
|
||||
assert!(validate_username("ab").is_err()); // Too short
|
||||
assert!(validate_username("123abc").is_err()); // Starts with number
|
||||
assert!(validate_username("_user").is_err()); // Starts with underscore
|
||||
assert!(validate_username("User").is_err()); // Uppercase
|
||||
assert!(validate_username("user-name").is_err()); // Has hyphen
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_email() {
|
||||
// Valid emails
|
||||
assert!(validate_email("user@example.com").is_ok());
|
||||
assert!(validate_email("test.user@domain.org").is_ok());
|
||||
|
||||
// Invalid emails
|
||||
assert!(validate_email("").is_err());
|
||||
assert!(validate_email("noatsign").is_err());
|
||||
assert!(validate_email("@nodomain").is_err());
|
||||
assert!(validate_email("no@dotcom").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password() {
|
||||
assert!(validate_password("12345678").is_ok());
|
||||
assert!(validate_password("longpassword123!").is_ok());
|
||||
assert!(validate_password("1234567").is_err()); // Too short
|
||||
assert!(validate_password("").is_err());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue