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