use core::fmt;
const COUNTRY_CODES: &[&str] = &[
"1", "20", "211", "212", "213", "216", "218", "220", "221", "222", "223", "224", "225", "226", "227", "228", "229", "230", "231", "232", "233", "234", "235", "236", "237", "238", "239",
"240", "241", "242", "243", "244", "245", "246", "247", "248", "249", "250", "251", "252", "253", "254", "255", "256", "257", "258", "260", "261", "262", "263", "264", "265", "266",
"267", "268", "269", "30", "31", "32", "33", "34", "350", "351", "352", "353", "354", "355", "356", "357", "358", "359", "36", "370", "371", "372", "373", "374", "375", "376", "377", "378", "379", "380", "381", "382", "383", "385", "386", "387", "389", "40", "41", "42", "43", "44", "45", "46", "47", "48",
"49", "500", "501", "502", "503", "504", "505", "506", "507", "508", "509", "51", "52", "53", "54", "55", "56", "57", "58", "590", "591", "592", "593", "594", "595", "596", "597", "598",
"599", "60", "61", "62", "63", "64", "65", "66", "670", "672", "673", "674", "675", "676", "677", "678", "679", "680", "681", "682", "683", "685", "686", "687", "688", "689", "690", "691",
"692", "7", "81", "82", "83", "84", "850", "852", "853", "855", "856", "86", "870", "872", "873", "874", "878", "879", "880", "881", "882", "883", "886", "888",
"90", "91", "92", "93", "94", "95", "960", "961", "962", "963", "964", "965", "966", "967", "968", "969", "970", "971", "972", "973", "974", "975", "976", "977", "979", "98", "992",
"993", "994", "995", "996", "998",
];
#[repr(transparent)]
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct Branded<T, B> {
value: T,
_brand: core::marker::PhantomData<B>,
}
impl<T, B> Branded<T, B> {
pub const fn new(value: T) -> Self {
Self {
value,
_brand: core::marker::PhantomData,
}
}
pub fn into_inner(self) -> T {
self.value
}
pub fn as_inner(&self) -> &T {
&self.value
}
}
pub struct PhoneNumberTag;
pub type PhoneNumber = Branded<String, PhoneNumberTag>;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PhoneNumberError {
Empty,
NoPlusPrefix,
NonDigitChars,
TooShort { actual: usize },
TooLong { actual: usize },
InvalidCountryCode,
}
impl PhoneNumberError {
pub fn code(&self) -> &'static str {
match self {
Self::Empty => "empty",
Self::NoPlusPrefix => "no_plus_prefix",
Self::NonDigitChars => "non_digit_chars",
Self::TooShort { .. } => "too_short",
Self::TooLong { .. } => "too_long",
Self::InvalidCountryCode => "invalid_country_code",
}
}
}
impl fmt::Display for PhoneNumberError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => f.write_str("Phone number must not be empty"),
Self::NoPlusPrefix => f.write_str("Phone number must start with +"),
Self::NonDigitChars => f.write_str("Phone number must contain only digits after the +"),
Self::TooShort { actual } => write!(f, "Phone number must have at least 7 digits (has {actual})"),
Self::TooLong { actual } => write!(f, "Phone number must have at most 15 digits (has {actual})"),
Self::InvalidCountryCode => f.write_str("Unknown country code"),
}
}
}
impl PhoneNumber {
pub const MAX_DIGITS: usize = 15;
pub const MIN_DIGITS: usize = 7;
pub fn from(value: String) -> Result<Self, PhoneNumberError> {
Self::validate(&value)?;
Ok(Self::from_unchecked(value))
}
pub fn from_unchecked(value: String) -> Self {
Self::new(value)
}
pub fn as_str(&self) -> &str {
self.as_inner()
}
pub fn validate(value: &str) -> Result<(), PhoneNumberError> {
if value.is_empty() {
return Err(PhoneNumberError::Empty);
}
if !value.starts_with('+') {
return Err(PhoneNumberError::NoPlusPrefix);
}
let digits = &value[1..];
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return Err(PhoneNumberError::NonDigitChars);
}
let n = digits.len();
if n < Self::MIN_DIGITS {
return Err(PhoneNumberError::TooShort { actual: n });
}
if n > Self::MAX_DIGITS {
return Err(PhoneNumberError::TooLong { actual: n });
}
if !is_valid_country_code(digits) {
return Err(PhoneNumberError::InvalidCountryCode);
}
Ok(())
}
pub fn is_phone_number(value: &str) -> bool {
Self::validate(value).is_ok()
}
pub fn format(&self, separator: &str) -> String {
let raw = self.as_str();
let digits = &raw[1..]; let Some(cc_len) = country_code_len(digits) else { return raw.to_owned() };
let national = &digits[cc_len..];
let n = national.len();
let num_full = n / 3;
let remainder = n % 3;
let mut out = String::with_capacity(raw.len() + n / 3 + 2);
out.push('+');
out.push_str(&digits[..cc_len]);
if n == 0 {
return out;
}
out.push_str(separator);
if num_full <= 1 {
out.push_str(national);
} else if remainder == 0 {
for i in 0..num_full {
if i > 0 {
out.push_str(separator);
}
out.push_str(&national[i * 3..(i + 1) * 3]);
}
} else {
for i in 0..num_full - 1 {
if i > 0 {
out.push_str(separator);
}
out.push_str(&national[i * 3..(i + 1) * 3]);
}
out.push_str(separator);
out.push_str(&national[(num_full - 1) * 3..]);
}
out
}
pub fn format_local(&self, prefix: &str, separator: &str) -> String {
let international = Self::format(self, separator);
let mut out = String::with_capacity(prefix.len() + international.len());
out.push_str(prefix);
out.push_str(separator);
out.push_str(&international[1..]); out
}
pub fn parse_input(raw: &str) -> Option<Self> {
let stripped: String = raw.chars().filter(|c| !matches!(c, ' ' | '-' | '.' | '(' | ')')).collect();
let normalised = if stripped.starts_with('+') { stripped } else { format!("+{stripped}") };
Self::validate(&normalised).ok()?;
Some(Self::from_unchecked(normalised))
}
}
impl fmt::Display for PhoneNumber {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
fn country_code_len(digits: &str) -> Option<usize> {
for &len in &[3u8, 2, 1] {
if digits.len() >= len as usize {
let prefix = &digits[..len as usize];
if COUNTRY_CODES.contains(&prefix) {
return Some(len as usize);
}
}
}
None
}
fn is_valid_country_code(digits: &str) -> bool {
country_code_len(digits).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_valid_e164_numbers() {
let cases = [
"+12345678901", "+442012345678", "+8613812345678", "+79161234567", "+85212345678", "+12421234567", ];
for raw in cases {
assert!(PhoneNumber::validate(raw).is_ok(), "should accept {raw}");
}
}
#[test]
fn rejects_empty() {
let err = PhoneNumber::validate("").unwrap_err();
assert_eq!(err.code(), "empty");
}
#[test]
fn rejects_no_plus() {
let err = PhoneNumber::validate("12345678901").unwrap_err();
assert_eq!(err.code(), "no_plus_prefix");
}
#[test]
fn rejects_spaces_in_canonical_form() {
let err = PhoneNumber::validate("+1 234 567 8901").unwrap_err();
assert_eq!(err.code(), "non_digit_chars");
}
#[test]
fn rejects_parentheses_and_hyphens() {
assert!(PhoneNumber::validate("+1(234)5678901").is_err());
assert!(PhoneNumber::validate("+1-234-567-8901").is_err());
}
#[test]
fn rejects_too_short() {
let err = PhoneNumber::validate("+123456").unwrap_err();
assert_eq!(err.code(), "too_short");
}
#[test]
fn rejects_too_long() {
let err = PhoneNumber::validate("+1234567890123456").unwrap_err();
assert_eq!(err.code(), "too_long");
}
#[test]
fn rejects_unknown_country_code() {
let err = PhoneNumber::validate("+9991234567").unwrap_err();
assert_eq!(err.code(), "invalid_country_code");
}
#[test]
fn boundary_min_7() {
assert!(PhoneNumber::validate("+7123456").is_ok()); }
#[test]
fn boundary_max_15() {
assert!(PhoneNumber::validate("+123456789012345").is_ok()); }
#[test]
fn from_constructs_valid() {
let pn = PhoneNumber::from("+12345678901".into()).unwrap();
assert_eq!(pn.as_str(), "+12345678901");
}
#[test]
fn from_rejects_invalid() {
assert!(PhoneNumber::from("bad".into()).is_err());
}
#[test]
fn from_unchecked_skips_validation() {
let pn = PhoneNumber::from_unchecked(String::new());
assert_eq!(pn.as_str(), "");
}
#[test]
fn is_phone_number_type_guard() {
assert!(PhoneNumber::is_phone_number("+12345678901"));
assert!(!PhoneNumber::is_phone_number(""));
assert!(!PhoneNumber::is_phone_number("+1"));
}
#[test]
fn format_us() {
let pn = PhoneNumber::from("+12345678901".into()).unwrap();
assert_eq!(pn.format(" "), "+1 234 567 8901");
}
#[test]
fn format_uk() {
let pn = PhoneNumber::from("+442012345678".into()).unwrap();
assert_eq!(pn.format(" "), "+44 201 234 5678");
}
#[test]
fn format_custom_separator() {
let pn = PhoneNumber::from("+12345678901".into()).unwrap();
assert_eq!(pn.format("-"), "+1-234-567-8901");
}
#[test]
fn format_local_default() {
let pn = PhoneNumber::from("+12345678901".into()).unwrap();
assert_eq!(pn.format_local("00", " "), "00 1 234 567 8901");
}
#[test]
fn parse_input_strips_spaces() {
let pn = PhoneNumber::parse_input("+1 234 567 8901").unwrap();
assert_eq!(pn.as_str(), "+12345678901");
}
#[test]
fn parse_input_strips_hyphens_and_adds_plus() {
let pn = PhoneNumber::parse_input("1-234-567-8901").unwrap();
assert_eq!(pn.as_str(), "+12345678901");
}
#[test]
fn parse_input_strips_parentheses() {
let pn = PhoneNumber::parse_input("+1 (234) 567-8901").unwrap();
assert_eq!(pn.as_str(), "+12345678901");
}
#[test]
fn parse_input_returns_none_for_invalid() {
assert!(PhoneNumber::parse_input("short").is_none());
assert!(PhoneNumber::parse_input("").is_none());
}
#[test]
fn display_returns_canonical_e164() {
let pn = PhoneNumber::from("+12345678901".into()).unwrap();
assert_eq!(format!("{pn}"), "+12345678901");
}
#[test]
fn branded_round_trip() {
struct EmailTag;
type Email = Branded<String, EmailTag>;
let email = Email::new("a@b.com".into());
assert_eq!(email.as_inner(), "a@b.com");
assert_eq!(email.into_inner(), "a@b.com");
}
}