Skip to main content

ng_postcode/
postcode.rs

1use std::fmt;
2use std::ops::Range;
3use std::str::FromStr;
4
5use Segment::{Area, District, Lga, State, Unit};
6
7const LEN: usize = 11;
8
9/// A well-formed postcode, held in its compact upper-case form.
10///
11/// Well formed is not the same as assigned: only the NIPOST API knows whether
12/// a code belongs to a real building.
13#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
14pub struct Postcode([u8; LEN]);
15
16/// The five segments of a postcode, `AA-99-H77-BB-55`, from widest to narrowest.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
18pub enum Segment {
19    /// Two letters.
20    State,
21    /// Two digits, 01 to 99.
22    Lga,
23    /// Three letters or digits.
24    District,
25    /// Two letters.
26    Area,
27    /// Two digits, 01 to 99.
28    Unit,
29}
30
31/// Why a string is not a well-formed postcode.
32#[derive(Clone, Debug, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum ParseError {
35    /// The input did not hold exactly 11 letters and digits.
36    Length { found: usize },
37    /// The input held something other than letters, digits, spaces and hyphens.
38    InvalidCharacter { ch: char, index: usize },
39    /// A segment has the wrong shape, such as digits in the state.
40    Segment(Segment),
41}
42
43/// The result of [`Postcode::parse_lenient`].
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45#[non_exhaustive]
46pub struct Corrected {
47    pub postcode: Postcode,
48    /// How many characters were swapped for their look-alike.
49    pub corrections: usize,
50}
51
52impl Postcode {
53    /// Parses a hyphenated, spaced or compact code in either case.
54    pub fn parse(input: &str) -> Result<Self, ParseError> {
55        collect(input).and_then(validate).map(Self)
56    }
57
58    /// Parses like [`parse`](Self::parse) after swapping look-alike characters
59    /// that cannot occur where they stand: `0 1 5 8` for `O I S B` where a
60    /// letter is required, and the reverse (plus `L` for `1`) where a digit is.
61    ///
62    /// The result is well formed but may not be the code the user meant, so
63    /// confirm it with them when `corrections` is not zero.
64    pub fn parse_lenient(input: &str) -> Result<Corrected, ParseError> {
65        let raw = collect(input)?;
66        let fixed: [u8; LEN] =
67            std::array::from_fn(|i| Segment::at(i).map_or(raw[i], |s| s.unconfuse(raw[i])));
68        let corrections = raw.iter().zip(&fixed).filter(|(a, b)| a != b).count();
69        validate(fixed).map(|bytes| Corrected {
70            postcode: Self(bytes),
71            corrections,
72        })
73    }
74
75    /// Builds a code from its segments, zero-filling the LGA and unit so that
76    /// `"1"` becomes `"01"`.
77    pub fn from_segments(
78        state: &str,
79        lga: &str,
80        district: &str,
81        area: &str,
82        unit: &str,
83    ) -> Result<Self, ParseError> {
84        let parts = [
85            State.padded(state)?,
86            Lga.padded(lga)?,
87            District.padded(district)?,
88            Area.padded(area)?,
89            Unit.padded(unit)?,
90        ];
91        Self::parse(&parts.concat())
92    }
93
94    /// The compact form, `EK01A03FK01`. Store and compare this one.
95    pub fn as_str(&self) -> &str {
96        std::str::from_utf8(&self.0).expect("postcode bytes are ASCII")
97    }
98
99    /// The spaced form shown to people, `EK 01 A03 FK 01`.
100    pub fn to_spaced(&self) -> String {
101        self.joined(Unit, " ")
102    }
103
104    /// The hyphenated code down to and including `through`, so
105    /// `prefix(Segment::Area)` is `EK-01-A03-FK`.
106    pub fn prefix(&self, through: Segment) -> String {
107        self.joined(through, "-")
108    }
109
110    pub fn segment(&self, segment: Segment) -> &str {
111        &self.as_str()[segment.range()]
112    }
113
114    pub fn state(&self) -> &str {
115        self.segment(State)
116    }
117
118    pub fn lga(&self) -> &str {
119        self.segment(Lga)
120    }
121
122    pub fn district(&self) -> &str {
123        self.segment(District)
124    }
125
126    pub fn area(&self) -> &str {
127        self.segment(Area)
128    }
129
130    pub fn unit(&self) -> &str {
131        self.segment(Unit)
132    }
133
134    fn joined(&self, through: Segment, separator: &str) -> String {
135        Segment::ALL
136            .into_iter()
137            .take_while(|&s| s <= through)
138            .map(|s| self.segment(s))
139            .collect::<Vec<_>>()
140            .join(separator)
141    }
142}
143
144/// Whether `input` is a well-formed postcode.
145pub fn is_valid(input: &str) -> bool {
146    Postcode::parse(input).is_ok()
147}
148
149impl Segment {
150    const ALL: [Self; 5] = [State, Lga, District, Area, Unit];
151
152    const fn range(self) -> Range<usize> {
153        match self {
154            State => 0..2,
155            Lga => 2..4,
156            District => 4..7,
157            Area => 7..9,
158            Unit => 9..11,
159        }
160    }
161
162    fn at(index: usize) -> Option<Self> {
163        Self::ALL.into_iter().find(|s| s.range().contains(&index))
164    }
165
166    fn accepts(self, bytes: &[u8]) -> bool {
167        match self {
168            State | Area => bytes.iter().all(u8::is_ascii_uppercase),
169            // 00 is never issued.
170            Lga | Unit => bytes.iter().all(u8::is_ascii_digit) && bytes != b"00",
171            District => bytes.iter().all(u8::is_ascii_alphanumeric),
172        }
173    }
174
175    fn unconfuse(self, byte: u8) -> u8 {
176        match (self, byte) {
177            (State | Area, b'0') => b'O',
178            (State | Area, b'1') => b'I',
179            (State | Area, b'5') => b'S',
180            (State | Area, b'8') => b'B',
181            (Lga | Unit, b'O') => b'0',
182            (Lga | Unit, b'I' | b'L') => b'1',
183            (Lga | Unit, b'S') => b'5',
184            (Lga | Unit, b'B') => b'8',
185            _ => byte,
186        }
187    }
188
189    fn padded(self, value: &str) -> Result<String, ParseError> {
190        let (value, width) = (value.trim(), self.range().len());
191        let shortest = if matches!(self, Lga | Unit) { 1 } else { width };
192        let fits = (shortest..=width).contains(&value.len())
193            && value.bytes().all(|b| b.is_ascii_alphanumeric());
194        if fits {
195            Ok(format!("{value:0>width$}"))
196        } else {
197            Err(ParseError::Segment(self))
198        }
199    }
200}
201
202fn collect(input: &str) -> Result<[u8; LEN], ParseError> {
203    // Every character is checked, but only a postcode's worth is held: no allocation.
204    let mut bytes = [0; LEN];
205    let mut found = 0;
206    for (index, ch) in input.char_indices() {
207        if ch == ' ' || ch == '-' {
208            continue;
209        }
210        if !ch.is_ascii_alphanumeric() {
211            return Err(ParseError::InvalidCharacter { ch, index });
212        }
213        if let Some(slot) = bytes.get_mut(found) {
214            *slot = ch.to_ascii_uppercase() as u8;
215        }
216        found += 1;
217    }
218    match found {
219        LEN => Ok(bytes),
220        _ => Err(ParseError::Length { found }),
221    }
222}
223
224fn validate(bytes: [u8; LEN]) -> Result<[u8; LEN], ParseError> {
225    match Segment::ALL
226        .into_iter()
227        .find(|s| !s.accepts(&bytes[s.range()]))
228    {
229        Some(segment) => Err(ParseError::Segment(segment)),
230        None => Ok(bytes),
231    }
232}
233
234impl FromStr for Postcode {
235    type Err = ParseError;
236
237    fn from_str(s: &str) -> Result<Self, Self::Err> {
238        Self::parse(s)
239    }
240}
241
242/// The canonical hyphenated form, `EK-01-A03-FK-01`.
243impl fmt::Display for Postcode {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        f.pad(&self.prefix(Unit))
246    }
247}
248
249impl fmt::Debug for Postcode {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        write!(f, "Postcode({self})")
252    }
253}
254
255impl fmt::Display for Segment {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        f.pad(match self {
258            State => "state",
259            Lga => "lga",
260            District => "district",
261            Area => "area",
262            Unit => "unit",
263        })
264    }
265}
266
267impl fmt::Display for ParseError {
268    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
269        match self {
270            Self::Length { found } => write!(f, "expected {LEN} letters and digits, found {found}"),
271            Self::InvalidCharacter { ch, index } => {
272                write!(f, "invalid character '{ch}' at index {index}")
273            }
274            Self::Segment(segment) => write!(f, "invalid {segment} segment"),
275        }
276    }
277}
278
279impl std::error::Error for ParseError {}
280
281#[cfg(feature = "serde")]
282impl serde::Serialize for Postcode {
283    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
284        serializer.collect_str(self)
285    }
286}
287
288#[cfg(feature = "serde")]
289impl<'de> serde::Deserialize<'de> for Postcode {
290    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
291        let text = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
292        text.parse().map_err(serde::de::Error::custom)
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    // The test postcodes published in the NIPOST API docs.
301    const PUBLISHED: &str = "\
302        EK-01-A03-FK-01 AK-11-I61-ZF-12 AK-11-H40-WD-11 BA-02-M67-BL-69 BA-02-E99-NE-30 \
303        EB-13-G95-FR-90 EB-13-I97-AB-30 EN-05-V19-CD-22 EN-05-V19-FT-20 FC-03-B06-AG-12 \
304        FC-02-B19-RT-30 JI-24-O18-JP-23 JI-24-N11-VM-58 KN-31-F82-WJ-80 KN-31-D78-IQ-38 \
305        LA-11-W06-TC-10 LA-11-U34-ZR-63 NI-09-J67-QC-65 NI-09-A75-DA-10 OG-14-T18-BN-16 \
306        OG-14-M82-QA-09";
307
308    fn parsed(code: &str) -> Postcode {
309        Postcode::parse(code).unwrap()
310    }
311
312    #[test]
313    fn published_codes_round_trip_unchanged() {
314        assert_eq!(PUBLISHED.split_whitespace().count(), 21);
315        for code in PUBLISHED.split_whitespace() {
316            assert_eq!(parsed(code).to_string(), code);
317            let lenient = Postcode::parse_lenient(code).unwrap();
318            assert_eq!((lenient.postcode, lenient.corrections), (parsed(code), 0));
319        }
320    }
321
322    #[test]
323    fn accepts_every_input_style() {
324        let code = parsed("EK-01-A03-FK-01");
325        assert_eq!(parsed("EK 01 A03 FK 01"), code);
326        assert_eq!(parsed("ek01a03fk01"), code);
327    }
328
329    #[test]
330    fn formats() {
331        let code = parsed("ek01a03fk01");
332        assert_eq!(code.as_str(), "EK01A03FK01");
333        assert_eq!(code.to_string(), "EK-01-A03-FK-01");
334        assert_eq!(format!("[{code:>17}]"), "[  EK-01-A03-FK-01]");
335        assert_eq!(format!("[{:<6}]", Area), "[area  ]");
336        assert_eq!(code.to_spaced(), "EK 01 A03 FK 01");
337        assert_eq!(format!("{code:?}"), "Postcode(EK-01-A03-FK-01)");
338    }
339
340    #[test]
341    fn exposes_segments_and_prefixes() {
342        let code = parsed("LA-11-W06-TC-10");
343        let segments = [
344            code.state(),
345            code.lga(),
346            code.district(),
347            code.area(),
348            code.unit(),
349        ];
350        assert_eq!(segments, ["LA", "11", "W06", "TC", "10"]);
351        assert_eq!(code.prefix(Segment::State), "LA");
352        assert_eq!(code.prefix(Segment::District), "LA-11-W06");
353        assert_eq!(code.prefix(Segment::Area), "LA-11-W06-TC");
354    }
355
356    #[test]
357    fn from_segments_zero_fills_numbers_only() {
358        let built = Postcode::from_segments("ek", " 1", "a03", "fk", "1");
359        assert_eq!(built, Ok(parsed("EK-01-A03-FK-01")));
360
361        let rejects = |segments: [&str; 5], segment| {
362            let [state, lga, district, area, unit] = segments;
363            let built = Postcode::from_segments(state, lga, district, area, unit);
364            assert_eq!(built, Err(ParseError::Segment(segment)));
365        };
366        rejects(["E", "1", "A03", "FK", "1"], Segment::State);
367        rejects(["EK", "001", "A03", "FK", "1"], Segment::Lga);
368        rejects(["EK", "1", "A3", "FK", "1"], Segment::District);
369        rejects(["EK", "1", "A03", "F-", "1"], Segment::Area);
370        rejects(["EK", "1", "A03", "FK", ""], Segment::Unit);
371    }
372
373    #[test]
374    fn rejects_malformed_input() {
375        let rejects = |input, error| assert_eq!(Postcode::parse(input), Err(error));
376        rejects("", ParseError::Length { found: 0 });
377        rejects("EK-01-A03-FK", ParseError::Length { found: 9 });
378        rejects("EK-01-A03-FK-011", ParseError::Length { found: 12 });
379        rejects(
380            "EK_01-A03-FK-01",
381            ParseError::InvalidCharacter { ch: '_', index: 2 },
382        );
383        rejects(
384            "ÉK-01-A03-FK-01",
385            ParseError::InvalidCharacter { ch: 'É', index: 0 },
386        );
387        rejects("E1-01-A03-FK-01", ParseError::Segment(Segment::State));
388        rejects("EK-0A-A03-FK-01", ParseError::Segment(Segment::Lga));
389        rejects("EK-00-A03-FK-01", ParseError::Segment(Segment::Lga));
390        rejects("EK-01-A03-F7-01", ParseError::Segment(Segment::Area));
391        rejects("EK-01-A03-FK-00", ParseError::Segment(Segment::Unit));
392    }
393
394    #[test]
395    fn lenient_swaps_lookalikes_by_position() {
396        let fixes = |input, code, corrections| {
397            let expected = Corrected {
398                postcode: parsed(code),
399                corrections,
400            };
401            assert_eq!(Postcode::parse_lenient(input), Ok(expected));
402        };
403        fixes("EK-O1-A03-FK-0I", "EK-01-A03-FK-01", 2);
404        fixes("0G-14-T18-8N-l6", "OG-14-T18-BN-16", 3);
405        // The district allows letters and digits, so it is never rewritten.
406        fixes("JI-24-O18-JP-23", "JI-24-O18-JP-23", 0);
407    }
408
409    #[test]
410    fn lenient_rejects_what_it_cannot_fix() {
411        let rejects = |input| {
412            let error = ParseError::Segment(Segment::Lga);
413            assert_eq!(Postcode::parse_lenient(input), Err(error));
414        };
415        rejects("EK-0X-A03-FK-01");
416        rejects("EK-OO-A03-FK-01");
417    }
418
419    #[test]
420    fn orders_by_hierarchy() {
421        let sorted = ["EK-01-A03-FK-01", "EK-01-A03-FK-02", "LA-11-W06-TC-10"].map(parsed);
422        let mut shuffled = [sorted[2], sorted[0], sorted[1]];
423        shuffled.sort();
424        assert_eq!(shuffled, sorted);
425    }
426
427    #[test]
428    fn is_valid_agrees_with_parse() {
429        assert!(is_valid("ek 01 a03 fk 01"));
430        assert!(!is_valid("EK-01-A03"));
431    }
432
433    #[cfg(feature = "serde")]
434    #[test]
435    fn serde_round_trips_through_the_canonical_form() {
436        let code: Postcode = serde_json::from_str("\"ek01a03fk01\"").unwrap();
437        assert_eq!(serde_json::to_string(&code).unwrap(), "\"EK-01-A03-FK-01\"");
438        assert!(serde_json::from_str::<Postcode>("\"EK-01\"").is_err());
439    }
440}