cloakrs_patterns/
physical_address.rs1use 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#[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}