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#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
14pub struct Postcode([u8; LEN]);
15
16#[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 State,
26 Lga,
28 District,
30 Area,
32 Unit,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
38pub enum ParseError {
39 Length { found: usize },
41 InvalidCharacter { ch: char, index: usize },
43 Segment(Segment),
45}
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub struct Corrected {
50 pub postcode: Postcode,
51 pub corrections: usize,
53}
54
55impl Postcode {
56 pub fn parse(input: &str) -> Result<Self, ParseError> {
58 collect(input).and_then(validate).map(Self)
59 }
60
61 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 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 pub fn as_str(&self) -> &str {
99 std::str::from_utf8(&self.0).expect("postcode bytes are ASCII")
100 }
101
102 pub fn to_spaced(&self) -> String {
104 self.joined(Unit, " ")
105 }
106
107 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
147pub 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 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
235impl 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 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 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}