Functional Weave
Code in Rust

validation.phone-e164@1.0.0

impl/rust.rs

5,157 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::validation_phone_e164_data::{PhoneCountry, PHONE_COUNTRIES};  ← this capability’s own data, compiled from data/phone-countries.json into the same file by fune build

/// Characters people put between digits. Brackets matter because of the UK
/// habit of writing "+44 (0)20 ...".
const SEPARATORS: &str = " -.()";

fn invalid(reason: &str) -> PhoneE164 {
    PhoneE164 {
        valid: false,
        normalised: None,
        calling_code: None,
        national_number: None,
        reason: Some(reason.to_string()),
    }
}

/// Normalise a phone number to E.164 for a default country.
///
/// This is a plausibility check over a small table of countries, not a
/// numbering-plan database: it knows each country's calling code, trunk
/// prefix, international dialling prefix, national number lengths and the
/// digits a national number may start with. It does not know which ranges
/// are allocated, or whether a number is a mobile.
///
/// # Panics
/// Panics if `default_country` is neither "" nor a supported country.
pub fn phone_e164(value: &str, default_country: &str) -> PhoneE164 {
    // ASCII-only upper-casing, so every language folds identically.
    let code = default_country.to_ascii_uppercase();
    let mut home: Option<&PhoneCountry> = None;
    if !code.is_empty() {
        home = PHONE_COUNTRIES.iter().find(|row| row.country == code);
        // A bad default country is the caller's bug, not the user's typo, so
        // it panics rather than answering.
        if home.is_none() {
            panic!("unsupported default country \"{}\"", default_country);
        }
    }

    let mut digits = String::new();
    let mut plus = false;
    for ch in value.chars() {
        if SEPARATORS.contains(ch) {
            continue;
        }
        if ch == '+' {
            // A plus is only meaningful as the very first thing typed.
            if plus || !digits.is_empty() {
                return invalid("bad-format");
            }
            plus = true;
            continue;
        }
        if !ch.is_ascii_digit() {
            return invalid("bad-character");
        }
        digits.push(ch);
    }
    if digits.is_empty() {
        return invalid(if plus { "bad-format" } else { "empty" });
    }

    if let Some(h) = home {
        if !plus && digits.starts_with(h.international_prefix) {
            // Dialled with the home country's international prefix: 00 44 ...
            // from the UK, 011 44 ... from North America, 0011 44 ... from
            // Australia.
            digits = digits[h.international_prefix.len()..].to_string();
            plus = true;
        }
    }

    let target: &PhoneCountry;
    let mut national: &str;
    if plus {
        let mut found: Option<&PhoneCountry> = None;
        // Calling codes are prefix-free, so the first match is the only one.
        for size in 1..=3usize {
            let head = &digits[..size.min(digits.len())];
            if let Some(h) = home {
                if h.calling_code == head {
                    found = Some(h);
                    break;
                }
            }
            found = PHONE_COUNTRIES.iter().find(|row| row.calling_code == head);
            if found.is_some() {
                break;
            }
        }
        target = match found {
            Some(t) => t,
            None => return invalid("unknown-country-code"),
        };
        national = &digits[target.calling_code.len()..];
    } else {
        target = match home {
            Some(h) => h,
            None => return invalid("no-country"),
        };
        national = &digits;
    }

    // The trunk prefix is dialled only inside the country ("0" in the UK, "1"
    // in North America). It is dropped once, in national form and in the
    // "+44 (0)20" form alike; no national number starts with it anyway.
    if !target.trunk_prefix.is_empty() && national.starts_with(target.trunk_prefix) {
        national = &national[target.trunk_prefix.len()..];
    }

    let length = national.len() as i64;
    if length < target.min_length || length > target.max_length {
        return invalid("bad-length");
    }
    if !target.leading_digits.contains(&national[0..1]) {
        return invalid("bad-prefix");
    }
    PhoneE164 {
        valid: true,
        normalised: Some(format!("+{}{}", target.calling_code, national)),
        calling_code: Some(target.calling_code.to_string()),
        national_number: Some(national.to_string()),
        reason: None,
    }
}

/// Object keys are camelCase to match the shared vectors, and so that a
/// capability building on this one can reuse the same shape.
pub fn phone_e164_to_value(result: &PhoneE164) -> Value {
    let text = |field: &Option<String>| match field {
        Some(s) => Value::str(s),
        None => Value::Null,
    };
    Value::obj(vec![
        ("valid", Value::Bool(result.valid)),
        ("normalised", text(&result.normalised)),
        ("callingCode", text(&result.calling_code)),
        ("nationalNumber", text(&result.national_number)),
        ("reason", text(&result.reason)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    phone_e164_to_value(&phone_e164(args[0].as_str(), args[1].as_str()))
}