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