Functional Weave
Code in Rust

validation.uk-postcode@1.0.0

impl/rust.rs

5,594 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

/// 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<char> = 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::<String>() == 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<String>| 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()))
}