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