use super::funejson::Value; /// Letters excluded from the two final characters of the inward code. They are /// the ones most easily misread in handwriting and on a sorting machine, so /// Royal Mail never issued them there. const INWARD_EXCLUDED: &str = "CIKMOV"; /// Q, V and X never appear as the first letter of a postcode area. const AREA_FIRST_EXCLUDED: &str = "QVX"; /// I, J and Z never appear as the second letter of a two-letter area. const AREA_SECOND_EXCLUDED: &str = "IJZ"; /// The only letters used in the third position of the A9A district form. const THIRD_POSITION_ALLOWED: &str = "ABCDEFGHJKPSTUW"; /// The only letters used in the fourth position of the AA9A district form. const FOURTH_POSITION_ALLOWED: &str = "ABEHMNPRVWXY"; /// The one postcode that obeys no rule: Girobank's, kept in service since 1968 /// and still valid on an address label. const GIROBANK: &str = "GIR0AA"; fn invalid() -> UkPostcode { UkPostcode { valid: false, normalised: None, outward: None, inward: None, } } fn is_digit(ch: char) -> bool { ('0'..='9').contains(&ch) } fn is_letter(ch: char) -> bool { ('A'..='Z').contains(&ch) } /// Parse and normalise a UK postcode. /// /// Returns a result rather than panicking, and rather than returning a bare /// boolean: the caller nearly always wants the canonical form to store, and /// splitting outward from inward is the difference between "can we deliver /// here" and "which delivery office is this". A malformed value comes back as /// `valid: false` with `None`s, so `result.normalised.unwrap_or(original)` is /// safe. /// /// Normalisation is upper case with exactly one space before the final three /// characters: "sw1a1aa" and " SW1A 1AA " both become "SW1A 1AA". That is /// the form Royal Mail prints and the form two records should be compared in. pub fn uk_postcode(value: &str) -> UkPostcode { // Only the ASCII space is stripped, and it is stripped everywhere: people // type the space in the wrong place far more often than they omit it, so // position carries no information worth preserving. let code: Vec = value .chars() .filter(|ch| *ch != ' ') .map(|ch| ch.to_ascii_uppercase()) .collect(); // Five is "M1 1AE", seven is "DN55 1PT"; nothing shorter or longer exists. if code.len() < 5 || code.len() > 7 { return invalid(); } if code.iter().collect::() == GIROBANK { return UkPostcode { valid: true, normalised: Some("GIR 0AA".to_string()), outward: Some("GIR".to_string()), inward: Some("0AA".to_string()), }; } let split = code.len() - 3; let outward = &code[..split]; let inward = &code[split..]; if !is_inward(inward) || !is_outward(outward) { return invalid(); } let outward: String = outward.iter().collect(); let inward: String = inward.iter().collect(); UkPostcode { valid: true, normalised: Some(format!("{} {}", outward, inward)), outward: Some(outward), inward: Some(inward), } } /// The inward code is always a digit followed by two letters. fn is_inward(inward: &[char]) -> bool { if !is_digit(inward[0]) { return false; } for ch in &inward[1..3] { if !is_letter(*ch) || INWARD_EXCLUDED.contains(*ch) { return false; } } true } /// The outward code is one of six shapes: A9, A99, A9A, AA9, AA99, AA9A. /// /// The letter restrictions below are not cosmetic - they are what stops a /// plausible looking string such as "QW1A" from being accepted. fn is_outward(outward: &[char]) -> bool { if !is_letter(outward[0]) || AREA_FIRST_EXCLUDED.contains(outward[0]) { return false; } match outward.len() { // A9 2 => is_digit(outward[1]), 3 => { if is_letter(outward[1]) { // AA9 !AREA_SECOND_EXCLUDED.contains(outward[1]) && is_digit(outward[2]) } else if !is_digit(outward[1]) { false } else { // A99 or A9A is_digit(outward[2]) || THIRD_POSITION_ALLOWED.contains(outward[2]) } } 4 => { // AA99 or AA9A if !is_letter(outward[1]) || AREA_SECOND_EXCLUDED.contains(outward[1]) { false } else if !is_digit(outward[2]) { false } else { is_digit(outward[3]) || FOURTH_POSITION_ALLOWED.contains(outward[3]) } } _ => false, } } /// Shorthand for callers that only need the yes or no. pub fn is_uk_postcode(value: &str) -> bool { uk_postcode(value).valid } /// 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 uk_postcode_to_value(result: &UkPostcode) -> Value { let text = |field: &Option| match field { Some(s) => Value::str(s), None => Value::Null, }; Value::obj(vec![ ("valid", Value::Bool(result.valid)), ("normalised", text(&result.normalised)), ("outward", text(&result.outward)), ("inward", text(&result.inward)), ]) } pub fn fune_vector(args: &[Value]) -> Value { // A non-string argument arrives here as an empty string, which is exactly // the answer TypeScript and Python give for a non-string: not a postcode. uk_postcode_to_value(&uk_postcode(args[0].as_str())) }