Skip to main content

stdbr_core/
cep.rs

1//! CEP (Código de Endereçamento Postal) - validation, formatting and generation.
2//!
3//! Brazilian postal codes are 8 digits with no check digit. Validation is
4//! structural (8 numeric characters, optionally formatted as `XXXXX-XXX`)
5//! plus optional region/state lookup by range.
6
7use alloc::string::String;
8use core::fmt;
9
10use crate::rand::{simple_seed, xorshift64};
11use crate::uf::State;
12use crate::util::impl_document_traits;
13
14const CEP_LEN: usize = 8;
15const FORMATTED_DIGIT_POS: [usize; 8] = [0, 1, 2, 3, 4, 6, 7, 8];
16
17/// Postal region mapped by the first digit of a CEP.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[repr(u8)]
20pub enum PostalRegion {
21    GranSaoPaulo = 0,
22    InteriorSaoPaulo = 1,
23    RjEs = 2,
24    Mg = 3,
25    BaSe = 4,
26    PeAlPbRn = 5,
27    CePiMaPaAmAcApRr = 6,
28    DfGoToMtMsRo = 7,
29    PrSc = 8,
30    Rs = 9,
31}
32
33impl PostalRegion {
34    fn from_digit(d: u8) -> Self {
35        assert!(d <= 9, "digit must be 0..=9");
36        // SAFETY: repr(u8) with discriminants 0..=9, d validated above.
37        unsafe { core::mem::transmute(d) }
38    }
39}
40
41/// CEP range `(start, end)` inclusive for a given state.
42fn cep_range(state: State) -> (u32, u32) {
43    match state {
44        State::SP => (1_000_000, 19_999_999),
45        State::RJ => (20_000_000, 28_999_999),
46        State::ES => (29_000_000, 29_999_999),
47        State::MG => (30_000_000, 39_999_999),
48        State::BA => (40_000_000, 48_999_999),
49        State::SE => (49_000_000, 49_999_999),
50        State::PE => (50_000_000, 56_999_999),
51        State::AL => (57_000_000, 57_999_999),
52        State::PB => (58_000_000, 58_999_999),
53        State::RN => (59_000_000, 59_999_999),
54        State::CE => (60_000_000, 63_999_999),
55        State::PI => (64_000_000, 64_999_999),
56        State::MA => (65_000_000, 65_999_999),
57        State::PA => (66_000_000, 68_899_999),
58        State::AM => (69_000_000, 69_299_999),
59        State::AC => (69_900_000, 69_999_999),
60        State::AP => (68_900_000, 68_999_999),
61        State::RR => (69_300_000, 69_399_999),
62        State::DF => (70_000_000, 72_799_999),
63        State::GO => (72_800_000, 76_799_999),
64        State::TO => (77_000_000, 77_999_999),
65        State::MT => (78_000_000, 78_899_999),
66        State::MS => (79_000_000, 79_999_999),
67        State::RO => (76_800_000, 76_999_999),
68        State::PR => (80_000_000, 87_999_999),
69        State::SC => (88_000_000, 89_999_999),
70        State::RS => (90_000_000, 99_999_999),
71    }
72}
73
74/// Determines the state from a CEP numeric value by range lookup.
75fn state_from_cep_value(value: u32) -> Option<State> {
76    for &state in &crate::uf::ALL {
77        let (start, end) = cep_range(state);
78        if value >= start && value <= end {
79            return Some(state);
80        }
81    }
82    None
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum CepError {
87    InvalidLength,
88    InvalidCharacter,
89    InvalidFormat,
90}
91
92impl fmt::Display for CepError {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.write_str(match self {
95            Self::InvalidLength => "CEP must contain exactly 8 digits",
96            Self::InvalidCharacter => "CEP contains invalid characters",
97            Self::InvalidFormat => "CEP format must be #####-### or 8 digits",
98        })
99    }
100}
101
102/// A validated CEP stored as 8 ASCII bytes.
103///
104/// ```
105/// use stdbr_core::cep::{Cep, generate_cep};
106///
107/// let cep = generate_cep();
108/// assert_eq!(cep.as_str().len(), 8);
109/// assert_eq!(cep.to_string().len(), 9); // #####-###
110///
111/// let parsed: Cep = cep.to_string().parse().unwrap();
112/// assert_eq!(cep, parsed);
113/// ```
114#[derive(Clone, Copy, PartialEq, Eq, Hash)]
115pub struct Cep {
116    bytes: [u8; CEP_LEN],
117}
118
119impl Cep {
120    /// Unformatted 8-digit `&str`.
121    pub fn as_str(&self) -> &str {
122        // SAFETY: constructors guarantee ASCII digits only.
123        unsafe { core::str::from_utf8_unchecked(&self.bytes) }
124    }
125
126    /// The 8 numeric digits (0–9).
127    pub fn digits(&self) -> [u8; CEP_LEN] {
128        self.bytes.map(|b| b - b'0')
129    }
130
131    /// Postal region derived from the 1st digit.
132    pub fn postal_region(&self) -> PostalRegion {
133        PostalRegion::from_digit(self.bytes[0] - b'0')
134    }
135
136    /// State lookup by CEP range.
137    pub fn state(&self) -> Option<State> {
138        state_from_cep_value(self.as_u32())
139    }
140
141    /// Formatted as `XXXXX-XXX`.
142    pub fn formatted(&self) -> String {
143        let s = self.as_str();
144        alloc::format!("{}-{}", &s[0..5], &s[5..8])
145    }
146
147    /// Masked: `XXXXX-***`.
148    pub fn masked(&self) -> String {
149        let s = self.as_str();
150        alloc::format!("{}-***", &s[0..5])
151    }
152
153    /// Numeric value of the CEP.
154    fn as_u32(self) -> u32 {
155        let d = self.digits();
156        u32::from(d[0]) * 10_000_000
157            + u32::from(d[1]) * 1_000_000
158            + u32::from(d[2]) * 100_000
159            + u32::from(d[3]) * 10_000
160            + u32::from(d[4]) * 1_000
161            + u32::from(d[5]) * 100
162            + u32::from(d[6]) * 10
163            + u32::from(d[7])
164    }
165
166    fn from_numeric(digits: [u8; CEP_LEN]) -> Self {
167        Self {
168            bytes: digits.map(|d| d + b'0'),
169        }
170    }
171}
172
173impl fmt::Display for Cep {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        let s = self.as_str();
176        write!(f, "{}-{}", &s[0..5], &s[5..8])
177    }
178}
179
180impl_document_traits!(Cep, CepError);
181
182pub fn remove_symbols(cep: &str) -> String {
183    cep.chars().filter(char::is_ascii_digit).collect()
184}
185
186pub fn is_valid(cep: &str) -> bool {
187    let raw = remove_symbols(cep);
188    raw.len() == CEP_LEN
189}
190
191/// Strict validation - accepts `#####-###` or `########` only.
192pub fn is_valid_strict(cep: &str) -> Result<(), CepError> {
193    parse_strict(cep).map(|_| ())
194}
195
196/// Formats as `#####-###`, or `None` if not 8 digits.
197pub fn format_cep(cep: &str) -> Option<String> {
198    let d = remove_symbols(cep);
199    (d.len() == CEP_LEN).then(|| alloc::format!("{}-{}", &d[0..5], &d[5..8]))
200}
201
202/// Generates a random valid CEP as an 8-digit string.
203pub fn generate() -> String {
204    generate_cep().as_str().into()
205}
206
207/// Generates a random valid [`Cep`].
208pub fn generate_cep() -> Cep {
209    let mut seed = simple_seed();
210    let mut digits = [0u8; CEP_LEN];
211    for d in &mut digits {
212        seed = xorshift64(seed);
213        *d = (seed % 10) as u8;
214    }
215    Cep::from_numeric(digits)
216}
217
218/// Generates a random [`Cep`] for a given postal region (1st digit fixed).
219pub fn generate_for_region(region: PostalRegion) -> Cep {
220    let mut seed = simple_seed();
221    let mut digits = [0u8; CEP_LEN];
222    digits[0] = region as u8;
223    for d in &mut digits[1..] {
224        seed = xorshift64(seed);
225        *d = (seed % 10) as u8;
226    }
227    Cep::from_numeric(digits)
228}
229
230/// Generates a random [`Cep`] within the range of a given state.
231pub fn generate_for_state(state: State) -> Cep {
232    let (start, end) = cep_range(state);
233    let mut seed = simple_seed();
234    seed = xorshift64(seed);
235    let range = end - start + 1;
236    #[allow(clippy::cast_possible_truncation)]
237    let value = start + (seed as u32 % range);
238
239    let mut digits = [0u8; CEP_LEN];
240    let mut v = value;
241    for i in (0..CEP_LEN).rev() {
242        digits[i] = (v % 10) as u8;
243        v /= 10;
244    }
245    Cep::from_numeric(digits)
246}
247
248fn parse_strict(s: &str) -> Result<Cep, CepError> {
249    let raw = s.as_bytes();
250
251    match raw.len() {
252        8 => {
253            if !raw.iter().all(u8::is_ascii_digit) {
254                return Err(CepError::InvalidCharacter);
255            }
256        }
257        9 => {
258            if raw[5] != b'-' {
259                return Err(CepError::InvalidFormat);
260            }
261            for &i in &FORMATTED_DIGIT_POS {
262                if !raw[i].is_ascii_digit() {
263                    return Err(CepError::InvalidCharacter);
264                }
265            }
266        }
267        _ => return Err(CepError::InvalidLength),
268    }
269
270    let mut digits = [0u8; CEP_LEN];
271    for (idx, &pos) in FORMATTED_DIGIT_POS.iter().enumerate() {
272        if pos < raw.len() {
273            digits[idx] = raw[pos] - b'0';
274        }
275    }
276
277    // For the 8-char case, positions map 1:1
278    if raw.len() == 8 {
279        for (i, &b) in raw.iter().enumerate() {
280            digits[i] = b - b'0';
281        }
282    }
283
284    Ok(Cep::from_numeric(digits))
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use alloc::string::ToString;
291
292    fn cep_sp() -> Cep {
293        // São Paulo: 01310-100
294        Cep::from_numeric([0, 1, 3, 1, 0, 1, 0, 0])
295    }
296
297    fn cep_rj() -> Cep {
298        // Rio de Janeiro: 20040-020
299        Cep::from_numeric([2, 0, 0, 4, 0, 0, 2, 0])
300    }
301
302    fn cep_rs() -> Cep {
303        // Porto Alegre: 90010-000
304        Cep::from_numeric([9, 0, 0, 1, 0, 0, 0, 0])
305    }
306
307    #[test]
308    fn is_valid_accepts_valid_unformatted() {
309        assert!(is_valid(cep_sp().as_str()));
310        assert!(is_valid(cep_rj().as_str()));
311        assert!(is_valid(cep_rs().as_str()));
312    }
313
314    #[test]
315    fn is_valid_accepts_valid_formatted() {
316        assert!(is_valid(cep_sp().as_ref()));
317        assert!(is_valid(cep_rj().as_ref()));
318    }
319
320    #[test]
321    fn is_valid_lenient_strips_garbage() {
322        let cep = cep_sp();
323        let s = cep.as_str();
324        let garbage = alloc::format!("{}${}", &s[0..5], &s[5..8]);
325        assert!(is_valid(&garbage));
326
327        let padded = alloc::format!("  {}  ", cep_sp());
328        assert!(is_valid(&padded));
329    }
330
331    #[test]
332    fn is_valid_rejects_wrong_length() {
333        assert!(!is_valid(""));
334        assert!(!is_valid("1234567"));
335        assert!(!is_valid("123456789"));
336    }
337
338    #[test]
339    fn is_valid_rejects_no_digits() {
340        assert!(!is_valid("abcdefgh"));
341        assert!(!is_valid("...---"));
342    }
343
344    #[test]
345    fn strict_accepts_valid_unformatted() {
346        assert!(is_valid_strict(cep_sp().as_str()).is_ok());
347        assert!(is_valid_strict(cep_rj().as_str()).is_ok());
348    }
349
350    #[test]
351    fn strict_accepts_valid_formatted() {
352        assert!(is_valid_strict(cep_sp().as_ref()).is_ok());
353        assert!(is_valid_strict(cep_rj().as_ref()).is_ok());
354    }
355
356    #[test]
357    fn strict_rejects_garbage_between_digits() {
358        let cep = cep_sp();
359        let s = cep.as_str();
360        let garbage = alloc::format!("{}${}", &s[0..5], &s[5..8]);
361        assert!(is_valid_strict(&garbage).is_err());
362    }
363
364    #[test]
365    fn strict_rejects_whitespace() {
366        let padded = alloc::format!("  {}  ", cep_sp().as_str());
367        assert_eq!(is_valid_strict(&padded), Err(CepError::InvalidLength));
368    }
369
370    #[test]
371    fn strict_rejects_misplaced_separators() {
372        assert_eq!(is_valid_strict("0131-0100"), Err(CepError::InvalidFormat));
373    }
374
375    #[test]
376    fn strict_rejects_letters() {
377        assert_eq!(is_valid_strict("abcdefgh"), Err(CepError::InvalidCharacter));
378    }
379
380    #[test]
381    fn parse_roundtrip() {
382        let cep = cep_sp();
383        let parsed: Cep = cep.to_string().parse().unwrap();
384        assert_eq!(cep, parsed);
385        assert_eq!(parsed.as_str(), cep.as_str());
386    }
387
388    #[test]
389    fn parse_unformatted() {
390        let cep = cep_sp();
391        let parsed: Cep = cep.as_str().parse().unwrap();
392        assert_eq!(cep, parsed);
393    }
394
395    #[test]
396    fn parse_equality_across_formats() {
397        let from_fmt: Cep = cep_sp().to_string().parse().unwrap();
398        let from_raw: Cep = cep_sp().as_str().parse().unwrap();
399        assert_eq!(from_fmt, from_raw);
400    }
401
402    #[test]
403    fn cep_is_copy() {
404        let a = cep_sp();
405        let b = a;
406        assert_eq!(a, b);
407    }
408
409    #[test]
410    fn cep_as_ref_str() {
411        let cep = cep_sp();
412        let r: &str = cep.as_ref();
413        assert_eq!(r, cep.as_str());
414    }
415
416    #[test]
417    fn debug_format() {
418        let cep = cep_sp();
419        let dbg = alloc::format!("{cep:?}");
420        assert!(dbg.starts_with("Cep("));
421        assert!(dbg.ends_with(')'));
422        assert!(dbg.contains('-'));
423    }
424
425    #[test]
426    fn postal_region() {
427        assert_eq!(cep_sp().postal_region(), PostalRegion::GranSaoPaulo);
428        assert_eq!(cep_rj().postal_region(), PostalRegion::RjEs);
429        assert_eq!(cep_rs().postal_region(), PostalRegion::Rs);
430    }
431
432    #[test]
433    fn state_lookup() {
434        assert_eq!(cep_sp().state(), Some(State::SP));
435        assert_eq!(cep_rj().state(), Some(State::RJ));
436        assert_eq!(cep_rs().state(), Some(State::RS));
437    }
438
439    #[test]
440    fn state_abbreviation() {
441        assert_eq!(State::SP.abbreviation(), "SP");
442        assert_eq!(State::RJ.abbreviation(), "RJ");
443        assert_eq!(State::RS.abbreviation(), "RS");
444        assert_eq!(State::DF.abbreviation(), "DF");
445    }
446
447    #[test]
448    fn formatted() {
449        assert_eq!(cep_sp().formatted(), "01310-100");
450        assert_eq!(cep_rj().formatted(), "20040-020");
451    }
452
453    #[test]
454    fn masked() {
455        assert_eq!(cep_sp().masked(), "01310-***");
456        assert_eq!(cep_rj().masked(), "20040-***");
457    }
458
459    #[test]
460    fn remove_symbols_strips_formatting() {
461        let cep = cep_sp();
462        let formatted = cep.to_string();
463        assert_eq!(remove_symbols(&formatted), cep.as_str());
464        assert_eq!(remove_symbols(cep.as_str()), cep.as_str());
465        assert_eq!(remove_symbols(""), "");
466    }
467
468    #[test]
469    fn format_cep_produces_formatted_output() {
470        let cep = cep_sp();
471        let formatted = cep.to_string();
472        assert_eq!(format_cep(cep.as_str()), Some(formatted.clone()));
473        assert_eq!(format_cep(&formatted), Some(formatted));
474    }
475
476    #[test]
477    fn format_cep_returns_none_on_bad_length() {
478        assert_eq!(format_cep("1234"), None);
479        assert_eq!(format_cep(""), None);
480    }
481
482    #[test]
483    fn format_cep_preserves_leading_zeros() {
484        let cep = cep_sp();
485        let formatted = format_cep(cep.as_str()).unwrap();
486        assert!(formatted.starts_with("01"));
487    }
488
489    #[test]
490    fn generate_produces_valid_ceps() {
491        for _ in 0..100 {
492            let cep = generate();
493            assert_eq!(cep.len(), 8);
494            assert!(is_valid(&cep), "generated invalid CEP: {cep}");
495        }
496    }
497
498    #[test]
499    fn generate_cep_roundtrips() {
500        for _ in 0..100 {
501            let cep = generate_cep();
502            assert!(is_valid(cep.as_str()));
503            let parsed: Cep = cep.as_str().parse().unwrap();
504            assert_eq!(cep, parsed);
505        }
506    }
507
508    #[test]
509    fn generate_for_region_respects_first_digit() {
510        let regions = [
511            PostalRegion::GranSaoPaulo,
512            PostalRegion::InteriorSaoPaulo,
513            PostalRegion::RjEs,
514            PostalRegion::Mg,
515            PostalRegion::BaSe,
516            PostalRegion::PeAlPbRn,
517            PostalRegion::CePiMaPaAmAcApRr,
518            PostalRegion::DfGoToMtMsRo,
519            PostalRegion::PrSc,
520            PostalRegion::Rs,
521        ];
522        for region in regions {
523            let cep = generate_for_region(region);
524            assert_eq!(cep.postal_region(), region);
525            assert!(is_valid(cep.as_str()));
526        }
527    }
528
529    #[test]
530    fn generate_for_state_within_range() {
531        let states = [
532            State::SP,
533            State::RJ,
534            State::MG,
535            State::RS,
536            State::DF,
537            State::AM,
538            State::AC,
539        ];
540        for state in states {
541            for _ in 0..10 {
542                let cep = generate_for_state(state);
543                assert_eq!(
544                    cep.state(),
545                    Some(state),
546                    "CEP {cep} should map to {state:?}"
547                );
548            }
549        }
550    }
551
552    #[test]
553    fn leading_zero_cep() {
554        let cep = cep_sp();
555        assert!(cep.as_str().starts_with('0'));
556        assert!(is_valid(cep.as_str()));
557
558        let parsed: Cep = cep.as_str().parse().unwrap();
559        assert_eq!(parsed.digits()[0], 0);
560    }
561}