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