1extern crate alloc;
4
5use alloc::string::{String, ToString};
6use core::fmt;
7use core::ops::Deref;
8use core::str::FromStr;
9
10use crate::error::GeoError;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22#[cfg_attr(feature = "serde", serde(transparent))]
23pub struct UkPostcode(String);
24
25impl UkPostcode {
26 pub fn parse(s: &str) -> Result<Self, GeoError> {
32 validate_uk(s)
33 }
34
35 pub fn new(s: String) -> Result<Self, GeoError> {
41 validate_uk(&s)
42 }
43
44 #[must_use]
46 pub fn as_str(&self) -> &str {
47 &self.0
48 }
49
50 #[must_use]
52 pub fn into_inner(self) -> String {
53 self.0
54 }
55}
56
57#[must_use]
59pub fn is_valid_uk_postcode(s: &str) -> bool {
60 validate_uk(s).is_ok()
61}
62
63fn normalize_uk(input: &str) -> String {
65 let upper = input.trim().to_ascii_uppercase();
66 let compact: String = upper.chars().filter(|c| *c != ' ').collect();
68 if compact.len() <= 3 {
69 return compact;
70 }
71 let split_at = compact.len() - 3;
72 let (outward, inward) = compact.split_at(split_at);
73 alloc::format!("{} {}", outward, inward)
74}
75
76fn validate_uk(input: &str) -> Result<UkPostcode, GeoError> {
77 if input.is_empty() {
78 return Err(GeoError::InvalidPostcode("postcode is empty".to_string()));
79 }
80 if input.contains('\r') || input.contains('\n') || input.contains('\t') {
81 return Err(GeoError::InvalidPostcode(
82 "postcode contains control character".to_string(),
83 ));
84 }
85 let normalized = normalize_uk(input);
87
88 #[cfg(feature = "regex")]
89 {
90 #[cfg(feature = "std")]
91 {
92 use std::sync::OnceLock;
93 static RE: OnceLock<regex::Regex> = OnceLock::new();
94 let re = match RE.get() {
95 Some(r) => r,
96 None => {
97 let init = match regex::Regex::new(r"^[A-Z]{1,2}[0-9][A-Z0-9]? [0-9][A-Z]{2}$") {
98 Ok(r) => r,
99 Err(_) => {
100 return Err(GeoError::InvalidPostcode(
101 "internal regex error".to_string(),
102 ))
103 }
104 };
105 let _ = RE.set(init);
106 match RE.get() {
107 Some(r) => r,
108 None => {
109 return Err(GeoError::InvalidPostcode(
110 "internal regex error".to_string(),
111 ))
112 }
113 }
114 }
115 };
116 if !re.is_match(&normalized) {
117 return Err(GeoError::InvalidPostcode(alloc::format!(
118 "postcode '{}' does not match UK pattern",
119 input
120 )));
121 }
122 }
123 #[cfg(not(feature = "std"))]
124 {
125 let re = match regex::Regex::new(r"^[A-Z]{1,2}[0-9][A-Z0-9]? [0-9][A-Z]{2}$") {
126 Ok(r) => r,
127 Err(_) => {
128 return Err(GeoError::InvalidPostcode(
129 "internal regex error".to_string(),
130 ))
131 }
132 };
133 if !re.is_match(&normalized) {
134 return Err(GeoError::InvalidPostcode(alloc::format!(
135 "postcode '{}' does not match UK pattern",
136 input
137 )));
138 }
139 }
140 Ok(UkPostcode(normalized))
141 }
142
143 #[cfg(not(feature = "regex"))]
144 {
145 let parts: alloc::vec::Vec<&str> = normalized.split(' ').collect();
147 if parts.len() != 2 {
148 return Err(GeoError::InvalidPostcode(alloc::format!(
149 "postcode '{}' must have outward and inward parts",
150 input
151 )));
152 }
153 let outward = parts[0];
154 let inward = parts[1];
155
156 if inward.len() != 3 {
158 return Err(GeoError::InvalidPostcode(alloc::format!(
159 "inward '{}' must be 3 chars (digit + 2 letters)",
160 inward
161 )));
162 }
163 let ib = inward.as_bytes();
164 if !ib[0].is_ascii_digit() || !ib[1].is_ascii_uppercase() || !ib[2].is_ascii_uppercase() {
165 return Err(GeoError::InvalidPostcode(alloc::format!(
166 "inward '{}' must be digit + 2 letters",
167 inward
168 )));
169 }
170
171 if outward.len() < 2 || outward.len() > 4 {
173 return Err(GeoError::InvalidPostcode(alloc::format!(
174 "outward '{}' must be 2-4 chars",
175 outward
176 )));
177 }
178 let ob = outward.as_bytes();
179 let mut letter_count = 0;
181 for &b in ob {
182 if b.is_ascii_uppercase() {
183 letter_count += 1;
184 } else {
185 break;
186 }
187 }
188 if letter_count < 1 || letter_count > 2 {
189 return Err(GeoError::InvalidPostcode(alloc::format!(
190 "outward '{}' must start with 1-2 letters",
191 outward
192 )));
193 }
194 if !ob[letter_count].is_ascii_digit() {
196 return Err(GeoError::InvalidPostcode(alloc::format!(
197 "outward '{}' must have digit after letters",
198 outward
199 )));
200 }
201 let remaining = ob.len() - (letter_count + 1);
203 if remaining > 1 {
204 return Err(GeoError::InvalidPostcode(alloc::format!(
205 "outward '{}' too long",
206 outward
207 )));
208 }
209 if remaining == 1 {
210 let last = ob[ob.len() - 1];
211 if !last.is_ascii_alphanumeric() {
212 return Err(GeoError::InvalidPostcode(alloc::format!(
213 "outward '{}' last char must be alphanumeric",
214 outward
215 )));
216 }
217 if last.is_ascii_lowercase() {
219 return Err(GeoError::InvalidPostcode(alloc::format!(
220 "outward '{}' must be uppercase alphanumeric",
221 outward
222 )));
223 }
224 }
225 for &b in ob {
227 if !b.is_ascii_alphanumeric() {
228 return Err(GeoError::InvalidPostcode(alloc::format!(
229 "outward '{}' must be alphanumeric",
230 outward
231 )));
232 }
233 }
234 Ok(UkPostcode(normalized))
235 }
236}
237
238impl Deref for UkPostcode {
239 type Target = str;
240 fn deref(&self) -> &Self::Target {
241 &self.0
242 }
243}
244
245impl fmt::Display for UkPostcode {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 f.write_str(&self.0)
248 }
249}
250
251impl TryFrom<String> for UkPostcode {
252 type Error = GeoError;
253 fn try_from(value: String) -> Result<Self, Self::Error> {
254 UkPostcode::new(value)
255 }
256}
257
258impl TryFrom<&str> for UkPostcode {
259 type Error = GeoError;
260 fn try_from(value: &str) -> Result<Self, Self::Error> {
261 UkPostcode::parse(value)
262 }
263}
264
265impl FromStr for UkPostcode {
266 type Err = GeoError;
267 fn from_str(s: &str) -> Result<Self, Self::Err> {
268 UkPostcode::parse(s)
269 }
270}
271
272impl AsRef<str> for UkPostcode {
273 fn as_ref(&self) -> &str {
274 &self.0
275 }
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Hash)]
282#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
283#[cfg_attr(feature = "serde", serde(transparent))]
284pub struct UsZipCode(String);
285
286impl UsZipCode {
287 pub fn parse(s: &str) -> Result<Self, GeoError> {
293 validate_us(s)
294 }
295
296 pub fn new(s: String) -> Result<Self, GeoError> {
302 validate_us(&s)
303 }
304
305 #[must_use]
307 pub fn as_str(&self) -> &str {
308 &self.0
309 }
310
311 #[must_use]
313 pub fn into_inner(self) -> String {
314 self.0
315 }
316}
317
318#[must_use]
320pub fn is_valid_us_zip(s: &str) -> bool {
321 validate_us(s).is_ok()
322}
323
324fn validate_us(input: &str) -> Result<UsZipCode, GeoError> {
325 if input.is_empty() {
326 return Err(GeoError::InvalidUsZip("zip is empty".to_string()));
327 }
328 if input.contains('\r') || input.contains('\n') || input.contains(' ') || input.contains('\t') {
329 return Err(GeoError::InvalidUsZip(
330 "zip must not contain whitespace or control".to_string(),
331 ));
332 }
333
334 #[cfg(feature = "regex")]
335 {
336 #[cfg(feature = "std")]
337 {
338 use std::sync::OnceLock;
339 static RE: OnceLock<regex::Regex> = OnceLock::new();
340 let re = match RE.get() {
341 Some(r) => r,
342 None => {
343 let init = match regex::Regex::new(r"^\d{5}(-\d{4})?$") {
344 Ok(r) => r,
345 Err(_) => {
346 return Err(GeoError::InvalidUsZip("internal regex error".to_string()))
347 }
348 };
349 let _ = RE.set(init);
350 match RE.get() {
351 Some(r) => r,
352 None => {
353 return Err(GeoError::InvalidUsZip("internal regex error".to_string()))
354 }
355 }
356 }
357 };
358 if !re.is_match(input) {
359 return Err(GeoError::InvalidUsZip(alloc::format!(
360 "zip '{}' must match XXX or XXXXX-XXXX",
361 input
362 )));
363 }
364 }
365 #[cfg(not(feature = "std"))]
366 {
367 let re = match regex::Regex::new(r"^\d{5}(-\d{4})?$") {
368 Ok(r) => r,
369 Err(_) => return Err(GeoError::InvalidUsZip("internal regex error".to_string())),
370 };
371 if !re.is_match(input) {
372 return Err(GeoError::InvalidUsZip(alloc::format!(
373 "zip '{}' must match XXX or XXXXX-XXXX",
374 input
375 )));
376 }
377 }
378 Ok(UsZipCode(input.to_string()))
379 }
380
381 #[cfg(not(feature = "regex"))]
382 {
383 let bytes = input.as_bytes();
384 if bytes.len() == 5 {
385 if bytes.iter().all(|b| b.is_ascii_digit()) {
386 return Ok(UsZipCode(input.to_string()));
387 }
388 return Err(GeoError::InvalidUsZip(alloc::format!(
389 "zip '{}' must be 5 digits",
390 input
391 )));
392 }
393 if bytes.len() == 10 {
394 if bytes[5] != b'-' {
396 return Err(GeoError::InvalidUsZip(alloc::format!(
397 "zip '{}' must have '-' at position 6",
398 input
399 )));
400 }
401 if bytes[..5].iter().all(|b| b.is_ascii_digit())
402 && bytes[6..].iter().all(|b| b.is_ascii_digit())
403 {
404 return Ok(UsZipCode(input.to_string()));
405 }
406 return Err(GeoError::InvalidUsZip(alloc::format!(
407 "zip '{}' must be 5 digits, hyphen, 4 digits",
408 input
409 )));
410 }
411 Err(GeoError::InvalidUsZip(alloc::format!(
412 "zip '{}' must be 5 digits or 5-4 extended",
413 input
414 )))
415 }
416}
417
418impl Deref for UsZipCode {
419 type Target = str;
420 fn deref(&self) -> &Self::Target {
421 &self.0
422 }
423}
424
425impl fmt::Display for UsZipCode {
426 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427 f.write_str(&self.0)
428 }
429}
430
431impl TryFrom<String> for UsZipCode {
432 type Error = GeoError;
433 fn try_from(value: String) -> Result<Self, Self::Error> {
434 UsZipCode::new(value)
435 }
436}
437
438impl TryFrom<&str> for UsZipCode {
439 type Error = GeoError;
440 fn try_from(value: &str) -> Result<Self, Self::Error> {
441 UsZipCode::parse(value)
442 }
443}
444
445impl FromStr for UsZipCode {
446 type Err = GeoError;
447 fn from_str(s: &str) -> Result<Self, Self::Err> {
448 UsZipCode::parse(s)
449 }
450}
451
452impl AsRef<str> for UsZipCode {
453 fn as_ref(&self) -> &str {
454 &self.0
455 }
456}
457
458#[derive(Debug, Clone, PartialEq, Eq, Hash)]
460#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
461#[cfg_attr(feature = "serde", serde(tag = "type", content = "value"))]
462pub enum Postcode {
463 Uk(UkPostcode),
465 Us(UsZipCode),
467}
468
469impl Postcode {
470 pub fn parse(s: &str) -> Result<Self, GeoError> {
476 if let Ok(uk) = UkPostcode::parse(s) {
477 return Ok(Postcode::Uk(uk));
478 }
479 if let Ok(us) = UsZipCode::parse(s) {
480 return Ok(Postcode::Us(us));
481 }
482 Err(GeoError::InvalidPostcode(alloc::format!(
483 "postcode '{}' is neither valid UK nor US",
484 s
485 )))
486 }
487
488 #[must_use]
490 pub fn as_str(&self) -> &str {
491 match self {
492 Postcode::Uk(x) => x.as_str(),
493 Postcode::Us(x) => x.as_str(),
494 }
495 }
496}
497
498impl fmt::Display for Postcode {
499 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
500 f.write_str(self.as_str())
501 }
502}
503
504impl FromStr for Postcode {
505 type Err = GeoError;
506 fn from_str(s: &str) -> Result<Self, Self::Err> {
507 Postcode::parse(s)
508 }
509}
510
511impl TryFrom<String> for Postcode {
512 type Error = GeoError;
513 fn try_from(value: String) -> Result<Self, Self::Error> {
514 Postcode::parse(&value)
515 }
516}
517
518impl TryFrom<&str> for Postcode {
519 type Error = GeoError;
520 fn try_from(value: &str) -> Result<Self, Self::Error> {
521 Postcode::parse(value)
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn valid_uk_with_space() {
531 assert!(UkPostcode::parse("SW1A 1AA").is_ok());
532 assert!(UkPostcode::parse("EC1A 1BB").is_ok());
533 assert!(UkPostcode::parse("M1 1AE").is_ok());
534 assert!(UkPostcode::parse("B33 8TH").is_ok());
535 assert!(UkPostcode::parse("CR2 6XH").is_ok());
536 assert!(UkPostcode::parse("DN55 1PT").is_ok());
537 }
538
539 #[test]
540 fn valid_uk_without_space_normalizes() {
541 let pc = UkPostcode::parse("SW1A1AA").expect("valid");
542 assert_eq!(pc.as_str(), "SW1A 1AA");
543 let pc2 = UkPostcode::parse("m11ae").expect("valid"); assert_eq!(pc2.as_str(), "M1 1AE");
545 }
546
547 #[test]
548 fn valid_uk_lowercase_normalized() {
549 let pc = UkPostcode::parse("sw1a 1aa").expect("valid");
550 assert_eq!(pc.as_str(), "SW1A 1AA");
551 }
552
553 #[test]
554 fn invalid_uk() {
555 assert!(UkPostcode::parse("").is_err());
556 assert!(UkPostcode::parse("SW1A1A").is_err()); assert!(UkPostcode::parse("12345").is_err());
558 assert!(UkPostcode::parse("SW1A 1A").is_err());
559 assert!(UkPostcode::parse("ZZZ 1AA").is_err()); assert!(UkPostcode::parse("SW1A 1AAA").is_err());
561 assert!(UkPostcode::parse("SWA 1AA").is_err()); }
563
564 #[test]
565 fn valid_us() {
566 assert!(UsZipCode::parse("90210").is_ok());
567 assert!(UsZipCode::parse("12345-6789").is_ok());
568 assert!(UsZipCode::parse("00501").is_ok());
569 }
570
571 #[test]
572 fn invalid_us() {
573 assert!(UsZipCode::parse("").is_err());
574 assert!(UsZipCode::parse("9021").is_err());
575 assert!(UsZipCode::parse("902101").is_err());
576 assert!(UsZipCode::parse("9021A").is_err());
577 assert!(UsZipCode::parse("1234-5678").is_err());
578 assert!(UsZipCode::parse("12345-678").is_err());
579 assert!(UsZipCode::parse(" 90210").is_err());
580 }
581
582 #[test]
583 fn generic_postcode() {
584 assert!(matches!(Postcode::parse("SW1A 1AA").unwrap(), Postcode::Uk(_)));
585 assert!(matches!(Postcode::parse("90210").unwrap(), Postcode::Us(_)));
586 assert!(Postcode::parse("INVALID").is_err());
587 }
588}