Skip to main content

rustio_admin/admin/
validation.rs

1//! Format validators for the `email` / `phone` field types.
2//!
3//! These back the `#[rustio(format = "email" | "phone")]` field
4//! attribute the scaffolder emits: at rest both columns are plain
5//! `TEXT`, so the only thing that distinguishes them from a `str`
6//! field is the widget (`<input type="email">` / `"tel"`) and the
7//! check applied in the derived `from_form`.
8//!
9//! The checks are deliberately **structural, not exhaustive** — full
10//! RFC 5322 e-mail and E.164 phone validation are rabbit holes that
11//! reject legitimate values. The goal is to catch obvious typos
12//! (`alice` instead of `alice@example.com`, `call me` instead of a
13//! number), not to be a spec-complete parser. Projects that need
14//! stricter rules override `from_form` or add a `ModelAdmin`
15//! validation hook.
16
17/// `true` when `s` looks like an e-mail address: a non-empty local
18/// part, a single `@`, and a domain that contains a dot and doesn't
19/// start or end with one. Whitespace is rejected. This is a typo
20/// guard, not RFC 5322.
21pub fn is_valid_email(s: &str) -> bool {
22    if s.chars().any(char::is_whitespace) {
23        return false;
24    }
25    let mut parts = s.split('@');
26    let (Some(local), Some(domain), None) = (parts.next(), parts.next(), parts.next()) else {
27        // Zero or more than one `@`.
28        return false;
29    };
30    if local.is_empty() || domain.is_empty() {
31        return false;
32    }
33    domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
34}
35
36/// `true` when `s` looks like a phone number: between 7 and 15 digits
37/// (the E.164 ceiling), with only digits and the common separators
38/// `+ - ( ) space .` in between. This is a typo guard, not E.164.
39pub fn is_valid_phone(s: &str) -> bool {
40    if !s
41        .chars()
42        .all(|c| c.is_ascii_digit() || matches!(c, '+' | '-' | '(' | ')' | ' ' | '.'))
43    {
44        return false;
45    }
46    let digits = s.chars().filter(char::is_ascii_digit).count();
47    (7..=15).contains(&digits)
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn email_accepts_typical_addresses() {
56        assert!(is_valid_email("alice@example.com"));
57        assert!(is_valid_email("a.b+tag@sub.example.co.uk"));
58    }
59
60    #[test]
61    fn email_rejects_obvious_typos() {
62        assert!(!is_valid_email("alice"));
63        assert!(!is_valid_email("alice@"));
64        assert!(!is_valid_email("@example.com"));
65        assert!(!is_valid_email("alice@example"));
66        assert!(!is_valid_email("a@b@c.com"));
67        assert!(!is_valid_email("alice @example.com"));
68        assert!(!is_valid_email("alice@.com"));
69        assert!(!is_valid_email("alice@example."));
70        assert!(!is_valid_email(""));
71    }
72
73    #[test]
74    fn phone_accepts_common_shapes() {
75        assert!(is_valid_phone("+1 (555) 123-4567"));
76        assert!(is_valid_phone("0701234567"));
77        assert!(is_valid_phone("555.123.4567"));
78    }
79
80    #[test]
81    fn phone_rejects_letters_and_bad_lengths() {
82        assert!(!is_valid_phone("call me"));
83        assert!(!is_valid_phone("123456")); // 6 digits, below floor
84        assert!(!is_valid_phone("1234567890123456")); // 16 digits, above ceiling
85        assert!(!is_valid_phone(""));
86    }
87}