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::{RandomSource, SeededRng, below_u32, simple_seed};
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#[derive(Clone, Copy)]
42struct CepRange {
43    start: u32,
44    end: u32,
45}
46
47impl CepRange {
48    const fn new(start: u32, end: u32) -> Self {
49        Self { start, end }
50    }
51
52    const fn len(self) -> u32 {
53        self.end - self.start + 1
54    }
55
56    const fn contains(self, value: u32) -> bool {
57        value >= self.start && value <= self.end
58    }
59}
60
61/// Official inclusive CEP segments for a state. Some UFs are interleaved and
62/// therefore cannot be represented by a single minimum/maximum pair.
63static CEP_RANGES: [&[CepRange]; 27] = [
64    &[CepRange::new(69_900_000, 69_999_999)], // AC
65    &[CepRange::new(57_000_000, 57_999_999)], // AL
66    &[
67        CepRange::new(69_000_000, 69_299_999),
68        CepRange::new(69_400_000, 69_899_999),
69    ], // AM
70    &[CepRange::new(68_900_000, 68_999_999)], // AP
71    &[CepRange::new(40_000_000, 48_999_999)], // BA
72    &[CepRange::new(60_000_000, 63_999_999)], // CE
73    &[
74        CepRange::new(70_000_000, 72_799_999),
75        CepRange::new(73_000_000, 73_699_999),
76    ], // DF
77    &[CepRange::new(29_000_000, 29_999_999)], // ES
78    &[
79        CepRange::new(72_800_000, 72_999_999),
80        CepRange::new(73_700_000, 76_799_999),
81    ], // GO
82    &[CepRange::new(65_000_000, 65_999_999)], // MA
83    &[CepRange::new(30_000_000, 39_999_999)], // MG
84    &[CepRange::new(79_000_000, 79_999_999)], // MS
85    &[CepRange::new(78_000_000, 78_899_999)], // MT
86    &[CepRange::new(66_000_000, 68_899_999)], // PA
87    &[CepRange::new(58_000_000, 58_999_999)], // PB
88    &[CepRange::new(50_000_000, 56_999_999)], // PE
89    &[CepRange::new(64_000_000, 64_999_999)], // PI
90    &[CepRange::new(80_000_000, 87_999_999)], // PR
91    &[CepRange::new(20_000_000, 28_999_999)], // RJ
92    &[CepRange::new(59_000_000, 59_999_999)], // RN
93    &[CepRange::new(76_800_000, 76_999_999)], // RO
94    &[CepRange::new(69_300_000, 69_399_999)], // RR
95    &[CepRange::new(90_000_000, 99_999_999)], // RS
96    &[CepRange::new(88_000_000, 89_999_999)], // SC
97    &[CepRange::new(49_000_000, 49_999_999)], // SE
98    &[CepRange::new(1_000_000, 19_999_999)],  // SP
99    &[CepRange::new(77_000_000, 77_999_999)], // TO
100];
101
102fn cep_ranges(state: State) -> &'static [CepRange] {
103    CEP_RANGES[state as usize]
104}
105
106/// Determines the state from a CEP numeric value by range lookup.
107fn state_from_cep_value(value: u32) -> Option<State> {
108    crate::uf::ALL
109        .iter()
110        .find(|&&state| cep_ranges(state).iter().any(|range| range.contains(value)))
111        .copied()
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum CepError {
116    InvalidLength,
117    InvalidCharacter,
118    InvalidFormat,
119}
120
121impl fmt::Display for CepError {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        f.write_str(match self {
124            Self::InvalidLength => "CEP must contain exactly 8 digits",
125            Self::InvalidCharacter => "CEP contains invalid characters",
126            Self::InvalidFormat => "CEP format must be #####-### or 8 digits",
127        })
128    }
129}
130
131impl core::error::Error for CepError {}
132
133/// A validated CEP stored as 8 ASCII bytes.
134///
135/// ```
136/// use stdbr_core::cep::{Cep, generate_cep};
137///
138/// let cep = generate_cep();
139/// assert_eq!(cep.as_str().len(), 8);
140/// assert_eq!(cep.to_string().len(), 9); // #####-###
141///
142/// let parsed: Cep = cep.to_string().parse().unwrap();
143/// assert_eq!(cep, parsed);
144/// ```
145#[derive(Clone, Copy, PartialEq, Eq, Hash)]
146pub struct Cep {
147    bytes: [u8; CEP_LEN],
148}
149
150impl Cep {
151    /// Unformatted 8-digit `&str`.
152    pub fn as_str(&self) -> &str {
153        // SAFETY: constructors guarantee ASCII digits only.
154        unsafe { core::str::from_utf8_unchecked(&self.bytes) }
155    }
156
157    /// The 8 numeric digits (0–9).
158    pub fn digits(&self) -> [u8; CEP_LEN] {
159        self.bytes.map(|b| b - b'0')
160    }
161
162    /// Postal region derived from the 1st digit.
163    pub fn postal_region(&self) -> PostalRegion {
164        PostalRegion::from_digit(self.bytes[0] - b'0')
165    }
166
167    /// State lookup by CEP range.
168    pub fn state(&self) -> Option<State> {
169        state_from_cep_value(self.as_u32())
170    }
171
172    /// Formatted as `XXXXX-XXX`.
173    pub fn formatted(&self) -> String {
174        let s = self.as_str();
175        alloc::format!("{}-{}", &s[0..5], &s[5..8])
176    }
177
178    /// Masked: `XXXXX-***`.
179    pub fn masked(&self) -> String {
180        let s = self.as_str();
181        alloc::format!("{}-***", &s[0..5])
182    }
183
184    /// Numeric value of the CEP.
185    fn as_u32(self) -> u32 {
186        let d = self.digits();
187        u32::from(d[0]) * 10_000_000
188            + u32::from(d[1]) * 1_000_000
189            + u32::from(d[2]) * 100_000
190            + u32::from(d[3]) * 10_000
191            + u32::from(d[4]) * 1_000
192            + u32::from(d[5]) * 100
193            + u32::from(d[6]) * 10
194            + u32::from(d[7])
195    }
196
197    fn from_numeric(digits: [u8; CEP_LEN]) -> Self {
198        Self {
199            bytes: digits.map(|d| d + b'0'),
200        }
201    }
202}
203
204impl fmt::Display for Cep {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        let s = self.as_str();
207        write!(f, "{}-{}", &s[0..5], &s[5..8])
208    }
209}
210
211impl_document_traits!(Cep, CepError);
212
213/// Normalizes a permissive CEP input by retaining only ASCII digits.
214pub fn normalize(cep: &str) -> String {
215    cep.chars().filter(char::is_ascii_digit).collect()
216}
217
218/// Compatibility alias for [`normalize`].
219pub fn remove_symbols(cep: &str) -> String {
220    normalize(cep)
221}
222
223/// Lenient validation that ignores every non-ASCII-digit character.
224pub fn is_valid_lenient(cep: &str) -> bool {
225    normalize(cep).len() == CEP_LEN
226}
227
228/// Compatibility alias for [`is_valid_lenient`].
229///
230/// Use [`is_valid_strict`] when punctuation and whitespace must be rejected.
231pub fn is_valid(cep: &str) -> bool {
232    is_valid_lenient(cep)
233}
234
235/// Strict validation - accepts `#####-###` or `########` only.
236pub fn is_valid_strict(cep: &str) -> Result<(), CepError> {
237    parse_strict(cep).map(|_| ())
238}
239
240/// Formats as `#####-###`, or `None` if not 8 digits.
241pub fn format_cep(cep: &str) -> Option<String> {
242    let d = remove_symbols(cep);
243    (d.len() == CEP_LEN).then(|| alloc::format!("{}-{}", &d[0..5], &d[5..8]))
244}
245
246/// Generates a random valid CEP as an 8-digit string.
247///
248/// This generation is not cryptographically secure.
249pub fn generate() -> String {
250    generate_cep().as_str().into()
251}
252
253/// Generates a random valid [`Cep`].
254///
255/// This generation is not cryptographically secure.
256pub fn generate_cep() -> Cep {
257    let mut rng = SeededRng::new(simple_seed());
258    generate_cep_with_rng(&mut rng)
259}
260
261/// Generates a deterministic [`Cep`] from a seed.
262///
263/// This generation is not cryptographically secure. Seed zero is accepted.
264pub fn generate_cep_with_seed(seed: u64) -> Cep {
265    let mut rng = SeededRng::new(seed);
266    generate_cep_with_rng(&mut rng)
267}
268
269/// Generates a [`Cep`] using an injected random source.
270///
271/// This generation is not cryptographically secure.
272pub fn generate_cep_with_rng<R: RandomSource + ?Sized>(rng: &mut R) -> Cep {
273    cep_from_value(below_u32(rng, 100_000_000))
274}
275
276/// Generates a random [`Cep`] for a given postal region (1st digit fixed).
277///
278/// This generation is not cryptographically secure.
279pub fn generate_for_region(region: PostalRegion) -> Cep {
280    let mut rng = SeededRng::new(simple_seed());
281    generate_for_region_with_rng(&mut rng, region)
282}
283
284/// Generates a deterministic [`Cep`] for a postal region from a seed.
285///
286/// This generation is not cryptographically secure. Seed zero is accepted.
287pub fn generate_for_region_with_seed(seed: u64, region: PostalRegion) -> Cep {
288    let mut rng = SeededRng::new(seed);
289    generate_for_region_with_rng(&mut rng, region)
290}
291
292/// Generates a [`Cep`] for a postal region using an injected random source.
293///
294/// This generation is not cryptographically secure.
295pub fn generate_for_region_with_rng<R: RandomSource + ?Sized>(
296    rng: &mut R,
297    region: PostalRegion,
298) -> Cep {
299    let value = u32::from(region as u8) * 10_000_000 + below_u32(rng, 10_000_000);
300    cep_from_value(value)
301}
302
303/// Generates a random [`Cep`] within the range of a given state.
304///
305/// Each official segment is selected proportionally to its number of CEPs.
306/// This generation is not cryptographically secure.
307pub fn generate_for_state(state: State) -> Cep {
308    let mut rng = SeededRng::new(simple_seed());
309    generate_for_state_with_rng(&mut rng, state)
310}
311
312/// Generates a deterministic [`Cep`] within a state's official segments.
313///
314/// Each segment is weighted by its inclusive size. This generation is not
315/// cryptographically secure. Seed zero is accepted.
316pub fn generate_for_state_with_seed(seed: u64, state: State) -> Cep {
317    let mut rng = SeededRng::new(seed);
318    generate_for_state_with_rng(&mut rng, state)
319}
320
321/// Generates a [`Cep`] within a state's official segments using an injected
322/// random source.
323///
324/// Each segment is weighted by its inclusive size. This generation is not
325/// cryptographically secure.
326pub fn generate_for_state_with_rng<R: RandomSource + ?Sized>(rng: &mut R, state: State) -> Cep {
327    let ranges = cep_ranges(state);
328    let total: u32 = ranges.iter().map(|range| range.len()).sum();
329    let mut offset = below_u32(rng, total);
330
331    for range in ranges {
332        let len = range.len();
333        if offset < len {
334            return cep_from_value(range.start + offset);
335        }
336        offset -= len;
337    }
338
339    unreachable!("CEP range offset must select a segment")
340}
341
342fn cep_from_value(mut value: u32) -> Cep {
343    let mut digits = [0u8; CEP_LEN];
344    for i in (0..CEP_LEN).rev() {
345        digits[i] = (value % 10) as u8;
346        value /= 10;
347    }
348    Cep::from_numeric(digits)
349}
350
351fn parse_strict(s: &str) -> Result<Cep, CepError> {
352    let raw = s.as_bytes();
353
354    match raw.len() {
355        8 => {
356            if !raw.iter().all(u8::is_ascii_digit) {
357                return Err(CepError::InvalidCharacter);
358            }
359        }
360        9 => {
361            if raw[5] != b'-' {
362                return Err(CepError::InvalidFormat);
363            }
364            for &i in &FORMATTED_DIGIT_POS {
365                if !raw[i].is_ascii_digit() {
366                    return Err(CepError::InvalidCharacter);
367                }
368            }
369        }
370        _ => return Err(CepError::InvalidLength),
371    }
372
373    let mut digits = [0u8; CEP_LEN];
374    for (idx, &pos) in FORMATTED_DIGIT_POS.iter().enumerate() {
375        if pos < raw.len() {
376            digits[idx] = raw[pos] - b'0';
377        }
378    }
379
380    // For the 8-char case, positions map 1:1
381    if raw.len() == 8 {
382        for (i, &b) in raw.iter().enumerate() {
383            digits[i] = b - b'0';
384        }
385    }
386
387    Ok(Cep::from_numeric(digits))
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use alloc::string::ToString;
394
395    fn cep_sp() -> Cep {
396        // São Paulo: 01310-100
397        Cep::from_numeric([0, 1, 3, 1, 0, 1, 0, 0])
398    }
399
400    fn cep_rj() -> Cep {
401        // Rio de Janeiro: 20040-020
402        Cep::from_numeric([2, 0, 0, 4, 0, 0, 2, 0])
403    }
404
405    fn cep_rs() -> Cep {
406        // Porto Alegre: 90010-000
407        Cep::from_numeric([9, 0, 0, 1, 0, 0, 0, 0])
408    }
409
410    #[test]
411    fn is_valid_accepts_valid_unformatted() {
412        assert!(is_valid(cep_sp().as_str()));
413        assert!(is_valid(cep_rj().as_str()));
414        assert!(is_valid(cep_rs().as_str()));
415    }
416
417    #[test]
418    fn is_valid_accepts_valid_formatted() {
419        assert!(is_valid(cep_sp().as_ref()));
420        assert!(is_valid(cep_rj().as_ref()));
421    }
422
423    #[test]
424    fn is_valid_lenient_strips_garbage() {
425        let cep = cep_sp();
426        let s = cep.as_str();
427        let garbage = alloc::format!("{}${}", &s[0..5], &s[5..8]);
428        assert!(is_valid(&garbage));
429
430        let padded = alloc::format!("  {}  ", cep_sp());
431        assert!(is_valid(&padded));
432    }
433
434    #[test]
435    fn is_valid_rejects_wrong_length() {
436        assert!(!is_valid(""));
437        assert!(!is_valid("1234567"));
438        assert!(!is_valid("123456789"));
439    }
440
441    #[test]
442    fn is_valid_rejects_no_digits() {
443        assert!(!is_valid("abcdefgh"));
444        assert!(!is_valid("...---"));
445    }
446
447    #[test]
448    fn strict_accepts_valid_unformatted() {
449        assert!(is_valid_strict(cep_sp().as_str()).is_ok());
450        assert!(is_valid_strict(cep_rj().as_str()).is_ok());
451    }
452
453    #[test]
454    fn strict_accepts_valid_formatted() {
455        assert!(is_valid_strict(cep_sp().as_ref()).is_ok());
456        assert!(is_valid_strict(cep_rj().as_ref()).is_ok());
457    }
458
459    #[test]
460    fn strict_rejects_garbage_between_digits() {
461        let cep = cep_sp();
462        let s = cep.as_str();
463        let garbage = alloc::format!("{}${}", &s[0..5], &s[5..8]);
464        assert!(is_valid_strict(&garbage).is_err());
465    }
466
467    #[test]
468    fn strict_rejects_whitespace() {
469        let padded = alloc::format!("  {}  ", cep_sp().as_str());
470        assert_eq!(is_valid_strict(&padded), Err(CepError::InvalidLength));
471    }
472
473    #[test]
474    fn strict_rejects_misplaced_separators() {
475        assert_eq!(is_valid_strict("0131-0100"), Err(CepError::InvalidFormat));
476    }
477
478    #[test]
479    fn strict_rejects_letters() {
480        assert_eq!(is_valid_strict("abcdefgh"), Err(CepError::InvalidCharacter));
481    }
482
483    #[test]
484    fn parse_roundtrip() {
485        let cep = cep_sp();
486        let parsed: Cep = cep.to_string().parse().unwrap();
487        assert_eq!(cep, parsed);
488        assert_eq!(parsed.as_str(), cep.as_str());
489    }
490
491    #[test]
492    fn parse_unformatted() {
493        let cep = cep_sp();
494        let parsed: Cep = cep.as_str().parse().unwrap();
495        assert_eq!(cep, parsed);
496    }
497
498    #[test]
499    fn parse_equality_across_formats() {
500        let from_fmt: Cep = cep_sp().to_string().parse().unwrap();
501        let from_raw: Cep = cep_sp().as_str().parse().unwrap();
502        assert_eq!(from_fmt, from_raw);
503    }
504
505    #[test]
506    fn cep_is_copy() {
507        let a = cep_sp();
508        let b = a;
509        assert_eq!(a, b);
510    }
511
512    #[test]
513    fn cep_as_ref_str() {
514        let cep = cep_sp();
515        let r: &str = cep.as_ref();
516        assert_eq!(r, cep.as_str());
517    }
518
519    #[test]
520    fn debug_format() {
521        let cep = cep_sp();
522        let dbg = alloc::format!("{cep:?}");
523        assert!(dbg.starts_with("Cep("));
524        assert!(dbg.ends_with(')'));
525        assert!(dbg.contains('-'));
526    }
527
528    #[test]
529    fn postal_region() {
530        assert_eq!(cep_sp().postal_region(), PostalRegion::GranSaoPaulo);
531        assert_eq!(cep_rj().postal_region(), PostalRegion::RjEs);
532        assert_eq!(cep_rs().postal_region(), PostalRegion::Rs);
533    }
534
535    #[test]
536    fn state_lookup() {
537        assert_eq!(cep_sp().state(), Some(State::SP));
538        assert_eq!(cep_rj().state(), Some(State::RJ));
539        assert_eq!(cep_rs().state(), Some(State::RS));
540    }
541
542    #[test]
543    fn state_abbreviation() {
544        assert_eq!(State::SP.abbreviation(), "SP");
545        assert_eq!(State::RJ.abbreviation(), "RJ");
546        assert_eq!(State::RS.abbreviation(), "RS");
547        assert_eq!(State::DF.abbreviation(), "DF");
548    }
549
550    #[test]
551    fn formatted() {
552        assert_eq!(cep_sp().formatted(), "01310-100");
553        assert_eq!(cep_rj().formatted(), "20040-020");
554    }
555
556    #[test]
557    fn masked() {
558        assert_eq!(cep_sp().masked(), "01310-***");
559        assert_eq!(cep_rj().masked(), "20040-***");
560    }
561
562    #[test]
563    fn remove_symbols_strips_formatting() {
564        let cep = cep_sp();
565        let formatted = cep.to_string();
566        assert_eq!(remove_symbols(&formatted), cep.as_str());
567        assert_eq!(remove_symbols(cep.as_str()), cep.as_str());
568        assert_eq!(remove_symbols(""), "");
569    }
570
571    #[test]
572    fn format_cep_produces_formatted_output() {
573        let cep = cep_sp();
574        let formatted = cep.to_string();
575        assert_eq!(format_cep(cep.as_str()), Some(formatted.clone()));
576        assert_eq!(format_cep(&formatted), Some(formatted));
577    }
578
579    #[test]
580    fn format_cep_returns_none_on_bad_length() {
581        assert_eq!(format_cep("1234"), None);
582        assert_eq!(format_cep(""), None);
583    }
584
585    #[test]
586    fn format_cep_preserves_leading_zeros() {
587        let cep = cep_sp();
588        let formatted = format_cep(cep.as_str()).unwrap();
589        assert!(formatted.starts_with("01"));
590    }
591
592    #[test]
593    fn generate_produces_valid_ceps() {
594        for _ in 0..100 {
595            let cep = generate();
596            assert_eq!(cep.len(), 8);
597            assert!(is_valid(&cep), "generated invalid CEP: {cep}");
598        }
599    }
600
601    #[test]
602    fn generate_cep_roundtrips() {
603        for _ in 0..100 {
604            let cep = generate_cep();
605            assert!(is_valid(cep.as_str()));
606            let parsed: Cep = cep.as_str().parse().unwrap();
607            assert_eq!(cep, parsed);
608        }
609    }
610
611    #[test]
612    fn generate_for_region_respects_first_digit() {
613        let regions = [
614            PostalRegion::GranSaoPaulo,
615            PostalRegion::InteriorSaoPaulo,
616            PostalRegion::RjEs,
617            PostalRegion::Mg,
618            PostalRegion::BaSe,
619            PostalRegion::PeAlPbRn,
620            PostalRegion::CePiMaPaAmAcApRr,
621            PostalRegion::DfGoToMtMsRo,
622            PostalRegion::PrSc,
623            PostalRegion::Rs,
624        ];
625        for region in regions {
626            let cep = generate_for_region(region);
627            assert_eq!(cep.postal_region(), region);
628            assert!(is_valid(cep.as_str()));
629        }
630    }
631
632    #[test]
633    fn generate_for_state_within_range() {
634        let states = [
635            State::SP,
636            State::RJ,
637            State::MG,
638            State::RS,
639            State::DF,
640            State::AM,
641            State::AC,
642        ];
643        for state in states {
644            for _ in 0..10 {
645                let cep = generate_for_state(state);
646                assert_eq!(
647                    cep.state(),
648                    Some(state),
649                    "CEP {cep} should map to {state:?}"
650                );
651            }
652        }
653    }
654
655    #[test]
656    fn leading_zero_cep() {
657        let cep = cep_sp();
658        assert!(cep.as_str().starts_with('0'));
659        assert!(is_valid(cep.as_str()));
660
661        let parsed: Cep = cep.as_str().parse().unwrap();
662        assert_eq!(parsed.digits()[0], 0);
663    }
664}