1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct Region {
24 pub code: &'static str,
26 pub calling: &'static str,
28 pub trunk: &'static str,
32 pub international: &'static str,
34 pub digits: (usize, usize),
37 pub french: bool,
40}
41
42const fn region(code: &'static str, calling: &'static str, trunk: &'static str, international: &'static str, digits: (usize, usize)) -> Region {
43 Region { code, calling, trunk, international, digits, french: false }
44}
45
46const fn french(code: &'static str, calling: &'static str) -> Region {
47 Region { code, calling, trunk: "0", international: "00", digits: (10, 10), french: true }
48}
49
50pub const REGIONS: &[Region] = &[
52 french("FR", "33"),
53 french("RE", "262"),
54 french("YT", "262"),
55 french("GP", "590"),
56 french("MQ", "596"),
57 french("GF", "594"),
58 region("BE", "32", "0", "00", (9, 10)),
59 region("CH", "41", "0", "00", (10, 10)),
60 region("LU", "352", "", "00", (6, 11)),
61 region("DE", "49", "0", "00", (6, 15)),
62 region("AT", "43", "0", "00", (6, 14)),
63 region("NL", "31", "0", "00", (10, 10)),
64 region("GB", "44", "0", "00", (10, 11)),
65 region("IE", "353", "0", "00", (7, 10)),
66 region("ES", "34", "", "00", (9, 9)),
67 region("PT", "351", "", "00", (9, 9)),
68 region("IT", "39", "", "00", (6, 11)),
69 region("DK", "45", "", "00", (8, 8)),
70 region("NO", "47", "", "00", (8, 8)),
71 region("SE", "46", "0", "00", (7, 10)),
72 region("FI", "358", "0", "00", (6, 12)),
73 region("PL", "48", "", "00", (9, 9)),
74 region("GR", "30", "", "00", (10, 10)),
75 region("US", "1", "1", "011", (10, 11)),
76 region("CA", "1", "1", "011", (10, 11)),
77 region("AU", "61", "0", "0011", (10, 10)),
78 region("NZ", "64", "0", "00", (8, 11)),
79 region("JP", "81", "0", "010", (10, 11)),
80 region("IN", "91", "0", "00", (11, 11)),
81 region("MA", "212", "0", "00", (10, 10)),
82 region("DZ", "213", "0", "00", (9, 10)),
83 region("TN", "216", "", "00", (8, 8)),
84 region("SN", "221", "", "00", (9, 9)),
85 region("CI", "225", "", "00", (10, 10)),
86];
87
88pub fn region_named(code: &str) -> Option<&'static Region> {
90 let code = code.trim();
91 REGIONS.iter().find(|r| r.code.eq_ignore_ascii_case(code))
92}
93
94pub fn region_of_locale(locale: &str) -> Option<&'static Region> {
97 let base = locale.split(['.', '@']).next().unwrap_or("");
98 let country = base.split(['_', '-']).nth(1)?;
99 region_named(country)
100}
101
102pub fn usual_region(language_locale: &str) -> Option<&'static Region> {
106 ["LC_ALL", "LC_TELEPHONE", "LANG"].iter().filter_map(|v| std::env::var(v).ok()).find_map(|v| region_of_locale(&v)).or_else(|| region_of_locale(language_locale))
107}
108
109pub fn chosen(setting: Option<&str>, language_locale: &str) -> Option<&'static Region> {
112 match setting.map(str::trim).filter(|s| !s.is_empty()) {
113 Some(code) => region_named(code),
114 None => usual_region(language_locale),
115 }
116}
117
118fn french_overseas(national: &str) -> Option<&'static str> {
122 match national.get(..3)? {
123 "262" | "263" | "269" | "639" | "692" | "693" => Some("262"),
124 "590" | "690" | "691" => Some("590"),
125 "594" | "694" => Some("594"),
126 "596" | "696" | "697" => Some("596"),
127 _ => None,
128 }
129}
130
131fn drops_written_trunk(calling: &str) -> bool {
134 REGIONS.iter().any(|r| r.calling == calling && r.trunk == "0")
135}
136
137fn without_written_trunk(digits: &str) -> String {
139 for length in 1..=3 {
141 if let Some((calling, rest)) = digits.split_at_checked(length)
142 && rest.starts_with('0')
143 && drops_written_trunk(calling)
144 {
145 return format!("{calling}{}", &rest[1..]);
146 }
147 }
148 digits.to_string()
149}
150
151fn is_spacing(c: char) -> bool {
155 matches!(c, ' ' | '\t' | '.' | '-' | '/' | '\u{a0}' | '\u{202f}' | '\u{2009}' | '\u{2007}' | '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}')
156 || matches!(c, '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{feff}')
157}
158
159fn split_extension(value: &str) -> (&str, &str) {
162 let trimmed = value.trim_end();
163 let before_digits = trimmed.trim_end_matches(|c: char| c.is_ascii_digit());
164 if before_digits.len() == trimmed.len() {
165 return (value, "");
166 }
167 let digits = &trimmed[before_digits.len()..];
168 let head = before_digits.trim_end_matches(|c: char| c == ' ' || c == '\u{a0}' || c == '\u{202f}' || c == '.' || c == ':' || c == '=');
169 let lower = head.to_ascii_lowercase();
170 for marker in ["extension", "poste", "ext", "x", "#", ",", ";"] {
171 if lower.ends_with(marker) {
172 let number = head[..head.len() - marker.len()].trim_end_matches(|c: char| is_spacing(c) || c == ';' || c == ',');
173 if marker == "x" && !number.ends_with(|c: char| c.is_ascii_digit() || c == ')') {
175 return (value, "");
176 }
177 return (number, digits);
178 }
179 }
180 (value, "")
181}
182
183fn digits_of(number: &str) -> Option<String> {
186 let mut out = String::new();
187 for (i, c) in number.trim().chars().enumerate() {
188 match c {
189 '+' if i == 0 => out.push('+'),
190 '0'..='9' => out.push(c),
191 '(' | ')' => {}
192 c if is_spacing(c) => {}
193 _ => return None,
194 }
195 }
196 (out.trim_start_matches('+').len() > 0).then_some(out)
197}
198
199fn without_zero_in_parentheses(number: &str) -> String {
202 let Some(at) = number.find("(0)") else { return number.to_string() };
203 let before: String = number[..at].chars().filter(char::is_ascii_digit).collect();
204 let international = number.trim_start().starts_with('+') || before.starts_with("00");
205 if !international {
206 return number.to_string();
207 }
208 let calling = before.trim_start_matches("00");
209 let kept = if calling == "39" { "0" } else { "" };
210 format!("{}{kept}{}", &number[..at], &number[at + 3..])
211}
212
213pub fn key(value: &str, region: Option<&Region>) -> String {
218 let value = value.trim_matches(|c: char| c.is_whitespace() || is_spacing(c));
219 let value = value.get(..4).filter(|s| s.eq_ignore_ascii_case("tel:")).map_or(value, |_| value[4..].trim());
220 let (number, extension) = split_extension(value);
221 let Some(main) = number_key(number, region) else {
222 return value.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase();
223 };
224 if extension.is_empty() { main } else { format!("{main};ext={extension}") }
225}
226
227fn number_key(number: &str, region: Option<&Region>) -> Option<String> {
229 let digits = digits_of(&without_zero_in_parentheses(number))?;
230 if let Some(international) = digits.strip_prefix('+') {
231 return Some(format!("+{}", without_written_trunk(international)));
232 }
233 let Some(region) = region else { return Some(digits) };
234 for prefix in [region.international, "00"] {
236 if let Some(rest) = digits.strip_prefix(prefix).filter(|rest| rest.len() >= 6 && !rest.starts_with('0')) {
237 return Some(format!("+{}", without_written_trunk(rest)));
238 }
239 }
240 let (fewest, most) = region.digits;
241 if !(fewest..=most).contains(&digits.len()) {
242 return Some(digits);
244 }
245 match region.trunk {
246 "" => Some(format!("+{}{digits}", region.calling)),
247 "1" => match digits.len() {
249 10 if !digits.starts_with(['0', '1']) => Some(format!("+1{digits}")),
250 11 if digits.starts_with('1') => Some(format!("+{digits}")),
251 _ => Some(digits),
252 },
253 trunk => match digits.strip_prefix(trunk) {
254 Some(national) if !national.starts_with('0') => {
255 let calling = if region.french { french_overseas(national).unwrap_or("33") } else { region.calling };
256 Some(format!("+{calling}{national}"))
257 }
258 _ => Some(digits),
260 },
261 }
262}
263
264pub fn is_whole(key: &str) -> bool {
267 let digits = key.split(';').next().unwrap_or(key);
268 digits.starts_with('+') || (digits.len() >= 7 && digits.chars().all(|c| c.is_ascii_digit()))
269}
270
271pub fn is_international(value: &str) -> bool {
273 let value = value.trim_matches(|c: char| c.is_whitespace() || is_spacing(c));
274 let value = value.get(..4).filter(|s| s.eq_ignore_ascii_case("tel:")).map_or(value, |_| value[4..].trim());
275 value.starts_with('+')
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 fn key_in(value: &str, country: &str) -> String {
283 key(value, region_named(country))
284 }
285
286 #[test]
287 fn france_its_spaces_and_its_prefix() {
288 let all = ["\u{202a}+33 4 65 71 12 34\u{202c}", "04 65 71 12 34", "04.65.71.12.34", "04-65-71-12-34", "0465711234", "+33 4 65 71 12 34", "+33 (0)4 65 71 12 34", "+33 04 65 71 12 34", "0033 4 65 71 12 34", "tel:+33-4-65-71-12-34", "04\u{a0}65\u{202f}71 12 34"];
289 for written in all {
290 assert_eq!(key_in(written, "FR"), "+33465711234", "{written}");
291 }
292 assert_eq!(key_in("0692 12 34 56", "FR"), "+262692123456");
294 assert_eq!(key_in("+262 692 12 34 56", "FR"), "+262692123456");
295 assert_eq!(key_in("05 90 12 34 56", "FR"), "+590590123456");
296 assert_eq!(key_in("0696 12 34 56", "FR"), "+596696123456");
297 assert_eq!(key_in("0594 12 34 56", "FR"), "+594594123456");
298 assert_eq!(key_in("01 99 00 67 89", "RE"), "+33199006789");
300 assert_eq!(key_in("0692 12 34 56", "RE"), "+262692123456");
301 assert_eq!(key_in("36 31", "FR"), "3631");
303 assert_eq!(key_in("112", "FR"), "112");
304 assert_eq!(key_in("465711234", "FR"), "465711234");
305 }
306
307 #[test]
308 fn neighbours_with_and_without_a_trunk_prefix() {
309 assert_eq!(key_in("0475 12 34 56", "BE"), "+32475123456");
310 assert_eq!(key_in("02 123 45 67", "BE"), "+3221234567");
311 assert_eq!(key_in("+32 (0)2 123 45 67", "FR"), "+3221234567");
312 assert_eq!(key_in("044 000 12 34", "CH"), "+41440001234");
313 assert_eq!(key_in("0041 44 000 12 34", "FR"), "+41440001234");
314 assert_eq!(key_in("621 123 456", "LU"), "+352621123456");
316 assert_eq!(key_in("+352 621 123 456", "FR"), "+352621123456");
317 assert_eq!(key_in("030 1234567", "DE"), "+49301234567");
318 assert_eq!(key_in("+49 (0)30 1234567", "DE"), "+49301234567");
319 assert_eq!(key_in("0151 23456789", "DE"), "+4915123456789");
320 assert_eq!(key_in("020 7946 0018", "GB"), "+442079460018");
321 assert_eq!(key_in("+44 (0)20 7946 0018", "FR"), "+442079460018");
322 assert_eq!(key_in("07700 900123", "GB"), "+447700900123");
323 assert_eq!(key_in("06 12345678", "NL"), "+31612345678");
324 assert_eq!(key_in("01 234 5678", "IE"), "+35312345678");
325 assert_eq!(key_in("0664 1234567", "AT"), "+436641234567");
326 }
327
328 #[test]
329 fn spain_portugal_and_italy_keep_their_numbers_whole() {
330 assert_eq!(key_in("612 34 56 78", "ES"), "+34612345678");
331 assert_eq!(key_in("91 123 45 67", "ES"), "+34911234567");
332 assert_eq!(key_in("912 345 678", "PT"), "+351912345678");
333 assert_eq!(key_in("06 1234 5678", "IT"), "+390612345678");
335 assert_eq!(key_in("+39 06 1234 5678", "FR"), "+390612345678");
336 assert_eq!(key_in("+39 (0)6 1234 5678", "FR"), "+390612345678");
337 assert_eq!(key_in("347 123 4567", "IT"), "+393471234567");
338 assert_eq!(key_in("20 12 34 56", "DK"), "+4520123456");
339 assert_eq!(key_in("412 34 567", "NO"), "+4741234567");
340 assert_eq!(key_in("512 345 678", "PL"), "+48512345678");
341 assert_eq!(key_in("21 0123 4567", "GR"), "+302101234567");
342 assert_eq!(key_in("070 123 45 67", "SE"), "+46701234567");
343 assert_eq!(key_in("040 1234567", "FI"), "+358401234567");
344 }
345
346 #[test]
347 fn north_america_and_further() {
348 for written in ["(514) 555-0100", "514-555-0100", "514.555.0100", "1 514 555 0100", "+1 (514) 555-0100", "+1-514-555-0100"] {
349 assert_eq!(key_in(written, "CA"), "+15145550100", "{written}");
350 }
351 assert_eq!(key_in("(212) 555-0100", "US"), "+12125550100");
352 assert_eq!(key_in("555-0100", "US"), "5550100");
354 assert_eq!(key_in("011 33 4 65 71 12 34", "US"), "+33465711234");
356 assert_eq!(key_in("0412 345 678", "AU"), "+61412345678");
357 assert_eq!(key_in("0011 33 4 65 71 12 34", "AU"), "+33465711234");
358 assert_eq!(key_in("021 123 4567", "NZ"), "+64211234567");
359 assert_eq!(key_in("090-1234-5678", "JP"), "+819012345678");
360 assert_eq!(key_in("010 33 4 65 71 12 34", "JP"), "+33465711234");
361 assert_eq!(key_in("098765 43210", "IN"), "+919876543210");
362 assert_eq!(key_in("06 61 23 45 67", "MA"), "+212661234567");
363 assert_eq!(key_in("0551 23 45 67", "DZ"), "+213551234567");
364 assert_eq!(key_in("20 123 456", "TN"), "+21620123456");
365 assert_eq!(key_in("77 123 45 67", "SN"), "+221771234567");
366 assert_eq!(key_in("07 07 12 34 56", "CI"), "+2250707123456");
367 }
368
369 #[test]
370 fn extensions_kept_and_odd_values_left_alone() {
371 assert_eq!(key_in("+33 1 99 00 67 89 poste 123", "FR"), "+33199006789;ext=123");
372 assert_eq!(key_in("01 99 00 67 89 ext. 123", "FR"), "+33199006789;ext=123");
373 assert_eq!(key_in("+1 212 555 0100 x12", "US"), "+12125550100;ext=12");
374 assert_eq!(key_in("tel:+1-212-555-0100;ext=12", "FR"), "+12125550100;ext=12");
375 assert_eq!(key_in("01 99 00 67 89 #12", "FR"), "+33199006789;ext=12");
376 assert_eq!(key_in("01 99 00 67 89,12", "FR"), "+33199006789;ext=12");
377 assert_ne!(key_in("+33 1 99 00 67 89 poste 12", "FR"), key_in("+33 1 99 00 67 89", "FR"));
379 assert_eq!(key_in("*#06#", "FR"), "*#06#");
381 assert_eq!(key_in("1-555-SIOUL", "US"), "1-555-sioul");
382 assert_eq!(key_in("sip:jane@example.org", "FR"), "sip:jane@example.org");
383 assert_eq!(key("04 65 71 12 34", None), "0465711234");
385 assert_eq!(key("+33 4 65 71 12 34", None), "+33465711234");
386 assert!(is_whole("+33465711234") && is_whole("0465711234") && !is_whole("3631") && !is_whole("*#06#"));
387 assert!(is_international(" +33 4 65") && is_international("tel:+33465") && !is_international("04 65"));
388 }
389
390 #[test]
391 fn the_country_from_the_settings_or_the_locale() {
392 assert_eq!(region_of_locale("fr_FR.UTF-8").map(|r| r.code), Some("FR"));
393 assert_eq!(region_of_locale("fr_BE.UTF-8@euro").map(|r| r.code), Some("BE"));
394 assert_eq!(region_of_locale("en-GB").map(|r| r.code), Some("GB"));
395 assert_eq!(region_of_locale("fr").map(|r| r.code), None);
396 assert_eq!(region_of_locale("C.UTF-8").map(|r| r.code), None);
397 assert_eq!(region_of_locale("pt_BR.UTF-8").map(|r| r.code), None, "a country not in the table");
398 assert_eq!(chosen(Some("ch"), "fr_FR").map(|r| r.code), Some("CH"));
399 assert_eq!(chosen(Some("XX"), "fr_FR"), None, "an unknown code reads numbers as written");
400 let codes: std::collections::BTreeSet<&str> = REGIONS.iter().map(|r| r.code).collect();
402 assert_eq!(codes.len(), REGIONS.len());
403 }
404}