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