Skip to main content

cloakrs_patterns/
physical_address.rs

1use crate::common::{compile_regex, confidence, context_boost};
2use cloakrs_core::{Confidence, EntityType, Locale, PiiEntity, Recognizer, Span};
3use once_cell::sync::Lazy;
4use regex::Regex;
5
6static ADDRESS_REGEX: Lazy<Regex> = Lazy::new(|| {
7    compile_regex(
8        r"(?x)
9        \b
10        \d{1,6}
11        \s+
12        (?:[A-Z][a-z0-9.'-]*\s+){1,4}
13        (?:St|Street|Ave|Avenue|Blvd|Boulevard|Rd|Road|Dr|Drive|Ln|Lane|Ct|Court|Way|Pl|Place|Terrace|Ter|Circle|Cir|Parkway|Pkwy)
14        \.?
15        (?:\s+(?:Apt|Apartment|Suite|Ste|Unit|\#)\s*[A-Za-z0-9-]+)?
16        (?:,\s*[A-Z][a-zA-Z .'-]+)?
17        (?:,\s*[A-Z]{2})?
18        (?:\s+\d{5}(?:-\d{4})?)?
19        \b",
20    )
21});
22
23const CONTEXT_WORDS: &[&str] = &[
24    "address",
25    "street",
26    "ship to",
27    "billing",
28    "mailing",
29    "residence",
30    "home",
31    "office",
32    "located at",
33    "deliver",
34    "recipient",
35];
36
37/// Recognizes physical street addresses with a US-first rule-based pattern.
38///
39/// # Examples
40///
41/// ```
42/// use cloakrs_core::{EntityType, Recognizer};
43/// use cloakrs_patterns::PhysicalAddressRecognizer;
44///
45/// let findings = PhysicalAddressRecognizer.scan("ship to 123 Main St, Springfield, IL 62704");
46/// assert_eq!(findings[0].entity_type, EntityType::PhysicalAddress);
47/// ```
48#[derive(Debug, Clone, Copy, Default)]
49pub struct PhysicalAddressRecognizer;
50
51impl Recognizer for PhysicalAddressRecognizer {
52    fn id(&self) -> &str {
53        "physical_address_us_v1"
54    }
55
56    fn entity_type(&self) -> EntityType {
57        EntityType::PhysicalAddress
58    }
59
60    fn supported_locales(&self) -> &[Locale] {
61        &[]
62    }
63
64    fn scan(&self, text: &str) -> Vec<PiiEntity> {
65        ADDRESS_REGEX
66            .find_iter(text)
67            .filter_map(|matched| {
68                let span = trim_span(text, Span::new(matched.start(), matched.end()));
69                if span.is_empty() {
70                    return None;
71                }
72                let candidate = &text[span.start..span.end];
73                if !self.validate(candidate) || !valid_boundary(text, span.start, span.end) {
74                    return None;
75                }
76                Some(PiiEntity {
77                    entity_type: self.entity_type(),
78                    span,
79                    text: candidate.to_string(),
80                    confidence: self.compute_confidence(text, span.start, candidate),
81                    recognizer_id: self.id().to_string(),
82                })
83            })
84            .collect()
85    }
86
87    fn validate(&self, candidate: &str) -> bool {
88        let lower = candidate.to_ascii_lowercase();
89        has_street_type(&lower)
90            && candidate.chars().any(|c| c.is_ascii_digit())
91            && candidate.split_whitespace().count() >= 3
92            && !lower.contains(" not found ")
93    }
94}
95
96impl PhysicalAddressRecognizer {
97    fn compute_confidence(&self, text: &str, start: usize, candidate: &str) -> Confidence {
98        let lower = candidate.to_ascii_lowercase();
99        let city_state_zip_boost = if lower.contains(',') || has_zip(candidate) {
100            0.08
101        } else {
102            0.0
103        };
104        confidence(0.70 + city_state_zip_boost + context_boost(text, start, CONTEXT_WORDS))
105    }
106}
107
108fn has_street_type(lower: &str) -> bool {
109    [
110        " st",
111        " street",
112        " ave",
113        " avenue",
114        " blvd",
115        " boulevard",
116        " rd",
117        " road",
118        " dr",
119        " drive",
120        " ln",
121        " lane",
122        " ct",
123        " court",
124        " way",
125        " pl",
126        " place",
127        " terrace",
128        " ter",
129        " circle",
130        " cir",
131        " parkway",
132        " pkwy",
133    ]
134    .iter()
135    .any(|street_type| lower.contains(street_type))
136}
137
138fn has_zip(candidate: &str) -> bool {
139    candidate
140        .split_whitespace()
141        .any(|part| part.len() == 5 && part.chars().all(|c| c.is_ascii_digit()))
142}
143
144fn trim_span(text: &str, span: Span) -> Span {
145    let mut end = span.end;
146    while end > span.start {
147        let Some(ch) = text[span.start..end].chars().next_back() else {
148            break;
149        };
150        if matches!(ch, '.' | ',' | ';' | ')' | ']') {
151            end -= ch.len_utf8();
152        } else {
153            break;
154        }
155    }
156    Span::new(span.start, end)
157}
158
159fn valid_boundary(text: &str, start: usize, end: usize) -> bool {
160    let before = text[..start].chars().next_back();
161    let after = text[end..].chars().next();
162    !before.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
163        && !after.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    fn texts(input: &str) -> Vec<String> {
171        PhysicalAddressRecognizer
172            .scan(input)
173            .into_iter()
174            .map(|finding| finding.text)
175            .collect()
176    }
177
178    #[test]
179    fn test_physical_address_simple_street_detected() {
180        assert_eq!(texts("address 123 Main St"), ["123 Main St"]);
181    }
182
183    #[test]
184    fn test_physical_address_full_us_address_detected() {
185        assert_eq!(
186            texts("ship to 123 Main Street, Springfield, IL 62704"),
187            ["123 Main Street, Springfield, IL 62704"]
188        );
189    }
190
191    #[test]
192    fn test_physical_address_apartment_detected() {
193        assert_eq!(
194            texts("billing 55 Market Ave Apt 4B"),
195            ["55 Market Ave Apt 4B"]
196        );
197    }
198
199    #[test]
200    fn test_physical_address_hash_unit_detected() {
201        assert_eq!(texts("office 9 Oak Road #12"), ["9 Oak Road #12"]);
202    }
203
204    #[test]
205    fn test_physical_address_boulevard_detected() {
206        assert_eq!(texts("home 700 Sunset Blvd"), ["700 Sunset Blvd"]);
207    }
208
209    #[test]
210    fn test_physical_address_no_number_rejected() {
211        assert!(texts("Main Street").is_empty());
212    }
213
214    #[test]
215    fn test_physical_address_no_street_type_rejected() {
216        assert!(texts("123 Main Building").is_empty());
217    }
218
219    #[test]
220    fn test_physical_address_lowercase_street_name_rejected() {
221        assert!(texts("address 123 main st").is_empty());
222    }
223
224    #[test]
225    fn test_physical_address_embedded_word_rejected() {
226        assert!(texts("x123 Main Sty").is_empty());
227    }
228
229    #[test]
230    fn test_physical_address_trailing_punctuation_trimmed() {
231        assert_eq!(texts("address 123 Main St."), ["123 Main St"]);
232    }
233
234    #[test]
235    fn test_physical_address_validate_accepts_address() {
236        assert!(PhysicalAddressRecognizer.validate("123 Main St"));
237    }
238
239    #[test]
240    fn test_physical_address_validate_rejects_sentence() {
241        assert!(!PhysicalAddressRecognizer.validate("123 Main Building"));
242    }
243
244    #[test]
245    fn test_physical_address_context_boosts_confidence() {
246        let with_context = PhysicalAddressRecognizer.scan("mailing address 123 Main St");
247        let without_context = PhysicalAddressRecognizer.scan("value 123 Main St");
248        assert!(with_context[0].confidence > without_context[0].confidence);
249    }
250
251    #[test]
252    fn test_physical_address_zip_boosts_confidence() {
253        let with_zip = PhysicalAddressRecognizer.scan("value 123 Main St 62704");
254        let without_zip = PhysicalAddressRecognizer.scan("value 123 Main St");
255        assert!(with_zip[0].confidence > without_zip[0].confidence);
256    }
257
258    #[test]
259    fn test_physical_address_supported_locales_are_universal() {
260        assert!(PhysicalAddressRecognizer.supported_locales().is_empty());
261    }
262}