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)]
18pub enum Segment {
19 State,
21 Lga,
23 District,
25 Area,
27 Unit,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
33pub enum ParseError {
34 Length { found: usize },
36 InvalidCharacter { ch: char, index: usize },
38 Segment(Segment),
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub struct Corrected {
45 pub postcode: Postcode,
46 pub corrections: usize,
48}
49
50impl Postcode {
51 pub fn parse(input: &str) -> Result<Self, ParseError> {
53 collect(input).and_then(validate).map(Self)
54 }
55
56 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 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 pub fn as_str(&self) -> &str {
94 std::str::from_utf8(&self.0).expect("postcode bytes are ASCII")
95 }
96
97 pub fn to_spaced(&self) -> String {
99 self.joined(Unit, " ")
100 }
101
102 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
142pub 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 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
230impl 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 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 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}