use regex::Regex;
use std::sync::OnceLock;
pub struct Valid;
impl Valid {
fn email_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap())
}
fn mobile_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^(1[3-9]\d{9})|(0\d{2,3}-\d{7,8})$").unwrap())
}
fn phone_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^\+?[1-9]\d{1,14}$").unwrap())
}
fn url_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)$").unwrap())
}
fn username_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[a-zA-Z0-9_.-]{3,50}$").unwrap())
}
fn color_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^#[0-9A-Fa-f]{6}$").unwrap())
}
fn id_card_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^\d{17}[\dXx]$").unwrap())
}
fn html_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"<[^>]*>").unwrap())
}
pub fn is_email(s: &str) -> bool { Self::email_re().is_match(s) }
pub fn is_mobile(s: &str) -> bool { Self::mobile_re().is_match(s) }
pub fn is_phone(s: &str) -> bool { Self::phone_re().is_match(s) }
pub fn is_url(s: &str) -> bool { Self::url_re().is_match(s) }
pub fn is_username(s: &str) -> bool { Self::username_re().is_match(s) }
pub fn is_color(s: &str) -> bool { Self::color_re().is_match(s) }
pub fn is_digits(s: &str) -> bool { !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) }
pub fn is_alphanumeric(s: &str) -> bool { !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()) }
pub fn len_between(s: &str, min: usize, max: usize) -> bool {
let len = s.chars().count();
len >= min && len <= max
}
pub fn is_strong_password(s: &str) -> bool {
if s.len() < 8 { return false; }
let has_lower = s.chars().any(|c| c.is_ascii_lowercase());
let has_upper = s.chars().any(|c| c.is_ascii_uppercase());
let has_digit = s.chars().any(|c| c.is_ascii_digit());
let has_special = s.chars().any(|c| "!@#$%^&*()_+-=[]{}|;:,.<>?".contains(c));
has_lower && has_upper && has_digit && has_special
}
pub fn is_ipv4(s: &str) -> bool {
s.parse::<std::net::Ipv4Addr>().is_ok()
}
pub fn is_base64(s: &str) -> bool {
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s).is_ok() && s.len() % 4 == 0
}
pub fn is_uuid(s: &str) -> bool {
uuid::Uuid::parse_str(s).is_ok()
}
pub fn is_id_card(s: &str) -> bool {
if !Self::id_card_re().is_match(s) {
return false;
}
Self::validate_id_card_check_digit(s)
}
fn validate_id_card_check_digit(id_card: &str) -> bool {
if id_card.len() != 18 {
return false;
}
let weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
let check_digits = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
let mut sum = 0u32;
for (i, ch) in id_card.chars().take(17).enumerate() {
match ch.to_digit(10) {
Some(digit) => sum += digit * weights[i],
None => return false,
}
}
let expected = check_digits[(sum % 11) as usize];
let actual = id_card.chars().last().unwrap_or(' ');
expected == actual.to_ascii_uppercase()
}
pub fn is_date(s: &str) -> bool {
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok()
}
pub fn is_datetime(s: &str) -> bool {
chrono::DateTime::parse_from_rfc3339(s).is_ok()
}
pub fn is_json(s: &str) -> bool {
serde_json::from_str::<serde_json::Value>(s).is_ok()
}
pub fn has_html(s: &str) -> bool {
Self::html_re().is_match(s)
}
pub fn is_html_free(s: &str) -> bool {
!Self::has_html(s)
}
pub fn is_file_extension(filename: &str, allowed: &[&str]) -> bool {
if let Some(ext) = std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())
{
let ext_lower = ext.to_lowercase();
return allowed.iter().any(|a| a.to_lowercase() == ext_lower);
}
false
}
}
#[cfg(feature = "validator")]
impl Valid {
pub fn format_validation_errors(errors: &validator::ValidationErrors) -> String {
let mut messages = Vec::new();
for (field, field_errors) in errors.field_errors() {
for error in field_errors {
if let Some(msg) = &error.message {
messages.push(format!("{}: {}", field, msg));
} else {
messages.push(format!("{}: {}", field, error.code));
}
}
}
messages.join("; ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_email() {
assert!(Valid::is_email("a@b.com"));
assert!(!Valid::is_email("not-email"));
}
#[test]
fn test_mobile() {
assert!(Valid::is_mobile("13812345678"));
assert!(Valid::is_mobile("010-12345678"));
assert!(Valid::is_mobile("021-87654321"));
assert!(Valid::is_mobile("0755-12345678"));
assert!(!Valid::is_mobile("1234"));
}
#[test]
fn test_phone() {
assert!(Valid::is_phone("+8613812345678"));
assert!(!Valid::is_phone("not-phone"));
}
#[test]
fn test_username() {
assert!(Valid::is_username("john_doe"));
assert!(Valid::is_username("user.name-123"));
assert!(!Valid::is_username("ab")); }
#[test]
fn test_color() {
assert!(Valid::is_color("#FF00AA"));
assert!(Valid::is_color("#000000"));
assert!(!Valid::is_color("FF00AA")); assert!(!Valid::is_color("#FF00A")); }
#[test]
fn test_uuid() {
assert!(Valid::is_uuid("550e8400-e29b-41d4-a716-446655440000"));
assert!(Valid::is_uuid("00000000-0000-0000-0000-000000000000"));
assert!(!Valid::is_uuid("not-a-uuid"));
}
#[test]
fn test_id_card() {
assert!(!Valid::is_id_card("1234")); assert!(!Valid::is_id_card("110101199003076790")); }
#[test]
fn test_date() {
assert!(Valid::is_date("2024-01-01"));
assert!(!Valid::is_date("2024-13-01")); assert!(!Valid::is_date("not-a-date"));
}
#[test]
fn test_datetime() {
assert!(Valid::is_datetime("2024-01-01T00:00:00Z"));
assert!(Valid::is_datetime("2024-01-01T00:00:00+08:00"));
assert!(!Valid::is_datetime("not-a-datetime"));
}
#[test]
fn test_json() {
assert!(Valid::is_json(r#"{"key": "value"}"#));
assert!(Valid::is_json(r#"[1, 2, 3]"#));
assert!(!Valid::is_json("not-json"));
}
#[test]
fn test_html() {
assert!(Valid::has_html("<div>hello</div>"));
assert!(Valid::has_html("<script>alert(1)</script>"));
assert!(!Valid::has_html("plain text"));
assert!(Valid::is_html_free("plain text"));
}
#[test]
fn test_file_extension() {
let allowed = &["jpg", "png", "gif"];
assert!(Valid::is_file_extension("photo.jpg", allowed));
assert!(Valid::is_file_extension("photo.JPG", allowed));
assert!(!Valid::is_file_extension("photo.pdf", allowed));
}
#[test]
fn test_password() {
assert!(Valid::is_strong_password("Abcdefg1!"));
assert!(!Valid::is_strong_password("123456")); assert!(!Valid::is_strong_password("Abcdefg1")); }
#[test]
fn test_url() {
assert!(Valid::is_url("https://example.com/path"));
assert!(!Valid::is_url("not-a-url"));
}
#[test]
fn test_ipv4() {
assert!(Valid::is_ipv4("127.0.0.1"));
assert!(!Valid::is_ipv4("not-ip"));
}
#[test]
fn test_base64() {
assert!(Valid::is_base64("SGVsbG8="));
assert!(!Valid::is_base64("not-base64!"));
}
}