Skip to main content

sioul_core/
phones.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Phone numbers, compared rather than rewritten: "04 65 71 12 34",
5//! "04.65.71.12.34", "+33 4 65 71 12 34", "+33 (0)4 65 71 12 34" and
6//! "0033465711234" are one number, written five ways. Each number gets a key,
7//! its international form (E.164, ITU-T) with its extension, and two numbers
8//! with one key are the same; what the card says stays as it was written.
9//!
10//! A number written without its country is read as the country chosen in the
11//! Contacts settings, else the country of the system's locale. A small table
12//! knows how the usual countries write their numbers at home: the trunk
13//! prefix dropped in the international form ("0" in most of Europe, "1" in
14//! North America, none in Spain or Italy) and how long a number is, so that a
15//! short code ("3631", "112") is never given a country. Values with letters
16//! ("1-555-SIOUL"), stars and hashes ("*#06#") are compared as written.
17//! The rules are those of each country's numbering plan, as the ITU publishes
18//! them (<https://www.itu.int/oth/T0202>); libphonenumber knows every country
19//! but weighs megabytes, for what this table does for the usual ones.
20
21/// How a country writes its numbers at home.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct Region {
24    /// ISO 3166-1: "FR".
25    pub code: &'static str,
26    /// The country calling code, without "+": "33".
27    pub calling: &'static str,
28    /// What starts a national number and goes in the international form: "0"
29    /// in most of Europe, "1" in North America; "" where nothing does (Spain,
30    /// Italy, whose leading 0 belongs to the number).
31    pub trunk: &'static str,
32    /// What dials abroad from there besides "00": "011" in North America.
33    pub international: &'static str,
34    /// How many digits a national number has, its trunk prefix included:
35    /// fewer or more, it is a short code or something else, compared as written.
36    pub digits: (usize, usize),
37    /// The French plan: France and its overseas departments share ten-digit
38    /// numbers, whose first digits say which country code they take.
39    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
50/// The countries known here (docs/client.md, "Duplicates").
51pub 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
88/// A country of the table, by its ISO code ("fr" too).
89pub 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
94/// The country a locale names: "fr_FR.UTF-8" → France, "en-GB" → the United
95/// Kingdom; none for a language alone ("fr"), "C" or a country not known here.
96pub 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
102/// The country numbers without one are read as, unless the settings say:
103/// the system's locale (LC_ALL, LC_TELEPHONE, LANG), else Sioul's language's
104/// own (`language_locale`, "fr_FR" for French).
105pub 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
109/// The country chosen in the settings (`setting`, an ISO code), else the usual one.
110/// A code not known here reads numbers without a country as they are written.
111pub 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
118/// The French plan's overseas numbers, by the three digits after the 0:
119/// Réunion and Mayotte share +262, Guadeloupe (with Saint-Martin and
120/// Saint-Barthélemy) +590, French Guiana +594, Martinique +596 (ARCEP).
121fn 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
131/// The country codes whose numbers never start with 0: written "+33 06…",
132/// the 0 is the trunk prefix kept by mistake. Italy's 0 is part of the number.
133fn drops_written_trunk(calling: &str) -> bool {
134    REGIONS.iter().any(|r| r.calling == calling && r.trunk == "0")
135}
136
137/// "+330465711234" → "+33465711234": a trunk 0 written after a country code that has none.
138fn without_written_trunk(digits: &str) -> String {
139    // Country codes are a prefix code (no code starts another): at most one matches.
140    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
151/// Characters that only space a number out: spaces of every width, dots,
152/// dashes, slashes; and the invisible marks of writing direction phones put
153/// around numbers (iOS writes "\u{202a}+33 6…\u{202c}").
154fn 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
159/// The extension at the end of a number, after its marker: "poste 12",
160/// "ext. 12", "x12", "#12", ",12", ";ext=12". Returns the number before it and the digits.
161fn 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            // "x" only after the number ("…0100 x12"), never inside a word.
174            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
183/// The digits of a number, with "+" first when it is written internationally;
184/// none when it holds anything a number does not (letters, "*", "#").
185fn 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
199/// "+33 (0)6…": the trunk prefix some write in parentheses after the country
200/// code, out; Italy's 0 belongs to the number and stays.
201fn 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
213/// A number's key, to compare it: its international form ("+33465711234"),
214/// its extension after ";ext=". A number written without its country takes
215/// `region`'s; a short code and a value that is not a number are compared as
216/// written, spaces aside. Never shown, never written to a card.
217pub 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
227/// The number's own key, without the extension; none when it is not a number.
228fn 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    // Abroad: "00", or the country's own prefix ("011" in North America).
235    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        // A short code ("3631"), or something else: as written.
243        return Some(digits);
244    }
245    match region.trunk {
246        "" => Some(format!("+{}{digits}", region.calling)),
247        // North America: ten digits, or eleven after its "1".
248        "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            // Without its trunk prefix ("465711234" in France): as written.
259            _ => Some(digits),
260        },
261    }
262}
263
264/// Whether a key stands for a whole number (not a short code), so that two
265/// cards sharing it may be one person.
266pub 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
271/// Whether a number is written in its international form, as people write it: "+33 6…".
272pub 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        // Overseas departments share the ten-digit plan, with their own codes.
293        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        // From Réunion, a Paris number is still France's.
299        assert_eq!(key_in("01 99 00 67 89", "RE"), "+33199006789");
300        assert_eq!(key_in("0692 12 34 56", "RE"), "+262692123456");
301        // Short codes and numbers without their 0 are left as written.
302        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        // Luxembourg has no trunk prefix: the whole number takes +352.
315        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        // Italy's leading 0 is part of the number, in the international form too.
334        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        // Seven digits without the area code: as written.
353        assert_eq!(key_in("555-0100", "US"), "5550100");
354        // Abroad from there: "011".
355        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        // Another extension is another number.
378        assert_ne!(key_in("+33 1 99 00 67 89 poste 12", "FR"), key_in("+33 1 99 00 67 89", "FR"));
379        // Not numbers: compared as written, spaces aside, case aside.
380        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        // No country known: national numbers compared by their digits.
384        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        // Every country once, each code a code of its own.
401        let codes: std::collections::BTreeSet<&str> = REGIONS.iter().map(|r| r.code).collect();
402        assert_eq!(codes.len(), REGIONS.len());
403    }
404}