use std::path::Path;
#[cfg(feature = "parallel-grep")]
use std::sync::Arc;
#[cfg(feature = "parallel-grep")]
use rayon::prelude::*;
use super::grep::GrepError;
use super::llev::{load_file, RuleSetChar};
#[cfg(feature = "parallel-grep")]
use super::nfa::product::ProductAutomatonChar;
#[cfg(feature = "parallel-grep")]
use super::nfa::thompson::ThompsonBuilderChar;
use super::online_scanner::{OnlinePhoneticScannerChar, ScanMatch, ScannerStats};
use super::online_transducer::OnlinePhoneticTransducerChar;
use super::types::RewriteRuleChar;
#[cfg(feature = "parallel-grep")]
#[derive(Debug, Clone)]
struct CandidateTask {
start_byte: usize,
start_char: usize,
}
#[cfg(feature = "parallel-grep")]
#[derive(Debug, Clone)]
struct SharedNormalized {
chars: Arc<[char]>,
byte_positions: Arc<[usize]>,
}
#[cfg(feature = "parallel-grep")]
impl SharedNormalized {
fn byte_offset(&self, char_pos: usize) -> Option<usize> {
self.byte_positions.get(char_pos).copied()
}
fn len(&self) -> usize {
self.chars.len()
}
}
#[derive(Debug, Clone)]
pub struct PhoneticGrepOnline {
rules: Vec<RewriteRuleChar>,
pattern: String,
max_distance: u8,
case_insensitive: bool,
}
impl PhoneticGrepOnline {
pub fn with_rules(pattern: &str, rules: Vec<RewriteRuleChar>, max_distance: u8) -> Self {
Self {
rules,
pattern: pattern.to_string(),
max_distance,
case_insensitive: false,
}
}
pub fn from_rules_file(
pattern: &str,
rules_path: &Path,
max_distance: u8,
) -> Result<Self, GrepError> {
let llev_file = load_file(rules_path).map_err(|e| GrepError::RuleLoad(e.to_string()))?;
let ruleset =
RuleSetChar::from_llev(&llev_file).map_err(|e| GrepError::RuleLoad(e.to_string()))?;
Ok(Self::with_rules(pattern, ruleset.rules, max_distance))
}
pub fn without_rules(pattern: &str, max_distance: u8) -> Self {
Self {
rules: Vec::new(),
pattern: pattern.to_string(),
max_distance,
case_insensitive: false,
}
}
pub fn case_insensitive(mut self, yes: bool) -> Self {
self.case_insensitive = yes;
self
}
pub fn pattern(&self) -> &str {
&self.pattern
}
pub fn max_distance(&self) -> u8 {
self.max_distance
}
pub fn rules(&self) -> &[RewriteRuleChar] {
&self.rules
}
pub fn scan(&self, document: &str) -> Vec<ScanMatch> {
let pattern = self.prepare_pattern();
let doc = self.prepare_document(document);
let mut scanner = OnlinePhoneticScannerChar::new(&pattern, &self.rules, self.max_distance);
scanner.scan(&doc)
}
pub fn scan_with_stats(&self, document: &str) -> (Vec<ScanMatch>, ScannerStats) {
let pattern = self.prepare_pattern();
let doc = self.prepare_document(document);
let mut scanner = OnlinePhoneticScannerChar::new(&pattern, &self.rules, self.max_distance);
let matches = scanner.scan(&doc);
let stats = scanner.stats();
(matches, stats)
}
pub fn normalized_query(&self) -> String {
let pattern = self.prepare_pattern();
let scanner = OnlinePhoneticScannerChar::new(&pattern, &self.rules, self.max_distance);
scanner.normalized_query().to_string()
}
pub fn streaming(&self) -> StreamingScanner {
let pattern = self.prepare_pattern();
let scanner = OnlinePhoneticScannerChar::new(&pattern, &self.rules, self.max_distance);
StreamingScanner {
inner: scanner,
case_insensitive: self.case_insensitive,
}
}
fn prepare_pattern(&self) -> String {
if self.case_insensitive {
self.pattern.to_lowercase()
} else {
self.pattern.clone()
}
}
fn prepare_document(&self, document: &str) -> String {
if self.case_insensitive {
document.to_lowercase()
} else {
document.to_string()
}
}
#[cfg(feature = "parallel-grep")]
pub fn scan_parallel(&self, document: &str) -> Vec<ScanMatch> {
let pattern = self.prepare_pattern();
let doc = self.prepare_document(document);
let (normalized, byte_positions, doc_byte_len) =
self.normalize_document_with_positions(&doc);
if normalized.is_empty() {
return Vec::new();
}
let normalized_query = self.compute_normalized_query(&pattern);
let query_len = normalized_query.chars().count();
if query_len == 0 {
return Vec::new();
}
let builder = ThompsonBuilderChar::new();
let query_nfa = builder.literal(&normalized_query);
let product = Arc::new(ProductAutomatonChar::new(query_nfa, self.max_distance));
let shared = SharedNormalized {
chars: Arc::from(normalized.as_slice()),
byte_positions: Arc::from(byte_positions.as_slice()),
};
let candidates: Vec<CandidateTask> = (0..normalized.len())
.map(|i| CandidateTask {
start_byte: shared.byte_offset(i).unwrap_or(0),
start_char: i,
})
.collect();
let mut matches: Vec<ScanMatch> = candidates
.into_par_iter()
.filter_map(|candidate| {
self.verify_candidate_parallel(
&candidate,
&shared,
&product,
query_len,
&doc,
doc_byte_len,
)
})
.collect();
matches.sort_by(|a, b| {
a.byte_range
.0
.cmp(&b.byte_range.0)
.then(a.distance.cmp(&b.distance))
});
self.deduplicate_matches(matches)
}
#[cfg(feature = "parallel-grep")]
fn normalize_document_with_positions(&self, document: &str) -> (Vec<char>, Vec<usize>, usize) {
if self.rules.is_empty() {
let mut chars = Vec::with_capacity(document.len());
let mut positions = Vec::with_capacity(document.len());
let mut byte_pos = 0;
for c in document.chars() {
positions.push(byte_pos);
chars.push(c);
byte_pos += c.len_utf8();
}
return (chars, positions, byte_pos);
}
let mut transducer = OnlinePhoneticTransducerChar::new(self.rules.clone());
let mut pending_positions: Vec<usize> = Vec::new();
let mut normalized_chars = Vec::with_capacity(document.len());
let mut byte_positions = Vec::with_capacity(document.len());
let mut input_byte_pos = 0;
for c in document.chars() {
pending_positions.push(input_byte_pos);
let char_byte_len = c.len_utf8();
let output_count_before = normalized_chars.len();
for out_c in transducer.feed(c) {
normalized_chars.push(out_c);
}
let new_output_count = normalized_chars.len() - output_count_before;
for _ in 0..new_output_count {
if !pending_positions.is_empty() {
byte_positions.push(pending_positions.remove(0));
} else {
byte_positions.push(input_byte_pos);
}
}
input_byte_pos += char_byte_len;
}
for out_c in transducer.finish() {
normalized_chars.push(out_c);
if !pending_positions.is_empty() {
byte_positions.push(pending_positions.remove(0));
} else {
byte_positions.push(input_byte_pos.saturating_sub(1));
}
}
(normalized_chars, byte_positions, input_byte_pos)
}
#[cfg(feature = "parallel-grep")]
fn compute_normalized_query(&self, pattern: &str) -> String {
if self.rules.is_empty() {
return pattern.to_string();
}
let mut transducer = OnlinePhoneticTransducerChar::new(self.rules.clone());
transducer.normalize(pattern)
}
#[cfg(feature = "parallel-grep")]
fn verify_candidate_parallel(
&self,
candidate: &CandidateTask,
shared: &SharedNormalized,
product: &ProductAutomatonChar,
query_len: usize,
original_doc: &str,
doc_byte_len: usize,
) -> Option<ScanMatch> {
let min_len = query_len.saturating_sub(self.max_distance as usize);
let max_len = query_len + self.max_distance as usize;
let start = candidate.start_char;
let mut best_match: Option<(u8, usize, String)> = None;
for len in min_len..=max_len {
let end = start + len;
if end > shared.len() {
break;
}
let candidate_str: String = shared.chars[start..end].iter().collect();
if let Some(distance) = product.min_distance(&candidate_str) {
match &best_match {
None => {
best_match = Some((distance, end, candidate_str));
}
Some((best_dist, _, _)) if distance < *best_dist => {
best_match = Some((distance, end, candidate_str));
}
Some((best_dist, best_end, _)) if distance == *best_dist && end > *best_end => {
best_match = Some((distance, end, candidate_str));
}
_ => {}
}
if distance == 0 {
break;
}
}
}
best_match.map(|(distance, end, normalized_text)| {
let byte_start = candidate.start_byte;
let byte_end = if end < shared.byte_positions.len() {
shared.byte_positions[end]
} else {
doc_byte_len
};
let original_text = if byte_start <= byte_end && byte_end <= original_doc.len() {
original_doc
.get(byte_start..byte_end)
.unwrap_or("")
.to_string()
} else {
String::new()
};
ScanMatch {
byte_range: (byte_start, byte_end),
char_range: (start, end),
original_text,
normalized_text,
distance,
}
})
}
#[cfg(feature = "parallel-grep")]
fn deduplicate_matches(&self, matches: Vec<ScanMatch>) -> Vec<ScanMatch> {
if matches.len() <= 1 {
return matches;
}
let mut result: Vec<ScanMatch> = Vec::with_capacity(matches.len());
let mut last_end = 0usize;
for m in matches {
if m.byte_range.0 < last_end {
if let Some(prev) = result.last_mut() {
if m.distance < prev.distance
|| (m.distance == prev.distance
&& m.byte_range.1 - m.byte_range.0
> prev.byte_range.1 - prev.byte_range.0)
{
*prev = m;
last_end = prev.byte_range.1;
}
}
} else {
last_end = m.byte_range.1;
result.push(m);
}
}
result
}
#[cfg(feature = "parallel-grep")]
pub fn scan_documents_parallel<'a, I, D>(&self, documents: I) -> Vec<(D, Vec<ScanMatch>)>
where
I: IntoIterator<Item = (D, &'a str)>,
D: Clone + Send + Sync,
{
let docs: Vec<(D, &str)> = documents.into_iter().collect();
docs.into_par_iter()
.map(|(doc_id, text)| {
let matches = self.scan(text);
(doc_id, matches)
})
.collect()
}
#[cfg(feature = "parallel-grep")]
pub fn scan_documents_parallel_nested<'a, I, D>(&self, documents: I) -> Vec<(D, Vec<ScanMatch>)>
where
I: IntoIterator<Item = (D, &'a str)>,
D: Clone + Send + Sync,
{
let docs: Vec<(D, &str)> = documents.into_iter().collect();
docs.into_par_iter()
.map(|(doc_id, text)| {
let matches = self.scan_parallel(text);
(doc_id, matches)
})
.collect()
}
#[cfg(feature = "parallel-grep")]
pub fn filter_documents_parallel<'a, I, D>(&self, documents: I) -> Vec<(D, Vec<ScanMatch>)>
where
I: IntoIterator<Item = (D, &'a str)>,
D: Clone + Send + Sync,
{
let docs: Vec<(D, &str)> = documents.into_iter().collect();
docs.into_par_iter()
.filter_map(|(doc_id, text)| {
let matches = self.scan(text);
if matches.is_empty() {
None
} else {
Some((doc_id, matches))
}
})
.collect()
}
#[cfg(feature = "parallel-grep")]
pub fn count_documents_parallel<'a, I, D>(&self, documents: I) -> Vec<(D, usize)>
where
I: IntoIterator<Item = (D, &'a str)>,
D: Clone + Send + Sync,
{
let docs: Vec<(D, &str)> = documents.into_iter().collect();
docs.into_par_iter()
.map(|(doc_id, text)| {
let matches = self.scan(text);
(doc_id, matches.len())
})
.collect()
}
}
pub struct StreamingScanner {
inner: OnlinePhoneticScannerChar,
case_insensitive: bool,
}
impl StreamingScanner {
pub fn feed(&mut self, chunk: &str) {
let text = if self.case_insensitive {
chunk.to_lowercase()
} else {
chunk.to_string()
};
for c in text.chars() {
self.inner.feed(c, c.len_utf8());
}
}
pub fn finish(mut self) -> Vec<ScanMatch> {
self.inner.scan("")
}
pub fn stats(&self) -> ScannerStats {
self.inner.stats()
}
pub fn normalized_query(&self) -> &str {
self.inner.normalized_query()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::phonetic::types::{ContextChar, PhoneChar};
fn make_rule(pattern: &str, replacement: &str, context: ContextChar) -> RewriteRuleChar {
fn char_to_phone(c: char) -> PhoneChar {
let lower = c.to_ascii_lowercase();
if "aeiou".contains(lower) {
PhoneChar::Vowel(c)
} else {
PhoneChar::Consonant(c)
}
}
RewriteRuleChar {
rule_id: 0,
rule_name: format!("{} -> {}", pattern, replacement),
pattern: pattern.chars().map(char_to_phone).collect(),
replacement: replacement.chars().map(char_to_phone).collect(),
context,
weight: 1.0,
syllable_condition: None,
}
}
#[test]
fn test_basic_match() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let matches = grep.scan("phone");
assert!(!matches.is_empty(), "should match 'phone'");
assert_eq!(matches[0].distance, 0);
}
#[test]
fn test_phonetic_equivalence() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let matches = grep.scan("fone");
assert!(!matches.is_empty(), "'fone' should match 'phone'");
assert_eq!(matches[0].distance, 0, "phonetically equivalent");
}
#[test]
fn test_fude_food_equivalence() {
let rules = vec![
make_rule("oo", "u", ContextChar::Anywhere),
make_rule("e", "", ContextChar::Final),
];
let grep = PhoneticGrepOnline::with_rules("fude", rules, 0);
assert_eq!(grep.normalized_query(), "fud");
let matches = grep.scan("food");
assert!(!matches.is_empty(), "'food' should match 'fude'");
assert_eq!(
matches[0].distance, 0,
"should be exact after normalization"
);
}
#[test]
fn test_fuzzy_with_phonetic() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 1);
let matches = grep.scan("fon");
assert!(!matches.is_empty(), "'fon' should fuzzy match 'phone'");
assert!(matches[0].distance <= 1);
}
#[test]
fn test_without_rules() {
let grep = PhoneticGrepOnline::without_rules("hello", 0);
let matches = grep.scan("hello");
assert!(!matches.is_empty(), "should match 'hello'");
assert_eq!(matches[0].distance, 0);
}
#[test]
fn test_case_insensitive() {
let grep = PhoneticGrepOnline::without_rules("hello", 0).case_insensitive(true);
let matches = grep.scan("HELLO");
assert!(!matches.is_empty(), "should match case-insensitively");
}
#[test]
fn test_streaming() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let mut stream = grep.streaming();
stream.feed("my pho");
stream.feed("ne");
let matches = stream.finish();
assert!(!matches.is_empty(), "streaming should find match");
}
#[test]
fn test_normalized_query() {
let rules = vec![
make_rule("ph", "f", ContextChar::Anywhere),
make_rule("oo", "u", ContextChar::Anywhere),
];
let grep = PhoneticGrepOnline::with_rules("philosophy", rules.clone(), 0);
assert_eq!(grep.normalized_query(), "filosofy");
let grep2 = PhoneticGrepOnline::with_rules("food", rules, 0);
assert_eq!(grep2.normalized_query(), "fud");
}
#[test]
fn test_no_match() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let matches = grep.scan("hello world");
assert!(matches.is_empty(), "should not match unrelated text");
}
#[test]
fn test_scan_with_stats() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let (matches, stats) = grep.scan_with_stats("phone");
assert!(!matches.is_empty());
assert_eq!(stats.chars_scanned, 5);
assert_eq!(stats.bytes_scanned, 5);
assert!(stats.matches_found >= 1);
}
#[test]
fn test_in_sentence() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules.clone(), 0);
let matches = grep.scan("phone");
assert!(!matches.is_empty(), "should find 'phone' exactly");
let grep2 = PhoneticGrepOnline::with_rules("phone", rules.clone(), 0);
let matches2 = grep2.scan("fone");
assert!(!matches2.is_empty(), "'fone' should match 'phone'");
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_parallel_basic_match() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let matches = grep.scan_parallel("phone");
assert!(!matches.is_empty(), "parallel should match 'phone'");
assert_eq!(matches[0].distance, 0);
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_parallel_phonetic_equivalence() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let matches = grep.scan_parallel("fone");
assert!(!matches.is_empty(), "parallel: 'fone' should match 'phone'");
assert_eq!(matches[0].distance, 0, "phonetically equivalent");
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_parallel_fude_food_equivalence() {
let rules = vec![
make_rule("oo", "u", ContextChar::Anywhere),
make_rule("e", "", ContextChar::Final),
];
let grep = PhoneticGrepOnline::with_rules("fude", rules, 0);
assert_eq!(grep.normalized_query(), "fud");
let matches = grep.scan_parallel("food");
assert!(!matches.is_empty(), "parallel: 'food' should match 'fude'");
assert_eq!(
matches[0].distance, 0,
"should be exact after normalization"
);
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_parallel_fuzzy_match() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 1);
let matches = grep.scan_parallel("fon");
assert!(
!matches.is_empty(),
"parallel: 'fon' should fuzzy match 'phone'"
);
assert!(matches[0].distance <= 1);
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_parallel_no_match() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let matches = grep.scan_parallel("xyz");
assert!(
matches.is_empty(),
"parallel: should not match unrelated text"
);
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_parallel_empty_document() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let matches = grep.scan_parallel("");
assert!(
matches.is_empty(),
"parallel: empty document has no matches"
);
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_parallel_without_rules() {
let grep = PhoneticGrepOnline::without_rules("hello", 0);
let matches = grep.scan_parallel("hello");
assert!(!matches.is_empty(), "parallel: should match 'hello'");
assert_eq!(matches[0].distance, 0);
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_parallel_matches_sequential() {
let rules = vec![
make_rule("ph", "f", ContextChar::Anywhere),
make_rule("oo", "u", ContextChar::Anywhere),
];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 1);
let seq_matches = grep.scan("fone");
let par_matches = grep.scan_parallel("fone");
assert!(!seq_matches.is_empty(), "sequential should find match");
assert!(!par_matches.is_empty(), "parallel should find match");
assert_eq!(
seq_matches[0].distance, par_matches[0].distance,
"distances should match"
);
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_parallel_multiple_candidates() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("fone", rules, 0);
let doc = "fone world";
let matches = grep.scan_parallel(doc);
assert!(!matches.is_empty(), "should find at least one match");
assert_eq!(matches[0].byte_range.0, 0, "match should be at start");
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_scan_documents_parallel() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let documents = vec![("doc1", "phone"), ("doc2", "fone"), ("doc3", "hello")];
let results = grep.scan_documents_parallel(documents);
assert_eq!(results.len(), 3, "should return results for all documents");
let doc1_matches = results.iter().find(|(id, _)| *id == "doc1").map(|(_, m)| m);
let doc2_matches = results.iter().find(|(id, _)| *id == "doc2").map(|(_, m)| m);
let doc3_matches = results.iter().find(|(id, _)| *id == "doc3").map(|(_, m)| m);
assert!(
doc1_matches.map_or(false, |m| !m.is_empty()),
"doc1 should have matches"
);
assert!(
doc2_matches.map_or(false, |m| !m.is_empty()),
"doc2 should have matches"
);
assert!(
doc3_matches.map_or(false, |m| m.is_empty()),
"doc3 should have no matches"
);
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_scan_documents_parallel_nested() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let documents = vec![("doc1", "phone"), ("doc2", "fone")];
let results = grep.scan_documents_parallel_nested(documents);
assert_eq!(results.len(), 2, "should return results for all documents");
for (doc_id, matches) in &results {
assert!(!matches.is_empty(), "{} should have matches", doc_id);
assert_eq!(
matches[0].distance, 0,
"{} should match with distance 0",
doc_id
);
}
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_filter_documents_parallel() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let documents = vec![
("match1", "phone"),
("nomatch", "hello"),
("match2", "fone"),
];
let results = grep.filter_documents_parallel(documents);
assert_eq!(
results.len(),
2,
"should only return documents with matches"
);
let ids: Vec<_> = results.iter().map(|(id, _)| *id).collect();
assert!(ids.contains(&"match1"), "should contain match1");
assert!(ids.contains(&"match2"), "should contain match2");
assert!(!ids.contains(&"nomatch"), "should not contain nomatch");
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_count_documents_parallel() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let documents = vec![("doc1", "phone"), ("doc2", "no match"), ("doc3", "fone")];
let results = grep.count_documents_parallel(documents);
assert_eq!(results.len(), 3, "should return counts for all documents");
let doc1_count = results
.iter()
.find(|(id, _)| *id == "doc1")
.map(|(_, c)| *c);
let doc2_count = results
.iter()
.find(|(id, _)| *id == "doc2")
.map(|(_, c)| *c);
let doc3_count = results
.iter()
.find(|(id, _)| *id == "doc3")
.map(|(_, c)| *c);
assert!(
doc1_count.map_or(false, |c| c >= 1),
"doc1 should have >= 1 match"
);
assert_eq!(doc2_count, Some(0), "doc2 should have 0 matches");
assert!(
doc3_count.map_or(false, |c| c >= 1),
"doc3 should have >= 1 match"
);
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_scan_documents_parallel_empty() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let documents: Vec<(&str, &str)> = vec![];
let results = grep.scan_documents_parallel(documents);
assert!(
results.is_empty(),
"empty input should produce empty output"
);
}
#[cfg(feature = "parallel-grep")]
#[test]
fn test_scan_documents_parallel_with_string_ids() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let grep = PhoneticGrepOnline::with_rules("phone", rules, 0);
let documents: Vec<(String, &str)> = vec![
("file1.txt".to_string(), "phone"),
("file2.txt".to_string(), "fone"),
];
let results = grep.scan_documents_parallel(documents);
assert_eq!(results.len(), 2);
for (path, matches) in results {
assert!(!matches.is_empty(), "{} should have matches", path);
}
}
}