Skip to main content

geo_kit/
postcode.rs

1//! Postcode newtypes — UK and US.
2
3extern 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/// A validated UK postcode, e.g. `SW1A 1AA`.
13///
14/// Validation:
15/// - uppercase normalized
16/// - space optional on input, stored with single space separating outward and inward
17/// - regex `^[A-Z]{1,2}[0-9][A-Z0-9]? [0-9][A-Z]{2}$` when `regex` feature enabled
18/// - otherwise hand-rolled equivalent
19/// - outward 1–4 alphanum (1–2 letters + digit + optional alphanum), inward exactly `digit + 2 letters`
20#[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    /// Parse and validate a UK postcode.
27    ///
28    /// # Errors
29    ///
30    /// Returns [`GeoError::InvalidPostcode`] if validation fails.
31    pub fn parse(s: &str) -> Result<Self, GeoError> {
32        validate_uk(s)
33    }
34
35    /// Create from an owned string.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`GeoError::InvalidPostcode`] if validation fails.
40    pub fn new(s: String) -> Result<Self, GeoError> {
41        validate_uk(&s)
42    }
43
44    /// Return as string slice (normalized with single space).
45    #[must_use]
46    pub fn as_str(&self) -> &str {
47        &self.0
48    }
49
50    /// Consume and return inner string.
51    #[must_use]
52    pub fn into_inner(self) -> String {
53        self.0
54    }
55}
56
57/// Returns `true` if `s` is a valid UK postcode.
58#[must_use]
59pub fn is_valid_uk_postcode(s: &str) -> bool {
60    validate_uk(s).is_ok()
61}
62
63/// Normalize UK postcode: trim, uppercase, remove all spaces, then re-insert single space before last 3 chars.
64fn normalize_uk(input: &str) -> String {
65    let upper = input.trim().to_ascii_uppercase();
66    // Remove all spaces
67    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    // Normalized form with single space.
86    let normalized = normalize_uk(input);
87
88    // GIR 0AA is the special-case UK postcode ( fertile Giro bank ) that
89    // predates the standard outward/inward pattern and does not match the
90    // regular expression below.
91    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        // Hand-rolled: outward 1-2 letters + digit + optional alnum, inward digit + 2 letters
153        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        // Inward: 3 chars, ^[0-9][A-Z]{2}$
164        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        // Outward: 2-4 chars, ^[A-Z]{1,2}[0-9][A-Z0-9]?$
179        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        // Count leading letters: 1 or 2
187        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        // After letters must be digit
202        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        // Optional trailing alnum
209        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            // Must be uppercase letter or digit (already uppercased, so check not lowercase)
225            if last.is_ascii_lowercase() {
226                return Err(GeoError::InvalidPostcode(alloc::format!(
227                    "outward '{}' must be uppercase alphanumeric",
228                    outward
229                )));
230            }
231        }
232        // Ensure all chars are alphanumeric
233        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/// A validated US ZIP code, e.g. `90210` or `90210-1234`.
286///
287/// Validation: `^\d{5}(-\d{4})?$`
288#[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    /// Parse and validate a US ZIP code.
295    ///
296    /// # Errors
297    ///
298    /// Returns [`GeoError::InvalidUsZip`] if validation fails.
299    pub fn parse(s: &str) -> Result<Self, GeoError> {
300        validate_us(s)
301    }
302
303    /// Create from an owned string.
304    ///
305    /// # Errors
306    ///
307    /// Returns [`GeoError::InvalidUsZip`] if validation fails.
308    pub fn new(s: String) -> Result<Self, GeoError> {
309        validate_us(&s)
310    }
311
312    /// Return as string slice.
313    #[must_use]
314    pub fn as_str(&self) -> &str {
315        &self.0
316    }
317
318    /// Consume and return inner string.
319    #[must_use]
320    pub fn into_inner(self) -> String {
321        self.0
322    }
323}
324
325/// Returns `true` if `s` is a valid US ZIP code.
326#[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            // XXXXX-XXXX
402            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/// Generic postcode covering UK and US variants.
466#[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 postcode.
471    Uk(UkPostcode),
472    /// US ZIP code.
473    Us(UsZipCode),
474}
475
476impl Postcode {
477    /// Parse as UK postcode first, then US ZIP.
478    ///
479    /// # Errors
480    ///
481    /// Returns [`GeoError::InvalidPostcode`] if neither matches.
482    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    /// Return as string slice.
496    #[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        // GIR 0AA is the historic Giro bank postcode — valid despite not
549        // matching the standard outward/inward pattern.
550        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()); // case/space variants
553        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"); // lowercase
561        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()); // inward too short
574        assert!(UkPostcode::parse("12345").is_err());
575        assert!(UkPostcode::parse("SW1A 1A").is_err());
576        assert!(UkPostcode::parse("ZZZ 1AA").is_err()); // too many letters outward
577        assert!(UkPostcode::parse("SW1A 1AAA").is_err());
578        assert!(UkPostcode::parse("SWA 1AA").is_err()); // letter after digit where digit expected
579    }
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}