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()))
}