1use gaze_types::{Candidate, ConflictTier, DetectContext, LocaleTag, PiiClass, Recognizer};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum AnchoredBoundary {
5 Punctuation,
6 Whitespace,
7 LineEnd,
8}
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum NameShape {
12 PersonName,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum CuePosition {
17 Before,
18 After,
19}
20
21pub struct AnchoredMatchRecognizer {
29 id: String,
30 cues: Vec<String>,
31 boundary: AnchoredBoundary,
32 right_window_chars: u16,
33 name_shape: NameShape,
34 cue_position: CuePosition,
35 source: String,
36 score: f32,
37 priority: i32,
38 token_family: String,
39 locales: Vec<LocaleTag>,
40 min_components: usize,
41}
42
43impl AnchoredMatchRecognizer {
44 #[allow(clippy::too_many_arguments)]
45 pub fn new(
46 id: String,
47 cues: Vec<String>,
48 boundary: AnchoredBoundary,
49 right_window_chars: u16,
50 name_shape: NameShape,
51 cue_position: CuePosition,
52 source_short_label: String,
53 min_components: usize,
54 score: f32,
55 priority: i32,
56 ) -> Self {
57 let mut cues = cues
58 .into_iter()
59 .filter(|cue| !cue.trim().is_empty())
60 .collect::<Vec<_>>();
61 cues.sort_by_key(|cue| std::cmp::Reverse(cue.chars().count()));
62 Self {
63 id,
64 cues,
65 boundary,
66 right_window_chars,
67 name_shape,
68 cue_position,
69 source: format!("structural.{source_short_label}"),
70 score,
71 priority,
72 token_family: "name.counter".to_string(),
73 locales: vec![LocaleTag::Global],
74 min_components,
75 }
76 }
77
78 fn candidate_after_cue(&self, input: &str, cue_end: usize) -> Option<std::ops::Range<usize>> {
79 let window = bounded_window_after(input, cue_end, self.right_window_chars);
80 let window = if matches!(self.boundary, AnchoredBoundary::LineEnd) {
81 window
82 .split_once(['\n', '\r'])
83 .map_or(window, |(line, _)| line)
84 } else {
85 window
86 };
87 let relative = is_person_name_candidate_with_min(window, self.min_components)?;
88 Some((cue_end + relative.start)..(cue_end + relative.end))
89 }
90
91 fn candidate_before_cue(
92 &self,
93 input: &str,
94 cue_start: usize,
95 ) -> Option<std::ops::Range<usize>> {
96 let window = bounded_window_before(input, cue_start, self.right_window_chars);
97 let relative = last_person_name_candidate_with_min(window, self.min_components)?;
98 let window_start = cue_start - window.len();
99 Some((window_start + relative.start)..(window_start + relative.end))
100 }
101}
102
103impl Recognizer for AnchoredMatchRecognizer {
104 fn id(&self) -> &str {
105 &self.id
106 }
107
108 fn supported_class(&self) -> &PiiClass {
109 &PiiClass::Name
110 }
111
112 fn detect(&self, input: &str, _ctx: &DetectContext<'_>) -> Vec<Candidate> {
113 if !matches!(self.name_shape, NameShape::PersonName) {
114 return Vec::new();
115 }
116
117 let mut candidates = Vec::new();
118 for cue in &self.cues {
119 for cue_range in find_cue_ranges(input, cue) {
120 let span = match self.cue_position {
121 CuePosition::Before => self.candidate_after_cue(input, cue_range.end),
122 CuePosition::After => self.candidate_before_cue(input, cue_range.start),
123 };
124 let Some(span) = span else {
125 continue;
126 };
127 if !self.boundary_allows(input, &span) {
128 continue;
129 }
130 candidates.push(Candidate::new(
131 span,
132 PiiClass::Name,
133 self.id.clone(),
134 self.score,
135 self.priority,
136 None,
137 self.token_family.clone(),
138 self.source.clone(),
139 ConflictTier::None,
140 Vec::new(),
141 ));
142 }
143 }
144 candidates.sort_by_key(|candidate| candidate.span.start);
145 candidates.dedup_by(|a, b| a.span == b.span);
146 candidates
147 }
148
149 fn token_family(&self) -> &str {
150 &self.token_family
151 }
152
153 fn locales(&self) -> &[LocaleTag] {
154 &self.locales
155 }
156}
157
158impl AnchoredMatchRecognizer {
159 fn boundary_allows(&self, input: &str, span: &std::ops::Range<usize>) -> bool {
160 match self.boundary {
161 AnchoredBoundary::Punctuation => input[span.end..]
162 .chars()
163 .find(|ch| !ch.is_whitespace())
164 .is_none_or(|ch| matches!(ch, '.' | ',' | ':' | ';' | ')' | ']' | '>' | '\n')),
165 AnchoredBoundary::Whitespace => input[span.end..]
166 .chars()
167 .next()
168 .is_none_or(|ch| ch.is_whitespace() || matches!(ch, '.' | ',' | ':' | ';')),
169 AnchoredBoundary::LineEnd => input[span.end..]
170 .chars()
171 .next()
172 .is_none_or(|ch| ch == '\n' || ch == '\r'),
173 }
174 }
175}
176
177pub fn is_person_name_candidate(input: &str) -> Option<std::ops::Range<usize>> {
178 is_person_name_candidate_with_min(input, 1)
179}
180
181fn is_person_name_candidate_with_min(
182 input: &str,
183 min_components: usize,
184) -> Option<std::ops::Range<usize>> {
185 for (start, _) in input.char_indices().filter(|(_, ch)| ch.is_uppercase()) {
186 if let Some(span) = person_name_at(input, start, min_components) {
187 return Some(span);
188 }
189 }
190 None
191}
192
193fn last_person_name_candidate_with_min(
194 input: &str,
195 min_components: usize,
196) -> Option<std::ops::Range<usize>> {
197 input
198 .char_indices()
199 .filter(|(_, ch)| ch.is_uppercase())
200 .filter_map(|(start, _)| person_name_at(input, start, min_components))
201 .next_back()
202}
203
204fn person_name_at(
205 input: &str,
206 start: usize,
207 min_components: usize,
208) -> Option<std::ops::Range<usize>> {
209 let mut end = start;
210 let mut components = 0usize;
211 let mut uppercase_components = 0usize;
212 let mut pending_particles: Vec<(usize, usize)> = Vec::new();
213 let mut cursor = start;
214
215 while components < 4 {
216 let Some((token_start, token_end)) = next_space_delimited(input, cursor) else {
217 break;
218 };
219 if token_start != cursor && !input[cursor..token_start].chars().all(char::is_whitespace) {
220 break;
221 }
222 let token = trim_trailing_boundary(&input[token_start..token_end]);
223 let effective_end = token_start + token.len();
224 if token.is_empty() {
225 break;
226 }
227
228 if is_upper_component(token) {
229 components += 1;
230 uppercase_components += 1;
231 pending_particles.clear();
232 end = effective_end;
233 cursor = token_end;
234 continue;
235 }
236
237 if components > 0 && is_particle(token) {
238 pending_particles.push((token_start, effective_end));
239 components += 1;
240 cursor = token_end;
241 continue;
242 }
243 break;
244 }
245
246 if !pending_particles.is_empty() {
247 end = pending_particles[0].0.saturating_sub(1);
248 components = components.saturating_sub(pending_particles.len());
249 }
250
251 let candidate = &input[start..end];
252 if has_organization_suffix(candidate) {
253 return None;
254 }
255 (components >= min_components
256 && uppercase_components > 0
257 && candidate.len() <= 64
258 && !candidate.is_empty())
259 .then_some(start..end)
260}
261
262fn next_space_delimited(input: &str, cursor: usize) -> Option<(usize, usize)> {
263 let start = input[cursor..]
264 .char_indices()
265 .find(|(_, ch)| !ch.is_whitespace())
266 .map(|(offset, _)| cursor + offset)?;
267 let end = input[start..]
268 .char_indices()
269 .find(|(_, ch)| ch.is_whitespace())
270 .map_or(input.len(), |(offset, _)| start + offset);
271 Some((start, end))
272}
273
274fn trim_trailing_boundary(token: &str) -> &str {
275 if token.len() == 2 {
276 let mut chars = token.chars();
277 if chars.next().is_some_and(|ch| ch.is_uppercase()) && chars.next() == Some('.') {
278 return token;
279 }
280 }
281 token.trim_end_matches(['.', ',', ':', ';', ')', ']', '>'])
282}
283
284fn is_upper_component(token: &str) -> bool {
285 if token.len() == 2 {
286 let mut chars = token.chars();
287 if chars.next().is_some_and(|ch| ch.is_uppercase()) && chars.next() == Some('.') {
288 return true;
289 }
290 }
291 let mut chars = token.chars();
292 let Some(first) = chars.next() else {
293 return false;
294 };
295 first.is_uppercase()
296 && !token.chars().all(|ch| ch.is_uppercase())
297 && token.chars().all(|ch| {
298 ch.is_alphabetic() || matches!(ch, '\'' | '-') || (ch == '.' && token.len() == 2)
299 })
300 && token
301 .split(['\'', '-'])
302 .all(|part| part.chars().next().is_some_and(|ch| ch.is_alphabetic()))
303}
304
305fn is_particle(token: &str) -> bool {
306 matches!(token, "de" | "la" | "van" | "der")
307}
308
309fn has_organization_suffix(candidate: &str) -> bool {
310 candidate
311 .split_whitespace()
312 .next_back()
313 .is_some_and(|last| {
314 matches!(
315 trim_trailing_boundary(last),
316 "Corp" | "Corporation" | "Inc" | "LLC" | "GmbH" | "AG"
317 )
318 })
319}
320
321fn find_cue_ranges(input: &str, cue: &str) -> Vec<std::ops::Range<usize>> {
322 let input_lower = input.to_lowercase();
323 let cue_lower = cue.to_lowercase();
324 input_lower
325 .match_indices(&cue_lower)
326 .filter_map(|(start, matched)| {
327 let end = start + matched.len();
328 (is_boundary(input, start, true) && is_boundary(input, end, false))
329 .then_some(start..end)
330 })
331 .collect()
332}
333
334fn is_boundary(input: &str, index: usize, before: bool) -> bool {
335 let ch = if before {
336 input[..index].chars().next_back()
337 } else {
338 input[index..].chars().next()
339 };
340 ch.is_none_or(|ch| !ch.is_alphanumeric() && ch != '_' && ch != '-')
341}
342
343fn bounded_window_after(input: &str, start: usize, max_chars: u16) -> &str {
344 let end = input[start..]
345 .char_indices()
346 .nth(max_chars as usize)
347 .map_or(input.len(), |(offset, _)| start + offset);
348 &input[start..end]
349}
350
351fn bounded_window_before(input: &str, end: usize, max_chars: u16) -> &str {
352 let start = input[..end]
353 .char_indices()
354 .rev()
355 .nth(max_chars as usize)
356 .map_or(0, |(offset, _)| offset);
357 &input[start..end]
358}
359
360#[cfg(test)]
361mod tests {
362 use std::collections::HashMap;
363
364 use gaze_types::{DictionaryBundle, LocaleTag};
365
366 use super::*;
367
368 #[test]
369 fn person_name_shape_accepts_required_matches() {
370 for value in [
371 "Łukasz Müller",
372 "O'Brien",
373 "Alice de la Cruz",
374 "Jean-Paul Sartre",
375 "Müller-Schmidt",
376 "José María García",
377 ] {
378 assert!(
379 is_person_name_candidate(value).is_some(),
380 "{value} should match"
381 );
382 }
383 }
384
385 #[test]
386 fn person_name_shape_rejects_required_non_matches() {
387 for value in ["IBM", "mailgun", "the alice"] {
388 assert!(
389 is_person_name_candidate(value).is_none(),
390 "{value} should not match"
391 );
392 }
393 assert!(is_person_name_candidate_with_min("Acme Corp", 2).is_none());
394 }
395
396 #[test]
397 fn detects_cue_followed_by_name_with_punctuation_boundary() {
398 let recognizer = test_recognizer(
399 "test.from_cue",
400 vec!["from"],
401 AnchoredBoundary::Punctuation,
402 CuePosition::Before,
403 "from",
404 );
405 let hits = recognizer.detect("Hello from Alice Example, please reply.", &ctx());
406
407 assert_eq!(hits.len(), 1);
408 assert_eq!(hits[0].span, 11..24);
409 assert_eq!(hits[0].recognizer_id, "test.from_cue");
410 assert_eq!(hits[0].source, "structural.from");
411 }
412
413 #[test]
414 fn detects_line_end_and_whitespace_bounded_names() {
415 let line_end = test_recognizer(
416 "test.line",
417 vec!["from"],
418 AnchoredBoundary::LineEnd,
419 CuePosition::Before,
420 "from",
421 );
422 assert_eq!(
423 line_end.detect("Forwarded from Alice Example\nBody", &ctx())[0].span,
424 15..28
425 );
426
427 let whitespace = test_recognizer(
428 "test.ws",
429 vec!["reply to"],
430 AnchoredBoundary::Whitespace,
431 CuePosition::Before,
432 "agent_recipient",
433 );
434 assert_eq!(
435 whitespace.detect("Please reply to Alice Example today", &ctx())[0].span,
436 16..29
437 );
438 }
439
440 #[test]
441 fn detects_cue_after_name_and_rejects_window_overflow() {
442 let recognizer = test_recognizer(
443 "test.after",
444 vec!["sent"],
445 AnchoredBoundary::Whitespace,
446 CuePosition::After,
447 "footer",
448 );
449 assert_eq!(
450 recognizer.detect("Alice Example sent this", &ctx())[0].span,
451 0..13
452 );
453
454 let short = AnchoredMatchRecognizer::new(
455 "test.short".to_string(),
456 vec!["from".to_string()],
457 AnchoredBoundary::Punctuation,
458 4,
459 NameShape::PersonName,
460 CuePosition::Before,
461 "from".to_string(),
462 2,
463 0.9,
464 10,
465 );
466 assert!(short.detect("from Alice Example.", &ctx()).is_empty());
467 }
468
469 #[test]
470 fn min_components_is_explicit_not_source_label_derived() {
471 let one_component_non_forward = AnchoredMatchRecognizer::new(
472 "test.one_component".to_string(),
473 vec!["from".to_string()],
474 AnchoredBoundary::Punctuation,
475 64,
476 NameShape::PersonName,
477 CuePosition::Before,
478 "agent_recipient".to_string(),
479 1,
480 0.9,
481 10,
482 );
483 assert_eq!(
484 one_component_non_forward.detect("from Alice:", &ctx())[0].span,
485 5..10
486 );
487
488 let two_component_forward = AnchoredMatchRecognizer::new(
489 "test.two_component".to_string(),
490 vec!["from".to_string()],
491 AnchoredBoundary::Punctuation,
492 64,
493 NameShape::PersonName,
494 CuePosition::Before,
495 "forward_marker".to_string(),
496 2,
497 0.9,
498 10,
499 );
500 assert!(two_component_forward
501 .detect("from Alice:", &ctx())
502 .is_empty());
503 }
504
505 #[test]
506 fn handles_unicode_overlap_and_non_name_rejection() {
507 let recognizer = test_recognizer(
508 "test.forward",
509 vec!["Forwarded", "Forwarded message from"],
510 AnchoredBoundary::Punctuation,
511 CuePosition::Before,
512 "forward_marker",
513 );
514 let hits = recognizer.detect("Forwarded message from Łukasz Müller:", &ctx());
515 assert_eq!(hits.len(), 1);
516 assert_eq!(
517 &"Forwarded message from Łukasz Müller:"[hits[0].span.clone()],
518 "Łukasz Müller"
519 );
520
521 assert!(recognizer
522 .detect("Forwarded message from mailgun:", &ctx())
523 .is_empty());
524 }
525
526 #[test]
527 fn emits_structural_source_labels_for_core_anchored_match_families() {
528 for (short_label, cue, boundary, input, expected_source) in [
529 (
530 "forward_marker",
531 "Forwarded message from",
532 AnchoredBoundary::Punctuation,
533 "Forwarded message from Alice Example:",
534 "structural.forward_marker",
535 ),
536 (
537 "agent_recipient",
538 "antwortest",
539 AnchoredBoundary::Punctuation,
540 "Du antwortest als Artistfy-Support an Alice Example.",
541 "structural.agent_recipient",
542 ),
543 (
544 "footer",
545 "Sent by",
546 AnchoredBoundary::Whitespace,
547 "Sent by Alice Example via Mailgun",
548 "structural.footer",
549 ),
550 ] {
551 let recognizer = test_recognizer(
552 &format!("test.{short_label}"),
553 vec![cue],
554 boundary,
555 CuePosition::Before,
556 short_label,
557 );
558 let hits = recognizer.detect(input, &ctx());
559
560 assert_eq!(
561 hits.first().map(|hit| hit.source.as_str()),
562 Some(expected_source),
563 "{short_label} should persist the structural source label"
564 );
565 }
566 }
567
568 fn test_recognizer(
569 id: &str,
570 cues: Vec<&str>,
571 boundary: AnchoredBoundary,
572 cue_position: CuePosition,
573 source_short_label: &str,
574 ) -> AnchoredMatchRecognizer {
575 AnchoredMatchRecognizer::new(
576 id.to_string(),
577 cues.into_iter().map(str::to_string).collect(),
578 boundary,
579 64,
580 NameShape::PersonName,
581 cue_position,
582 source_short_label.to_string(),
583 2,
584 0.91,
585 60,
586 )
587 }
588
589 fn ctx() -> DetectContext<'static> {
590 let dictionaries = Box::leak(Box::new(DictionaryBundle::from_entries(HashMap::new())));
591 let locale_chain = Box::leak(Box::new([LocaleTag::Global]));
592 DetectContext::new(locale_chain, dictionaries)
593 }
594}