add initial crates and apps

This commit is contained in:
Evan Carroll 2026-01-12 15:34:40 -06:00
parent 5c87ba3519
commit 1ca300098f
113 changed files with 28169 additions and 0 deletions

View file

@ -0,0 +1,9 @@
[package]
name = "chattyness-shared"
version.workspace = true
edition.workspace = true
[dependencies]
chattyness-error.workspace = true
regex.workspace = true
serde.workspace = true

View 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::*;

View 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());
}
}