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