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    #[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        // Hand-rolled: outward 1-2 letters + digit + optional alnum, inward digit + 2 letters
146        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        // Inward: 3 chars, ^[0-9][A-Z]{2}$
157        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        // Outward: 2-4 chars, ^[A-Z]{1,2}[0-9][A-Z0-9]?$
172        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        // Count leading letters: 1 or 2
180        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        // After letters must be digit
195        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        // Optional trailing alnum
202        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            // Must be uppercase letter or digit (already uppercased, so check not lowercase)
218            if last.is_ascii_lowercase() {
219                return Err(GeoError::InvalidPostcode(alloc::format!(
220                    "outward '{}' must be uppercase alphanumeric",
221                    outward
222                )));
223            }
224        }
225        // Ensure all chars are alphanumeric
226        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/// A validated US ZIP code, e.g. `90210` or `90210-1234`.
279///
280/// Validation: `^\d{5}(-\d{4})?$`
281#[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    /// Parse and validate a US ZIP code.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`GeoError::InvalidUsZip`] if validation fails.
292    pub fn parse(s: &str) -> Result<Self, GeoError> {
293        validate_us(s)
294    }
295
296    /// Create from an owned string.
297    ///
298    /// # Errors
299    ///
300    /// Returns [`GeoError::InvalidUsZip`] if validation fails.
301    pub fn new(s: String) -> Result<Self, GeoError> {
302        validate_us(&s)
303    }
304
305    /// Return as string slice.
306    #[must_use]
307    pub fn as_str(&self) -> &str {
308        &self.0
309    }
310
311    /// Consume and return inner string.
312    #[must_use]
313    pub fn into_inner(self) -> String {
314        self.0
315    }
316}
317
318/// Returns `true` if `s` is a valid US ZIP code.
319#[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            // XXXXX-XXXX
395            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/// Generic postcode covering UK and US variants.
459#[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 postcode.
464    Uk(UkPostcode),
465    /// US ZIP code.
466    Us(UsZipCode),
467}
468
469impl Postcode {
470    /// Parse as UK postcode first, then US ZIP.
471    ///
472    /// # Errors
473    ///
474    /// Returns [`GeoError::InvalidPostcode`] if neither matches.
475    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    /// Return as string slice.
489    #[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"); // lowercase
544        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()); // inward too short
557        assert!(UkPostcode::parse("12345").is_err());
558        assert!(UkPostcode::parse("SW1A 1A").is_err());
559        assert!(UkPostcode::parse("ZZZ 1AA").is_err()); // too many letters outward
560        assert!(UkPostcode::parse("SW1A 1AAA").is_err());
561        assert!(UkPostcode::parse("SWA 1AA").is_err()); // letter after digit where digit expected
562    }
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}