Skip to main content

icydb_model/base/validator/intl/
phone.rs

1//! Module: base::validator::intl::phone
2//!
3//! Responsibility: base validator definitions.
4//! Does not own: normalization policy, persistence, or schema mutation semantics.
5//! Boundary: reports typed visitor issues for facade schema values.
6
7use crate::{prelude::*, visitor::Validator};
8
9///
10/// E164PhoneNumber
11/// Ensures phone number has the canonical E.164 envelope:
12/// `+` followed by 7 to 15 ASCII digits, with a non-zero first digit.
13///
14
15#[validator]
16pub struct E164PhoneNumber;
17
18impl Validator<str> for E164PhoneNumber {
19    fn validate(&self, s: &str, ctx: &mut dyn VisitorContext) {
20        let Some(digits) = s.strip_prefix('+') else {
21            ctx.issue("phone number must start with +");
22            return;
23        };
24
25        if digits.is_empty() || !digits.chars().all(|ch| ch.is_ascii_digit()) {
26            ctx.issue("phone number must contain only digits after +");
27            return;
28        }
29
30        if digits.starts_with('0') {
31            ctx.issue("phone number country code must not start with 0");
32            return;
33        }
34
35        let digit_count = digits.len();
36
37        if !(7..=15).contains(&digit_count) {
38            ctx.issue(format!(
39                "phone number has {digit_count} digits; expected 7 to 15"
40            ));
41        }
42    }
43}
44
45///
46/// TESTS
47///
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52
53    struct TestCtx {
54        issues: crate::visitor::VisitorIssues,
55    }
56
57    impl TestCtx {
58        fn new() -> Self {
59            Self {
60                issues: crate::visitor::VisitorIssues::new(),
61            }
62        }
63
64        fn has_issues(&self) -> bool {
65            !self.issues.is_empty()
66        }
67    }
68
69    impl crate::visitor::VisitorContext for TestCtx {
70        fn add_issue(&mut self, issue: crate::visitor::Issue) {
71            self.issues.push(String::new(), issue);
72        }
73
74        fn add_issue_at(&mut self, _: crate::visitor::PathSegment, issue: crate::visitor::Issue) {
75            self.add_issue(issue);
76        }
77    }
78
79    #[test]
80    fn e164_phone_validator_accepts_canonical_phone_number() {
81        let validator = E164PhoneNumber;
82        let mut ctx = TestCtx::new();
83
84        validator.validate("+15551234567", &mut ctx);
85
86        assert!(!ctx.has_issues());
87    }
88
89    #[test]
90    fn e164_phone_validator_rejects_interleaved_non_digits() {
91        let validator = E164PhoneNumber;
92        let mut ctx = TestCtx::new();
93
94        validator.validate("+1 (555) 123-4567", &mut ctx);
95
96        assert!(ctx.has_issues());
97    }
98
99    #[test]
100    fn e164_phone_validator_rejects_zero_country_code_and_bad_lengths() {
101        for value in ["+05551234567", "+123456", "+1234567890123456"] {
102            let validator = E164PhoneNumber;
103            let mut ctx = TestCtx::new();
104
105            validator.validate(value, &mut ctx);
106
107            assert!(ctx.has_issues(), "{value} should be rejected");
108        }
109    }
110}