Skip to main content

sofie_values/
phone_number.rs

1use std::fmt;
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use sofie_core::{Cast, ColumnReader, ColumnSpec, Error, Result, Show, Shown, SqlType, SqlValue};
5
6use crate::refusal::refusal;
7use crate::{Input, Present};
8use sofie_core::deserialize_parsed;
9
10/// Sorted for a binary search. Codes are prefix-free: `1` and `7` are the only one-digit codes and
11/// every unlisted prefix starts a three-digit code, so no table of every country is needed.
12const TWO_DIGIT_CODES: [u16; 44] = [
13    20, 27, 30, 31, 32, 33, 34, 36, 39, 40, 41, 43, 44, 45, 46, 47, 48, 49, 51, 52, 53, 54, 55, 56,
14    57, 58, 60, 61, 62, 63, 64, 65, 66, 81, 82, 84, 86, 90, 91, 92, 93, 94, 95, 98,
15];
16/// E.164 caps a number at 15 digits, its country code included.
17const MAXIMUM_DIGITS: usize = 15;
18const MINIMUM_NATIONAL_DIGITS: usize = 4;
19const SEPARATORS: [char; 5] = [' ', '-', '.', '(', ')'];
20/// Italy, San Marino, the Vatican, Côte d'Ivoire and the Republic of the Congo dial their leading
21/// zero from abroad too.
22const ZERO_KEPT: [u16; 5] = [39, 225, 242, 378, 379];
23
24/// Personal data: `Debug` masks it.
25#[derive(Clone, PartialEq, Eq, Hash)]
26pub struct PhoneNumber {
27    country_code: u16,
28    e164: String,
29}
30
31impl PhoneNumber {
32    pub fn try_new(country_code: u16, national: &str) -> Result<Self> {
33        let national = if ZERO_KEPT.contains(&country_code) {
34            national
35        } else {
36            without_trunk_zero(national)?
37        };
38        let code = country_code.to_string();
39        if code_length(&code) != Some(code.len()) {
40            return Err(refusal(
41                "phone",
42                "validation.phone.country_code",
43                "must start with a country calling code",
44            ));
45        }
46        if national.len() < MINIMUM_NATIONAL_DIGITS
47            || !national.bytes().all(|byte| byte.is_ascii_digit())
48        {
49            return Err(refusal(
50                "phone",
51                "validation.phone.too_short",
52                "must have a number of at least 4 digits after the country code",
53            ));
54        }
55        if code.len() + national.len() > MAXIMUM_DIGITS {
56            return Err(refusal(
57                "phone",
58                "validation.phone.too_long",
59                "must have at most 15 digits",
60            ));
61        }
62        Ok(Self {
63            country_code,
64            e164: format!("+{code}{national}"),
65        })
66    }
67
68    pub fn parse(text: &str) -> Result<Self> {
69        let digits: String = text.trim().replace(SEPARATORS, "");
70        let digits = digits.strip_prefix('+').ok_or_else(|| {
71            refusal(
72                "phone",
73                "validation.phone.plus",
74                "must start with + and the country code",
75            )
76        })?;
77        if !digits.bytes().all(|byte| byte.is_ascii_digit()) {
78            return Err(refusal(
79                "phone",
80                "validation.phone.characters",
81                "must have only digits after the +, apart from spaces, dots, hyphens and brackets",
82            ));
83        }
84        let length = code_length(digits)
85            .filter(|&length| digits.len() > length)
86            .ok_or_else(|| {
87                refusal(
88                    "phone",
89                    "validation.phone.country_code",
90                    "must start with a country calling code",
91                )
92            })?;
93        let (code, national) = digits.split_at(length);
94        let country_code = code.parse().expect("at most three digits fit a u16");
95        Self::try_new(country_code, national)
96    }
97
98    #[must_use]
99    pub fn country_code(&self) -> u16 {
100        self.country_code
101    }
102
103    #[must_use]
104    pub fn national_number(&self) -> &str {
105        let code_digits = self.country_code.ilog10() as usize + 1;
106        &self.e164[1 + code_digits..]
107    }
108
109    #[must_use]
110    pub fn e164(&self) -> &str {
111        &self.e164
112    }
113}
114
115fn without_trunk_zero(national: &str) -> Result<&str> {
116    let national = national.strip_prefix('0').unwrap_or(national);
117    if national.starts_with('0') {
118        return Err(refusal(
119            "phone",
120            "validation.phone.trunk_zero",
121            "must not start with 0 after the country code, apart from one trunk zero",
122        ));
123    }
124    Ok(national)
125}
126
127fn code_length(digits: &str) -> Option<usize> {
128    match digits.as_bytes().first()? {
129        b'1' | b'7' => return Some(1),
130        b'0' => return None,
131        _ => {}
132    }
133    let prefix = digits.get(..2)?.parse::<u16>().ok()?;
134    if TWO_DIGIT_CODES.binary_search(&prefix).is_ok() {
135        return Some(2);
136    }
137    Some(3)
138}
139
140impl fmt::Debug for PhoneNumber {
141    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
142        let tail = &self.e164[self.e164.len() - 2..];
143        write!(formatter, "PhoneNumber(+{} ***{tail})", self.country_code)
144    }
145}
146
147impl Cast for PhoneNumber {
148    const COLUMNS: &'static [ColumnSpec] = &[
149        ColumnSpec::new("_code", SqlType::Int).unordered(),
150        ColumnSpec::new("_number", SqlType::Text),
151    ];
152
153    fn encode(&self, out: &mut Vec<SqlValue>) {
154        out.push(SqlValue::Int(i64::from(self.country_code)));
155        out.push(SqlValue::Text(self.national_number().to_owned()));
156    }
157
158    fn decode(row: &mut dyn ColumnReader) -> Result<Self> {
159        let code = i64::decode(row)?;
160        let code = u16::try_from(code)
161            .map_err(|error| Error::internal("a stored country code is out of range", error))?;
162        Self::try_new(code, row.text()?)
163            .map_err(|error| Error::internal("a stored phone number is not valid", error))
164    }
165}
166
167impl Serialize for PhoneNumber {
168    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
169        serializer.serialize_str(&self.e164)
170    }
171}
172
173impl Show for PhoneNumber {
174    fn show(&self) -> Shown<'_> {
175        Shown::Text(&self.e164)
176    }
177}
178
179impl<'de> Deserialize<'de> for PhoneNumber {
180    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
181        deserialize_parsed(deserializer, "an international phone number", Self::parse)
182    }
183}
184
185impl Present for PhoneNumber {
186    const INPUT: Input = Input::typed(&[("type", "tel"), ("autocomplete", "tel")]);
187    const SEARCH: crate::Search = crate::Search::Exact;
188
189    fn csv(&self) -> String {
190        self.e164.clone()
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use sofie_core::RowReader;
197
198    use super::*;
199    use crate::testing::{assert_refused, round_trip};
200
201    fn phone(text: &str) -> PhoneNumber {
202        PhoneNumber::parse(text).unwrap()
203    }
204
205    #[test]
206    fn splits_the_country_code_by_its_prefix() {
207        for (text, code, national) in [
208            ("+1 (415) 555-0100", 1, "4155550100"),
209            ("+7 912 345 67 89", 7, "9123456789"),
210            ("+44 20 7946 0958", 44, "2079460958"),
211            ("+90 532 123 45 67", 90, "5321234567"),
212            ("+353 1 234 5678", 353, "12345678"),
213            ("+971 50 123 4567", 971, "501234567"),
214            ("+39 06 1234 5678", 39, "0612345678"),
215        ] {
216            let number = phone(text);
217            assert_eq!(
218                (number.country_code(), number.national_number()),
219                (code, national),
220                "{text}"
221            );
222        }
223    }
224
225    #[test]
226    fn drops_one_trunk_zero_after_the_country_code() {
227        assert_eq!(phone("+44 (0)20 7946 0958"), phone("+44 20 7946 0958"));
228        assert_eq!(phone("+49 (0) 30 123456").e164(), "+4930123456");
229        assert_eq!(phone("+90 (0532) 123 45 67").e164(), "+905321234567");
230        assert_eq!(phone("+90 0532 123 45 67").e164(), "+905321234567");
231        assert_eq!(
232            PhoneNumber::try_new(90, "05321234567").unwrap().e164(),
233            "+905321234567"
234        );
235        assert_eq!(phone("+44 20 7946 (0)958").e164(), "+442079460958");
236    }
237
238    #[test]
239    fn refuses_a_national_number_still_starting_with_zero_after_the_trunk_zero() {
240        assert_refused(
241            PhoneNumber::parse,
242            &["+44 00 2079 4609", "+90 (0)0532 123 45 67"],
243        );
244        assert!(PhoneNumber::try_new(44, "0020794609").is_err());
245    }
246
247    #[test]
248    fn keeps_the_leading_zero_where_it_is_part_of_the_number() {
249        assert_eq!(phone("+39 06 1234 5678").e164(), "+390612345678");
250        assert_eq!(phone("+378 0549 123456").e164(), "+3780549123456");
251        assert_eq!(phone("+379 06 6982 0000").e164(), "+3790669820000");
252        assert_eq!(phone("+225 07 0123 4567").e164(), "+2250701234567");
253        assert_eq!(phone("+242 06 123 4567").e164(), "+242061234567");
254    }
255
256    #[test]
257    fn refuses_what_is_not_an_international_number() {
258        assert_refused(
259            PhoneNumber::parse,
260            &[
261                "",
262                "05321234567",
263                "+",
264                "+0 123 4567",
265                "+90",
266                "+90 123",
267                "+90 532 abc 45 67",
268                "+90 5321 2345 6789 01",
269                "+1/415/555/0100",
270            ],
271        );
272    }
273
274    #[test]
275    fn refuses_a_second_plus_before_the_country_code() {
276        assert_refused(
277            PhoneNumber::parse,
278            &["++90 532 123 45 67", "+(+90) 532 123 45 67"],
279        );
280    }
281
282    #[test]
283    fn checks_a_code_given_apart_from_the_number() {
284        assert!(PhoneNumber::try_new(90, "5321234567").is_ok());
285        assert!(PhoneNumber::try_new(353, "12345678").is_ok());
286        for code in [0, 4, 445, 1000] {
287            assert!(PhoneNumber::try_new(code, "12345678").is_err(), "{code}");
288        }
289    }
290
291    #[test]
292    fn holds_at_most_fifteen_digits() {
293        assert!(PhoneNumber::parse("+90 1234567890123").is_ok());
294        assert!(PhoneNumber::parse("+90 12345678901234").is_err());
295    }
296
297    #[test]
298    fn masks_itself_in_debug_and_writes_e164_on_demand() {
299        let number = phone("+90 532 123 45 67");
300
301        assert_eq!(format!("{number:?}"), "PhoneNumber(+90 ***67)");
302        assert_eq!(number.e164(), "+905321234567");
303    }
304
305    #[test]
306    fn round_trips_through_a_code_and_a_number_column() {
307        let number = phone("+90 532 123 45 67");
308        assert_eq!(
309            round_trip(&number),
310            vec![SqlValue::Int(90), SqlValue::Text("5321234567".into())]
311        );
312        let stored_badly = vec![SqlValue::Int(-1), SqlValue::Text("5321234567".into())];
313        assert!(PhoneNumber::decode(&mut RowReader::new(&stored_badly)).is_err());
314    }
315
316    #[test]
317    fn round_trips_through_json_as_e164() {
318        let number = phone("+90 532 123 45 67");
319        let json = serde_json::to_value(&number).unwrap();
320
321        assert_eq!(json, serde_json::json!("+905321234567"));
322        assert_eq!(serde_json::from_value::<PhoneNumber>(json).unwrap(), number);
323        assert!(serde_json::from_value::<PhoneNumber>(serde_json::json!("5321234567")).is_err());
324    }
325}