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 did not end where a segment does: after 2, 4, 7, 9 or 11 letters and digits.
38    PrefixLength { found: usize },
39    /// The input held something other than letters, digits, spaces and hyphens.
40    InvalidCharacter { ch: char, index: usize },
41    /// A segment has the wrong shape, such as digits in the state.
42    Segment(Segment),
43}
44
45/// The result of [`Postcode::parse_lenient`].
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47#[non_exhaustive]
48pub struct Corrected {
49    pub postcode: Postcode,
50    /// How many characters were swapped for their look-alike.
51    pub corrections: usize,
52}
53
54impl Postcode {
55    /// Parses a hyphenated, spaced or compact code in either case.
56    pub fn parse(input: &str) -> Result<Self, ParseError> {
57        collect(input).and_then(validate).map(Self)
58    }
59
60    /// Parses like [`parse`](Self::parse) after swapping look-alike characters
61    /// that cannot occur where they stand: `0 1 5 8` for `O I S B` where a
62    /// letter is required, and the reverse (plus `L` for `1`) where a digit is.
63    ///
64    /// The result is well formed but may not be the code the user meant, so
65    /// confirm it with them when `corrections` is not zero.
66    pub fn parse_lenient(input: &str) -> Result<Corrected, ParseError> {
67        let raw = collect(input)?;
68        let fixed: [u8; LEN] =
69            std::array::from_fn(|i| Segment::at(i).map_or(raw[i], |s| s.unconfuse(raw[i])));
70        let corrections = raw.iter().zip(&fixed).filter(|(a, b)| a != b).count();
71        validate(fixed).map(|bytes| Corrected {
72            postcode: Self(bytes),
73            corrections,
74        })
75    }
76
77    /// Builds a code from its segments, zero-filling the LGA and unit so that
78    /// `"1"` becomes `"01"`.
79    pub fn from_segments(
80        state: &str,
81        lga: &str,
82        district: &str,
83        area: &str,
84        unit: &str,
85    ) -> Result<Self, ParseError> {
86        let parts = [
87            State.padded(state)?,
88            Lga.padded(lga)?,
89            District.padded(district)?,
90            Area.padded(area)?,
91            Unit.padded(unit)?,
92        ];
93        Self::parse(&parts.concat())
94    }
95
96    /// The compact form, `EK01A03FK01`. Store and compare this one.
97    pub fn as_str(&self) -> &str {
98        std::str::from_utf8(&self.0).expect("postcode bytes are ASCII")
99    }
100
101    /// The spaced form shown to people, `EK 01 A03 FK 01`.
102    pub fn to_spaced(&self) -> String {
103        self.joined(Unit, " ")
104    }
105
106    /// The hyphenated code down to and including `through`, so
107    /// `prefix(Segment::Area)` is `EK-01-A03-FK`.
108    pub fn prefix(&self, through: Segment) -> String {
109        self.joined(through, "-")
110    }
111
112    /// The code cut off after `through`, as a value: `truncate(Segment::Area)` is the
113    /// area this building is in.
114    pub fn truncate(&self, through: Segment) -> Prefix {
115        let mut bytes = self.0;
116        bytes[through.range().end..].fill(0);
117        Prefix { bytes, through }
118    }
119
120    pub fn segment(&self, segment: Segment) -> &str {
121        &self.as_str()[segment.range()]
122    }
123
124    pub fn state(&self) -> &str {
125        self.segment(State)
126    }
127
128    pub fn lga(&self) -> &str {
129        self.segment(Lga)
130    }
131
132    pub fn district(&self) -> &str {
133        self.segment(District)
134    }
135
136    pub fn area(&self) -> &str {
137        self.segment(Area)
138    }
139
140    pub fn unit(&self) -> &str {
141        self.segment(Unit)
142    }
143
144    fn joined(&self, through: Segment, separator: &str) -> String {
145        Segment::ALL
146            .into_iter()
147            .take_while(|&s| s <= through)
148            .map(|s| self.segment(s))
149            .collect::<Vec<_>>()
150            .join(separator)
151    }
152}
153
154/// A postcode cut off after one of its segments, such as the district `EK-01-A03`.
155///
156/// It names every code that starts with it, so it serves to group or select codes by
157/// state, LGA, district or area. A whole code is the narrowest prefix.
158#[derive(Clone, Copy, PartialEq, Eq, Hash)]
159pub struct Prefix {
160    // Zero after the last segment.
161    bytes: [u8; LEN],
162    through: Segment,
163}
164
165impl Prefix {
166    /// Parses a hyphenated, spaced or compact prefix in either case.
167    pub fn parse(input: &str) -> Result<Self, ParseError> {
168        let (bytes, found) = scan(input)?;
169        let through = Segment::ALL
170            .into_iter()
171            .find(|s| s.range().end == found)
172            .ok_or(ParseError::PrefixLength { found })?;
173        match (Segment::ALL.into_iter())
174            .take_while(|&s| s <= through)
175            .find(|s| !s.accepts(&bytes[s.range()]))
176        {
177            Some(segment) => Err(ParseError::Segment(segment)),
178            None => Ok(Self { bytes, through }),
179        }
180    }
181
182    /// The last segment the prefix holds.
183    pub fn through(&self) -> Segment {
184        self.through
185    }
186
187    /// The compact form, `EK01A03`. Store and compare this one.
188    pub fn as_str(&self) -> &str {
189        std::str::from_utf8(&self.bytes[..self.through.range().end])
190            .expect("prefix bytes are ASCII")
191    }
192
193    /// Whether `code` starts with this prefix.
194    pub fn contains(&self, code: &Postcode) -> bool {
195        code.truncate(self.through) == *self
196    }
197
198    /// The prefix one segment shorter, or `None` for a state.
199    pub fn parent(&self) -> Option<Self> {
200        let through = (Segment::ALL.into_iter()).rfind(|&s| s < self.through)?;
201        Some(Postcode(self.bytes).truncate(through))
202    }
203}
204
205/// Whether `input` is a well-formed postcode.
206pub fn is_valid(input: &str) -> bool {
207    Postcode::parse(input).is_ok()
208}
209
210impl Segment {
211    const ALL: [Self; 5] = [State, Lga, District, Area, Unit];
212
213    const fn range(self) -> Range<usize> {
214        match self {
215            State => 0..2,
216            Lga => 2..4,
217            District => 4..7,
218            Area => 7..9,
219            Unit => 9..11,
220        }
221    }
222
223    fn at(index: usize) -> Option<Self> {
224        Self::ALL.into_iter().find(|s| s.range().contains(&index))
225    }
226
227    fn accepts(self, bytes: &[u8]) -> bool {
228        match self {
229            State | Area => bytes.iter().all(u8::is_ascii_uppercase),
230            // 00 is never issued.
231            Lga | Unit => bytes.iter().all(u8::is_ascii_digit) && bytes != b"00",
232            District => bytes.iter().all(u8::is_ascii_alphanumeric),
233        }
234    }
235
236    fn unconfuse(self, byte: u8) -> u8 {
237        match (self, byte) {
238            (State | Area, b'0') => b'O',
239            (State | Area, b'1') => b'I',
240            (State | Area, b'5') => b'S',
241            (State | Area, b'8') => b'B',
242            (Lga | Unit, b'O') => b'0',
243            (Lga | Unit, b'I' | b'L') => b'1',
244            (Lga | Unit, b'S') => b'5',
245            (Lga | Unit, b'B') => b'8',
246            _ => byte,
247        }
248    }
249
250    fn padded(self, value: &str) -> Result<String, ParseError> {
251        let (value, width) = (value.trim(), self.range().len());
252        let shortest = if matches!(self, Lga | Unit) { 1 } else { width };
253        let fits = (shortest..=width).contains(&value.len())
254            && value.bytes().all(|b| b.is_ascii_alphanumeric());
255        if fits {
256            Ok(format!("{value:0>width$}"))
257        } else {
258            Err(ParseError::Segment(self))
259        }
260    }
261}
262
263fn collect(input: &str) -> Result<[u8; LEN], ParseError> {
264    match scan(input)? {
265        (bytes, LEN) => Ok(bytes),
266        (_, found) => Err(ParseError::Length { found }),
267    }
268}
269
270/// The letters and digits of `input` in upper case, and how many there were.
271fn scan(input: &str) -> Result<([u8; LEN], usize), ParseError> {
272    // Every character is checked, but only a postcode's worth is held: no allocation.
273    let mut bytes = [0; LEN];
274    let mut found = 0;
275    for (index, ch) in input.char_indices() {
276        if ch == ' ' || ch == '-' {
277            continue;
278        }
279        if !ch.is_ascii_alphanumeric() {
280            return Err(ParseError::InvalidCharacter { ch, index });
281        }
282        if let Some(slot) = bytes.get_mut(found) {
283            *slot = ch.to_ascii_uppercase() as u8;
284        }
285        found += 1;
286    }
287    Ok((bytes, found))
288}
289
290fn validate(bytes: [u8; LEN]) -> Result<[u8; LEN], ParseError> {
291    match Segment::ALL
292        .into_iter()
293        .find(|s| !s.accepts(&bytes[s.range()]))
294    {
295        Some(segment) => Err(ParseError::Segment(segment)),
296        None => Ok(bytes),
297    }
298}
299
300impl FromStr for Postcode {
301    type Err = ParseError;
302
303    fn from_str(s: &str) -> Result<Self, Self::Err> {
304        Self::parse(s)
305    }
306}
307
308/// The canonical hyphenated form, `EK-01-A03-FK-01`.
309impl fmt::Display for Postcode {
310    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
311        f.pad(&self.prefix(Unit))
312    }
313}
314
315impl fmt::Debug for Postcode {
316    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317        write!(f, "Postcode({self})")
318    }
319}
320
321impl FromStr for Prefix {
322    type Err = ParseError;
323
324    fn from_str(s: &str) -> Result<Self, Self::Err> {
325        Self::parse(s)
326    }
327}
328
329impl From<Postcode> for Prefix {
330    fn from(code: Postcode) -> Self {
331        code.truncate(Unit)
332    }
333}
334
335/// The hyphenated form, `EK-01-A03`.
336impl fmt::Display for Prefix {
337    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338        f.pad(&Postcode(self.bytes).prefix(self.through))
339    }
340}
341
342impl fmt::Debug for Prefix {
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        write!(f, "Prefix({self})")
345    }
346}
347
348/// By compact form, so a prefix sorts just before the codes it contains.
349impl Ord for Prefix {
350    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
351        self.as_str().cmp(other.as_str())
352    }
353}
354
355impl PartialOrd for Prefix {
356    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
357        Some(self.cmp(other))
358    }
359}
360
361impl fmt::Display for Segment {
362    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
363        f.pad(match self {
364            State => "state",
365            Lga => "lga",
366            District => "district",
367            Area => "area",
368            Unit => "unit",
369        })
370    }
371}
372
373impl fmt::Display for ParseError {
374    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
375        match self {
376            Self::Length { found } => write!(f, "expected {LEN} letters and digits, found {found}"),
377            Self::PrefixLength { found } => {
378                write!(
379                    f,
380                    "expected 2, 4, 7, 9 or 11 letters and digits, found {found}"
381                )
382            }
383            Self::InvalidCharacter { ch, index } => {
384                write!(f, "invalid character '{ch}' at index {index}")
385            }
386            Self::Segment(segment) => write!(f, "invalid {segment} segment"),
387        }
388    }
389}
390
391impl std::error::Error for ParseError {}
392
393#[cfg(feature = "serde")]
394impl serde::Serialize for Postcode {
395    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
396        serializer.collect_str(self)
397    }
398}
399
400#[cfg(feature = "serde")]
401impl<'de> serde::Deserialize<'de> for Postcode {
402    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
403        let text = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
404        text.parse().map_err(serde::de::Error::custom)
405    }
406}
407
408#[cfg(feature = "serde")]
409impl serde::Serialize for Prefix {
410    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
411        serializer.collect_str(self)
412    }
413}
414
415#[cfg(feature = "serde")]
416impl<'de> serde::Deserialize<'de> for Prefix {
417    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
418        let text = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
419        text.parse().map_err(serde::de::Error::custom)
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    // The test postcodes published in the NIPOST API docs.
428    const PUBLISHED: &str = "\
429        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 \
430        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 \
431        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 \
432        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 \
433        OG-14-M82-QA-09";
434
435    fn parsed(code: &str) -> Postcode {
436        Postcode::parse(code).unwrap()
437    }
438
439    #[test]
440    fn published_codes_round_trip_unchanged() {
441        assert_eq!(PUBLISHED.split_whitespace().count(), 21);
442        for code in PUBLISHED.split_whitespace() {
443            assert_eq!(parsed(code).to_string(), code);
444            let lenient = Postcode::parse_lenient(code).unwrap();
445            assert_eq!((lenient.postcode, lenient.corrections), (parsed(code), 0));
446        }
447    }
448
449    #[test]
450    fn accepts_every_input_style() {
451        let code = parsed("EK-01-A03-FK-01");
452        assert_eq!(parsed("EK 01 A03 FK 01"), code);
453        assert_eq!(parsed("ek01a03fk01"), code);
454    }
455
456    #[test]
457    fn formats() {
458        let code = parsed("ek01a03fk01");
459        assert_eq!(code.as_str(), "EK01A03FK01");
460        assert_eq!(code.to_string(), "EK-01-A03-FK-01");
461        assert_eq!(format!("[{code:>17}]"), "[  EK-01-A03-FK-01]");
462        assert_eq!(format!("[{:<6}]", Area), "[area  ]");
463        assert_eq!(code.to_spaced(), "EK 01 A03 FK 01");
464        assert_eq!(format!("{code:?}"), "Postcode(EK-01-A03-FK-01)");
465    }
466
467    #[test]
468    fn exposes_segments_and_prefixes() {
469        let code = parsed("LA-11-W06-TC-10");
470        let segments = [
471            code.state(),
472            code.lga(),
473            code.district(),
474            code.area(),
475            code.unit(),
476        ];
477        assert_eq!(segments, ["LA", "11", "W06", "TC", "10"]);
478        assert_eq!(code.prefix(Segment::State), "LA");
479        assert_eq!(code.prefix(Segment::District), "LA-11-W06");
480        assert_eq!(code.prefix(Segment::Area), "LA-11-W06-TC");
481    }
482
483    #[test]
484    fn from_segments_zero_fills_numbers_only() {
485        let built = Postcode::from_segments("ek", " 1", "a03", "fk", "1");
486        assert_eq!(built, Ok(parsed("EK-01-A03-FK-01")));
487
488        let rejects = |segments: [&str; 5], segment| {
489            let [state, lga, district, area, unit] = segments;
490            let built = Postcode::from_segments(state, lga, district, area, unit);
491            assert_eq!(built, Err(ParseError::Segment(segment)));
492        };
493        rejects(["E", "1", "A03", "FK", "1"], Segment::State);
494        rejects(["EK", "001", "A03", "FK", "1"], Segment::Lga);
495        rejects(["EK", "1", "A3", "FK", "1"], Segment::District);
496        rejects(["EK", "1", "A03", "F-", "1"], Segment::Area);
497        rejects(["EK", "1", "A03", "FK", ""], Segment::Unit);
498    }
499
500    #[test]
501    fn rejects_malformed_input() {
502        let rejects = |input, error| assert_eq!(Postcode::parse(input), Err(error));
503        rejects("", ParseError::Length { found: 0 });
504        rejects("EK-01-A03-FK", ParseError::Length { found: 9 });
505        rejects("EK-01-A03-FK-011", ParseError::Length { found: 12 });
506        rejects(
507            "EK_01-A03-FK-01",
508            ParseError::InvalidCharacter { ch: '_', index: 2 },
509        );
510        rejects(
511            "ÉK-01-A03-FK-01",
512            ParseError::InvalidCharacter { ch: 'É', index: 0 },
513        );
514        rejects("E1-01-A03-FK-01", ParseError::Segment(Segment::State));
515        rejects("EK-0A-A03-FK-01", ParseError::Segment(Segment::Lga));
516        rejects("EK-00-A03-FK-01", ParseError::Segment(Segment::Lga));
517        rejects("EK-01-A03-F7-01", ParseError::Segment(Segment::Area));
518        rejects("EK-01-A03-FK-00", ParseError::Segment(Segment::Unit));
519    }
520
521    #[test]
522    fn lenient_swaps_lookalikes_by_position() {
523        let fixes = |input, code, corrections| {
524            let expected = Corrected {
525                postcode: parsed(code),
526                corrections,
527            };
528            assert_eq!(Postcode::parse_lenient(input), Ok(expected));
529        };
530        fixes("EK-O1-A03-FK-0I", "EK-01-A03-FK-01", 2);
531        fixes("0G-14-T18-8N-l6", "OG-14-T18-BN-16", 3);
532        // The district allows letters and digits, so it is never rewritten.
533        fixes("JI-24-O18-JP-23", "JI-24-O18-JP-23", 0);
534    }
535
536    #[test]
537    fn lenient_rejects_what_it_cannot_fix() {
538        let rejects = |input| {
539            let error = ParseError::Segment(Segment::Lga);
540            assert_eq!(Postcode::parse_lenient(input), Err(error));
541        };
542        rejects("EK-0X-A03-FK-01");
543        rejects("EK-OO-A03-FK-01");
544    }
545
546    #[test]
547    fn orders_by_hierarchy() {
548        let sorted = ["EK-01-A03-FK-01", "EK-01-A03-FK-02", "LA-11-W06-TC-10"].map(parsed);
549        let mut shuffled = [sorted[2], sorted[0], sorted[1]];
550        shuffled.sort();
551        assert_eq!(shuffled, sorted);
552    }
553
554    #[test]
555    fn is_valid_agrees_with_parse() {
556        assert!(is_valid("ek 01 a03 fk 01"));
557        assert!(!is_valid("EK-01-A03"));
558    }
559
560    #[cfg(feature = "serde")]
561    #[test]
562    fn serde_round_trips_through_the_canonical_form() {
563        let code: Postcode = serde_json::from_str("\"ek01a03fk01\"").unwrap();
564        assert_eq!(serde_json::to_string(&code).unwrap(), "\"EK-01-A03-FK-01\"");
565        assert!(serde_json::from_str::<Postcode>("\"EK-01\"").is_err());
566    }
567}