pub fn is_email(value: &str) -> bool {
if value.len() > 254 {
return false;
}
let Some((local, domain)) = value.split_once('@') else {
return false;
};
!domain.contains('@') && is_email_local(local) && is_domain(domain)
}
fn is_email_local(local: &str) -> bool {
if local.is_empty() || local.len() > 64 {
return false;
}
if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
return false;
}
local
.chars()
.all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+-/=?^_`{|}~.".contains(c))
}
fn is_domain(domain: &str) -> bool {
if domain.is_empty() || domain.len() > 253 {
return false;
}
let labels: Vec<&str> = domain.split('.').collect();
if labels.len() < 2 {
return false;
}
let well_formed = labels.iter().all(|label| {
!label.is_empty()
&& label.len() <= 63
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
});
let tld = labels[labels.len() - 1];
well_formed && tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic())
}
pub fn is_url(value: &str) -> bool {
if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
return false;
}
let Some((scheme, rest)) = value.split_once("://") else {
return false;
};
if !scheme.starts_with(|c: char| c.is_ascii_alphabetic())
|| !scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
{
return false;
}
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
let host = authority.rsplit_once('@').map_or(authority, |(_, host)| host);
let host = strip_port(host);
!host.is_empty()
&& host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | ':' | '[' | ']'))
}
fn strip_port(host: &str) -> &str {
if host.starts_with('[') {
return host;
}
match host.rsplit_once(':') {
Some((head, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => head,
_ => host,
}
}
pub fn is_date(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
return false;
}
let (Some(year), Some(month), Some(day)) =
(digits(&value[0..4]), digits(&value[5..7]), digits(&value[8..10]))
else {
return false;
};
(1..=12).contains(&month) && day >= 1 && day <= days_in_month(year, month)
}
fn digits(part: &str) -> Option<u32> {
if part.bytes().all(|b| b.is_ascii_digit()) { part.parse().ok() } else { None }
}
fn days_in_month(year: u32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 0,
}
}
fn is_leap_year(year: u32) -> bool {
year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
}
pub fn is_uuid(value: &str) -> bool {
let mut groups = value.split('-');
for length in [8, 4, 4, 4, 12] {
match groups.next() {
Some(group)
if group.len() == length && group.bytes().all(|b| b.is_ascii_hexdigit()) => {}
_ => return false,
}
}
groups.next().is_none()
}
pub fn is_alpha(value: &str) -> bool {
!value.is_empty() && value.chars().all(char::is_alphabetic)
}
pub fn is_alpha_num(value: &str) -> bool {
!value.is_empty() && value.chars().all(char::is_alphanumeric)
}
pub fn is_alpha_dash(value: &str) -> bool {
!value.is_empty() && value.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_ordinary_email_addresses() {
for address in ["ada@example.com", "a.b+tag@sub.example.co.uk", "x_1@a-b.io"] {
assert!(is_email(address), "{address} should be a valid email");
}
}
#[test]
fn rejects_email_addresses_people_actually_mistype() {
for address in [
"",
"ada",
"ada@",
"@example.com",
"ada@example",
"ada@@example.com",
"ada b@example.com",
"ada@example..com",
".ada@example.com",
"ada.@example.com",
"ada@-example.com",
"ada@example.c",
"ada@example.c0m",
] {
assert!(!is_email(address), "{address} should not be a valid email");
}
}
#[test]
fn an_over_long_address_is_rejected_without_scanning_it() {
let address = format!("{}@example.com", "a".repeat(300));
assert!(!is_email(&address));
}
#[test]
fn accepts_urls_with_a_scheme_and_a_host() {
for url in [
"https://example.com",
"http://example.com/path?q=1#top",
"https://user:pw@example.com:8443/x",
"ftp://files.example.org",
"http://localhost:3000",
"http://[::1]:8080/",
] {
assert!(is_url(url), "{url} should be a valid url");
}
}
#[test]
fn rejects_urls_without_a_scheme_or_host() {
for url in ["example.com", "https://", "://example.com", "1http://x.com", "http://ex ample.com"] {
assert!(!is_url(url), "{url} should not be a valid url");
}
}
#[test]
fn accepts_real_calendar_dates_and_rejects_impossible_ones() {
assert!(is_date("2024-02-29"));
assert!(is_date("1999-12-31"));
assert!(!is_date("2023-02-29"), "2023 is not a leap year");
assert!(!is_date("1900-02-29"), "a century that is not divisible by 400 is not a leap year");
assert!(!is_date("2024-13-01"));
assert!(!is_date("2024-04-31"));
assert!(!is_date("2024-1-1"), "the format is zero padded");
assert!(!is_date("24-01-01"));
assert!(!is_date("2024/01/01"));
assert!(!is_date("+024-01-01"));
}
#[test]
fn recognises_uuids_of_any_version() {
assert!(is_uuid("00000000-0000-0000-0000-000000000000"));
assert!(is_uuid("9f8b2c1a-4d3e-4f5a-8b7c-1d2e3f4a5b6c"));
assert!(is_uuid("9F8B2C1A-4D3E-4F5A-8B7C-1D2E3F4A5B6C"));
assert!(!is_uuid("9f8b2c1a4d3e4f5a8b7c1d2e3f4a5b6c"));
assert!(!is_uuid("9f8b2c1a-4d3e-4f5a-8b7c-1d2e3f4a5b6"));
assert!(!is_uuid("9f8b2c1a-4d3e-4f5a-8b7c-1d2e3f4a5b6c-extra"));
assert!(!is_uuid("zf8b2c1a-4d3e-4f5a-8b7c-1d2e3f4a5b6c"));
}
#[test]
fn alpha_families_agree_on_the_empty_string() {
assert!(!is_alpha(""));
assert!(!is_alpha_num(""));
assert!(!is_alpha_dash(""));
}
#[test]
fn alpha_families_widen_one_character_class_at_a_time() {
assert!(is_alpha("Zoë"));
assert!(!is_alpha("Zoe2"));
assert!(is_alpha_num("Zoe2"));
assert!(!is_alpha_num("zoe-2"));
assert!(is_alpha_dash("zoe-2_x"));
assert!(!is_alpha_dash("zoe 2"));
}
}