Skip to main content

sensitive_rs/
filter.rs

1use crate::engine::MatchAlgorithm;
2use crate::{engine::MultiPatternEngine, variant::VariantDetector};
3use lru::LruCache;
4#[cfg(feature = "parallel")]
5use rayon::prelude::*;
6use regex::Regex;
7use std::collections::HashSet;
8use std::num::NonZero;
9use std::sync::{Arc, Mutex};
10use std::{
11    fs::File,
12    io::{self, BufRead, BufReader},
13    path::Path,
14};
15
16/// Advanced sensitive word filter with variant detection
17pub struct Filter {
18    engine: MultiPatternEngine,        // Multi-pattern matching engine
19    variant_detector: VariantDetector, // Variation detector
20    noise: Regex,                      // Noise processing rules
21    cache: Arc<Mutex<LruCache<String, Vec<String>>>>,
22    #[cfg(feature = "net")]
23    http_client: reqwest::blocking::Client, // Network request client
24}
25
26impl std::fmt::Debug for Filter {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        f.debug_struct("Filter")
29            .field("engine", &self.engine)
30            .field("variant_detector", &self.variant_detector)
31            .field("noise", &self.noise)
32            .field("cache", &"<LruCache>")
33            .finish()
34    }
35}
36
37impl Filter {
38    /// Create a new filter with default settings
39    pub fn new() -> Self {
40        Self {
41            engine: MultiPatternEngine::new(None, &[]),
42            variant_detector: VariantDetector::new(),
43            noise: Regex::new(r"[^\w\s\u4e00-\u9fff]").unwrap(),
44            cache: Arc::new(Mutex::new(LruCache::new(NonZero::new(1000).unwrap()))), // Cache 1000 results
45            #[cfg(feature = "net")]
46            http_client: reqwest::blocking::Client::builder()
47                .timeout(std::time::Duration::from_secs(5))
48                .build()
49                .unwrap(),
50        }
51    }
52
53    fn check_cache(&self, text: &str) -> Option<Vec<String>> {
54        self.cache.lock().unwrap_or_else(|e| e.into_inner()).get(text).cloned()
55    }
56
57    fn cache_result(&self, text: &str, results: &[String]) {
58        self.cache.lock().unwrap_or_else(|e| e.into_inner()).put(text.to_string(), results.to_vec());
59    }
60
61    fn word_match_variants(word: &str) -> Vec<String> {
62        let mut variants = vec![word.to_string()];
63        if word.chars().any(char::is_whitespace) {
64            let folded: String = word.chars().filter(|c| !c.is_whitespace()).collect();
65            if !folded.is_empty() && folded != word {
66                variants.push(folded);
67            }
68        }
69        variants
70    }
71
72    fn extend_patterns_with_word_variants(patterns: &mut Vec<String>, words: &[&str]) {
73        let mut seen: HashSet<String> = patterns.iter().cloned().collect();
74        for word in words {
75            for variant in Self::word_match_variants(word) {
76                if seen.insert(variant.clone()) {
77                    patterns.push(variant);
78                }
79            }
80        }
81    }
82
83    /// Clear the cache
84    pub fn clear_cache(&self) {
85        self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
86    }
87
88    /// Create with specific algorithm
89    pub fn with_algorithm(algorithm: MatchAlgorithm) -> Self {
90        Self { engine: MultiPatternEngine::new(Some(algorithm), &[]), ..Self::new() }
91    }
92
93    /// Load default dictionary
94    pub fn with_default_dict() -> io::Result<Self> {
95        let mut filter = Self::new();
96        filter.load_word_dict("dict/dict.txt")?;
97        Ok(filter)
98    }
99
100    /// Update noise pattern
101    pub fn update_noise_pattern(&mut self, pattern: &str) -> Result<(), regex::Error> {
102        self.noise = Regex::new(pattern)?;
103        Ok(())
104    }
105
106    /// Add a sensitive word
107    pub fn add_word(&mut self, word: &str) {
108        let mut patterns = self.engine.get_patterns().to_vec();
109        Self::extend_patterns_with_word_variants(&mut patterns, &[word]);
110        self.engine.rebuild(&patterns);
111        for variant in Self::word_match_variants(word) {
112            self.variant_detector.add_word(&variant);
113        }
114        self.clear_cache();
115    }
116
117    /// Add multiple words
118    pub fn add_words(&mut self, words: &[&str]) {
119        let mut patterns = self.engine.get_patterns().to_vec();
120        Self::extend_patterns_with_word_variants(&mut patterns, words);
121
122        self.engine.rebuild(&patterns);
123        for word in words {
124            for variant in Self::word_match_variants(word) {
125                self.variant_detector.add_word(&variant);
126            }
127        }
128        self.clear_cache();
129    }
130
131    /// Get the currently used algorithm
132    pub fn current_algorithm(&self) -> MatchAlgorithm {
133        self.engine.current_algorithm()
134    }
135
136    /// Remove a word
137    pub fn del_word(&mut self, word: &str) {
138        let word_set: HashSet<_> = Self::word_match_variants(word).into_iter().collect();
139        let patterns: Vec<_> = self.engine.get_patterns().iter().filter(|w| !word_set.contains(*w)).cloned().collect();
140
141        self.engine.rebuild(&patterns);
142        self.clear_cache();
143    }
144
145    /// Remove multiple words
146    pub fn del_words(&mut self, words: &[&str]) {
147        let word_set: HashSet<String> = words.iter().flat_map(|word| Self::word_match_variants(word)).collect();
148        let patterns: Vec<_> = self.engine.get_patterns().iter().filter(|w| !word_set.contains(*w)).cloned().collect();
149
150        self.engine.rebuild(&patterns);
151        self.clear_cache();
152    }
153
154    /// Load dictionary from file
155    pub fn load_word_dict<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
156        let file = File::open(path)?;
157        self.load(BufReader::new(file))
158    }
159
160    /// Load dictionary from reader
161    pub fn load<R: BufRead>(&mut self, reader: R) -> io::Result<()> {
162        let words: Vec<_> = reader.lines().collect::<Result<_, _>>()?;
163        self.add_words(&words.iter().map(|s| s.as_str()).collect::<Vec<_>>());
164        Ok(())
165    }
166
167    /// Load dictionary from URL
168    #[cfg(feature = "net")]
169    pub fn load_net_word_dict(&mut self, url: &str) -> io::Result<()> {
170        let response = self.http_client.get(url).send().map_err(io::Error::other)?;
171
172        if !response.status().is_success() {
173            return Err(io::Error::other(format!("HTTP request failed: {}", response.status())));
174        }
175
176        let reader = BufReader::new(response);
177        self.load(reader)
178    }
179
180    /// Find first sensitive word
181    pub fn find_in(&self, text: &str) -> (bool, String) {
182        let clean_text = self.remove_noise(text);
183
184        // 1. Try exact match first
185        if let Some(word) = self.engine.find_first(&clean_text) {
186            return (true, word);
187        }
188
189        // 2. Try variant detection
190        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
191
192        if let Some(word) = self.variant_detector.detect(&clean_text, &patterns).first() {
193            return (true, word.to_string());
194        }
195
196        (false, String::new())
197    }
198
199    /// Replace sensitive words with replacement character
200    pub fn replace(&self, text: &str, replacement: char) -> String {
201        let clean_text = self.remove_noise(text);
202        let repl = replacement.to_string();
203
204        // Exact matches: single-pass rebuild from engine match positions
205        // (leftmost-longest, so overlapping dict words resolve cleanly), one
206        // replacement char per matched character.
207        let mut positions = self.engine.find_matches_with_positions(&clean_text);
208        positions.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)));
209        let mut result = String::with_capacity(clean_text.len());
210        let mut cursor = 0usize;
211        for m in &positions {
212            if m.start < cursor {
213                continue; // covered by a previously kept (longer-leftmost) span
214            }
215            result.push_str(&clean_text[cursor..m.start]);
216            result.push_str(&repl.repeat(clean_text[m.start..m.end].chars().count()));
217            cursor = m.end;
218        }
219        result.push_str(&clean_text[cursor..]);
220
221        // Variant branch: `detect` returns original word names, not the variant
222        // text actually present, so these replaces are no-ops when only variants
223        // occur. Kept unchanged in this perf pass; revisit separately.
224        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
225        let variants = self.variant_detector.detect(&result, &patterns);
226        for variant in variants {
227            let repl_str = repl.repeat(variant.chars().count());
228            result = result.replace(variant, &repl_str);
229        }
230
231        result
232    }
233
234    /// Filter out sensitive words (remove them completely)
235    pub fn filter(&self, text: &str) -> String {
236        let clean_text = self.remove_noise(text);
237
238        // Use engine's optimized replace_all for pattern removal
239        let mut result = self.engine.replace_all(&clean_text, "");
240
241        // Remove sensitive words detected by variants
242        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
243        let variants = self.variant_detector.detect(&result, &patterns);
244
245        for variant in variants {
246            result = result.replace(variant, "");
247        }
248
249        result
250    }
251
252    /// Validate text
253    pub fn validate(&self, text: &str) -> (bool, String) {
254        self.find_in(text)
255    }
256
257    /// Remove only specific noise characters, preserve spaces
258    pub fn remove_noise(&self, text: &str) -> String {
259        self.noise.replace_all(text, "").to_string()
260    }
261
262    /// Get current noise pattern
263    pub fn get_noise_pattern(&self) -> &Regex {
264        &self.noise
265    }
266}
267
268impl Filter {
269    /// Optimized method of finding all sensitive words
270    pub fn find_all(&self, text: &str) -> Vec<String> {
271        let clean_text = self.remove_noise(text);
272
273        // 1. Caching mechanism - Check whether the results have been cached
274        if let Some(cached_result) = self.check_cache(&clean_text) {
275            return cached_result;
276        }
277
278        #[cfg(feature = "parallel")]
279        let results = if clean_text.len() > 1000 {
280            self.find_all_parallel(&clean_text) // long text -> parallel
281        } else {
282            self.find_all_sequential(&clean_text) // short text -> sequential
283        };
284        #[cfg(not(feature = "parallel"))]
285        let results = self.find_all_sequential(&clean_text);
286
287        // 3. Cache results
288        self.cache_result(&clean_text, &results);
289
290        results
291    }
292
293    /// Parallel Processing Version - For Long Text
294    #[cfg(feature = "parallel")]
295    fn find_all_parallel(&self, text: &str) -> Vec<String> {
296        let chunk_size = std::cmp::max(text.len() / rayon::current_num_threads(), 100);
297        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
298
299        // Compute overlap to catch patterns spanning chunk boundaries
300        let max_pattern_len = patterns.iter().map(|p| p.chars().count()).max().unwrap_or(0);
301        let overlap = max_pattern_len.min(chunk_size);
302
303        // Build overlapping chunks for parallel processing
304        let chars: Vec<char> = text.chars().collect();
305        let engine_results: Vec<String> = if chars.len() <= chunk_size {
306            self.engine.find_all(text)
307        } else {
308            let step = chunk_size;
309            chars
310                .windows(chunk_size + overlap)
311                .step_by(step)
312                .collect::<Vec<_>>()
313                .par_iter()
314                .flat_map(|window| {
315                    let chunk_text: String = window.iter().collect();
316                    self.engine.find_all(&chunk_text)
317                })
318                .collect()
319        };
320
321        // Parallel variant detection - Fixed parallel iterator problem
322        let variant_results: Vec<String> = text
323            .split_whitespace()
324            .collect::<Vec<_>>()
325            .par_iter()
326            .map(|segment| self.variant_detector.detect(segment, &patterns))
327            .flatten()
328            .map(|s| s.to_string())
329            .collect();
330
331        // Merge and remove repetition
332        let mut results = engine_results;
333        results.extend(variant_results);
334        self.deduplicate_and_sort(results)
335    }
336
337    /// Sequential processing version - suitable for short text
338    fn find_all_sequential(&self, text: &str) -> Vec<String> {
339        let mut results = self.engine.find_all(text);
340        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
341
342        // Add variant detection results
343        results.extend(self.variant_detector.detect(text, &patterns).into_iter().map(|s| s.to_string()));
344
345        self.deduplicate_and_sort(results)
346    }
347
348    /// Deduplication and sort
349    fn deduplicate_and_sort(&self, mut results: Vec<String>) -> Vec<String> {
350        results.sort_unstable();
351        results.dedup();
352        results
353    }
354
355    /// Bulk search for optimized versions
356    pub fn find_all_batch(&self, texts: &[&str]) -> Vec<Vec<String>> {
357        #[cfg(feature = "parallel")]
358        {
359            texts.par_iter().map(|text| self.find_all(text)).collect()
360        }
361        #[cfg(not(feature = "parallel"))]
362        {
363            texts.iter().map(|text| self.find_all(text)).collect()
364        }
365    }
366
367    /// Hierarchical Matching - Preferential Matching by Sensitive Word Length
368    pub fn find_all_layered(&self, text: &str) -> Vec<String> {
369        let clean_text = self.remove_noise(text);
370        let mut results = Vec::new();
371        let mut remaining_text = clean_text.clone();
372
373        // Arrange patterns in descending order of length, prioritize long words
374        let mut sorted_patterns = self.engine.get_patterns().to_vec();
375        sorted_patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
376
377        // Hierarchical matching
378        for pattern in &sorted_patterns {
379            if remaining_text.contains(pattern) {
380                results.push(pattern.clone());
381                // Remove matching parts to avoid duplicate matches
382                remaining_text = remaining_text.replace(pattern, " ");
383            }
384        }
385
386        // Variation detection (for remaining text)
387        let patterns: Vec<_> = sorted_patterns.iter().map(|s| s.as_str()).collect();
388        results.extend(self.variant_detector.detect(&remaining_text, &patterns).into_iter().map(|s| s.to_string()));
389
390        self.deduplicate_and_sort(results)
391    }
392
393    /// Streaming version - suitable for oversized text
394    pub fn find_all_streaming<R: BufRead>(&self, reader: R) -> io::Result<Vec<String>> {
395        let mut all_results = Vec::new();
396
397        for line in reader.lines() {
398            let line = line?;
399            let results = self.find_all(&line);
400            all_results.extend(results);
401        }
402
403        Ok(self.deduplicate_and_sort(all_results))
404    }
405}
406
407impl Default for Filter {
408    fn default() -> Self {
409        Self::new()
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use std::io::Cursor;
417    #[test]
418    fn test_integration() {
419        let mut filter = Filter::new();
420        filter.add_words(&["赌博", "色情"]);
421
422        // Exact match
423        assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
424
425        // Pinyin variant
426        assert_eq!(filter.find_in("含有 dubo"), (true, "赌博".to_string()));
427
428        // Replacement
429        assert_eq!(filter.replace("赌博 色情", '*'), "** **");
430
431        // Filter
432        assert_eq!(filter.filter("赌博内容"), "内容");
433    }
434
435    #[test]
436    fn test_noise_handling() {
437        let mut filter = Filter::new();
438        filter.add_word("赌博");
439
440        // 测试空格保留
441        assert_eq!(filter.remove_noise("赌 博"), "赌 博");
442
443        // 测试特殊符号移除
444        assert_eq!(filter.remove_noise("赌@#$博"), "赌博");
445    }
446
447    #[test]
448    fn test_replace_vs_filter() {
449        let mut filter = Filter::new();
450        filter.add_words(&["赌博", "色情"]);
451
452        let text = "这里有赌博和色情内容";
453
454        // replace should be replaced with characters
455        assert_eq!(filter.replace(text, '*'), "这里有**和**内容");
456
457        // filter should be completely removed
458        assert_eq!(filter.filter(text), "这里有和内容");
459    }
460
461    #[test]
462    fn test_replace_single_pass_rebuild() {
463        // R3: exact matches rebuilt in a single pass, one '*' per matched char,
464        // regardless of how many patterns hit.
465        let mut filter = Filter::new();
466        filter.add_words(&["赌博", "色情"]);
467        assert_eq!(filter.replace("前缀赌博中间色情后缀", '*'), "前缀**中间**后缀");
468    }
469
470    #[test]
471    fn test_variant_detection() {
472        let mut filter = Filter::new();
473        filter.add_word("测试");
474
475        assert_eq!(filter.find_in("ceshi"), (true, "测试".to_string()));
476    }
477
478    #[test]
479    fn test_algorithm_switch_one() {
480        // Use Wu-Manber in small quantities
481        let mut small = Filter::new();
482        small.add_words(&["a", "b", "c"]);
483        assert!(matches!(small.engine.current_algorithm(), MatchAlgorithm::WuManber));
484
485        // Aho-Corasick for medium quantity
486        let words: Vec<_> = (0..150).map(|i| format!("word{i}")).collect();
487        let mut medium = Filter::new();
488        medium.add_words(&words.iter().map(|s| s.as_str()).collect::<Vec<_>>());
489        println!("Medium current_algorithm: {:?}", medium.engine.current_algorithm());
490        assert!(matches!(medium.engine.current_algorithm(), MatchAlgorithm::AhoCorasick));
491    }
492
493    #[test]
494    fn test_io_operations() -> io::Result<()> {
495        let mut filter = Filter::new();
496        let cursor = Cursor::new("word1\nword2\nword3");
497        filter.load(cursor)?;
498
499        assert_eq!(filter.find_in("word2"), (true, "word2".to_string()));
500        Ok(())
501    }
502
503    #[test]
504    fn test_space_folded_word_matches_both_forms() {
505        let mut filter = Filter::new();
506        filter.add_word("A 级");
507
508        assert_eq!(filter.find_in("含有 A 级 内容"), (true, "A 级".to_string()));
509        assert_eq!(filter.find_in("含有 A级 内容"), (true, "A级".to_string()));
510
511        let results = filter.find_all("A 级 和 A级");
512        assert!(results.contains(&"A 级".to_string()));
513        assert!(results.contains(&"A级".to_string()));
514    }
515
516    #[test]
517    fn test_loaded_space_word_adds_folded_match() -> io::Result<()> {
518        let mut filter = Filter::new();
519        filter.load(Cursor::new("A 级\n3 级片"))?;
520
521        assert_eq!(filter.find_in("这里有 A级 内容"), (true, "A级".to_string()));
522        assert_eq!(filter.find_in("这里有 3级片 内容"), (true, "3级片".to_string()));
523        Ok(())
524    }
525
526    #[test]
527    fn test_delete_space_word_removes_both_forms() {
528        let mut filter = Filter::new();
529        filter.add_words(&["A 级", "B 级"]);
530
531        filter.del_word("A 级");
532
533        assert_eq!(filter.find_in("含有 A 级 内容"), (false, String::new()));
534        assert_eq!(filter.find_in("含有 A级 内容"), (false, String::new()));
535        assert_eq!(filter.find_in("含有 B级 内容"), (true, "B级".to_string()));
536    }
537
538    #[test]
539    fn test_algorithm_recommendation() {
540        assert_eq!(MultiPatternEngine::recommend_algorithm(50), MatchAlgorithm::WuManber);
541        assert_eq!(MultiPatternEngine::recommend_algorithm(150), MatchAlgorithm::AhoCorasick);
542        assert_eq!(MultiPatternEngine::recommend_algorithm(15000), MatchAlgorithm::Regex);
543    }
544
545    #[test]
546    fn test_algorithm_switch() {
547        // Use Wu-Manber in small quantities
548        let mut small = Filter::new();
549        small.add_words(&["a", "b", "c"]);
550        println!("Small (3 words): {:?}", small.current_algorithm());
551        assert!(matches!(small.current_algorithm(), MatchAlgorithm::WuManber));
552
553        // Aho-Corasick for medium quantity
554        let words: Vec<_> = (0..150).map(|i| format!("word{i}")).collect();
555        let word_refs: Vec<&str> = words.iter().map(|s| s.as_str()).collect();
556
557        let mut medium = Filter::new();
558        medium.add_words(&word_refs);
559
560        println!("Medium (150 words): {:?}", medium.current_algorithm());
561        println!("Pattern count: {}", medium.engine.get_patterns().len());
562
563        // Verification algorithm selection logic
564        let recommended = MultiPatternEngine::recommend_algorithm(150);
565        println!("Recommended algorithm for 150 words: {recommended:?}");
566
567        assert!(matches!(medium.current_algorithm(), MatchAlgorithm::AhoCorasick));
568    }
569
570    #[test]
571    fn test_cache_invalidation_on_add_word() {
572        let mut filter = Filter::new();
573        filter.add_word("赌博");
574
575        // First search populates cache
576        let results1 = filter.find_all("这里有赌博");
577        assert!(results1.contains(&"赌博".to_string()));
578
579        // Add a new word
580        filter.add_word("色情");
581
582        // Cache should be invalidated — new word must appear
583        let results2 = filter.find_all("这里有赌博和色情");
584        assert!(results2.contains(&"赌博".to_string()));
585        assert!(results2.contains(&"色情".to_string()));
586    }
587
588    #[test]
589    fn test_cache_invalidation_on_del_word() {
590        let mut filter = Filter::new();
591        filter.add_words(&["赌博", "色情"]);
592
593        // First search populates cache
594        let results1 = filter.find_all("这里有赌博和色情");
595        assert!(results1.contains(&"赌博".to_string()));
596        assert!(results1.contains(&"色情".to_string()));
597
598        // Delete a word
599        filter.del_word("赌博");
600
601        // Cache should be invalidated — deleted word must not appear
602        let results2 = filter.find_all("这里有赌博和色情");
603        assert!(!results2.contains(&"赌博".to_string()));
604        assert!(results2.contains(&"色情".to_string()));
605    }
606
607    #[test]
608    fn test_mutex_poison_recovery() {
609        use std::sync::Arc;
610
611        let filter = Arc::new(Filter::new());
612        let filter_clone = Arc::clone(&filter);
613
614        // Poison the mutex by panicking while holding the lock
615        let handle = std::thread::spawn(move || {
616            let _guard = filter_clone.cache.lock().unwrap();
617            panic!("intentional panic to poison mutex");
618        });
619        let _ = handle.join();
620
621        // Filter should still work — recovers from poisoned mutex
622        let results = filter.find_all("test");
623        assert!(results.is_empty());
624    }
625
626    #[test]
627    fn test_parallel_search_cross_boundary() {
628        let mut filter = Filter::new();
629        filter.add_word("赌博");
630
631        // Build text > 1000 bytes so find_all uses parallel path
632        // Place "赌博" at a position that could land on a chunk boundary
633        let prefix: String = "安全文字".repeat(200); // 800 bytes (4 chars × 3 bytes × 200)
634        let text = format!("{prefix}这里有赌博内容");
635
636        let results = filter.find_all(&text);
637        assert!(results.contains(&"赌博".to_string()));
638    }
639
640    #[test]
641    fn test_parallel_search_no_duplicates() {
642        let mut filter = Filter::new();
643        filter.add_word("赌博");
644
645        // Build text > 1000 bytes with the word in the middle
646        let prefix: String = "安全".repeat(300); // 1800 bytes
647        let text = format!("{prefix}赌博{prefix}");
648
649        let results = filter.find_all(&text);
650        let count = results.iter().filter(|w| *w == "赌博").count();
651        assert_eq!(count, 1, "expected exactly 1 match, got {count}");
652    }
653
654    // ---- Task 2: advanced methods (batch / layered / streaming) ----
655
656    #[test]
657    fn test_find_all_batch() {
658        let mut filter = Filter::new();
659        filter.add_words(&["赌博", "色情"]);
660
661        let texts = vec!["含有赌博", "含有色情", "正常内容"];
662        let results = filter.find_all_batch(&texts);
663
664        assert_eq!(results.len(), 3);
665        assert!(results[0].contains(&"赌博".to_string()));
666        assert!(results[1].contains(&"色情".to_string()));
667        assert!(results[2].is_empty());
668    }
669
670    #[test]
671    fn test_find_all_batch_empty() {
672        let filter = Filter::new();
673        let results = filter.find_all_batch(&[]);
674        assert!(results.is_empty());
675    }
676
677    #[test]
678    fn test_find_all_layered_prefers_longest() {
679        let mut filter = Filter::new();
680        filter.add_words(&["赌", "赌博", "赌博机"]);
681
682        // Longest match consumes the span; shorter overlapping words are dropped.
683        let results = filter.find_all_layered("这里有赌博机");
684        assert!(results.contains(&"赌博机".to_string()));
685        assert!(!results.contains(&"赌".to_string()));
686        assert!(!results.contains(&"赌博".to_string()));
687    }
688
689    #[test]
690    fn test_find_all_streaming_multiline() {
691        let mut filter = Filter::new();
692        filter.add_words(&["赌博", "色情"]);
693
694        let input = "第一行含有赌博\n第二行含有色情\n第三行正常";
695        let cursor = std::io::Cursor::new(input);
696        let results = filter.find_all_streaming(cursor).unwrap();
697
698        assert!(results.contains(&"赌博".to_string()));
699        assert!(results.contains(&"色情".to_string()));
700        assert_eq!(results.len(), 2);
701    }
702
703    // ---- Task 3: LRU cache behavior ----
704
705    #[test]
706    fn test_cache_hit_returns_consistent_results() {
707        let mut filter = Filter::new();
708        filter.add_words(&["赌博", "色情"]);
709
710        // First call — cache miss; second call — cache hit.
711        let r1 = filter.find_all("含有赌博和色情内容");
712        let r2 = filter.find_all("含有赌博和色情内容");
713        assert_eq!(r1, r2);
714    }
715
716    #[test]
717    fn test_cache_clear() {
718        let mut filter = Filter::new();
719        filter.add_word("赌博");
720
721        filter.find_all("含有赌博"); // populate cache
722        filter.clear_cache();
723
724        // After clear, the result is recomputed (still correct).
725        let results = filter.find_all("含有赌博");
726        assert!(results.contains(&"赌博".to_string()));
727    }
728
729    // ---- Task 4: edge cases ----
730
731    #[test]
732    fn test_empty_text() {
733        let mut filter = Filter::new();
734        filter.add_word("赌博");
735
736        assert_eq!(filter.find_in(""), (false, String::new()));
737        assert!(filter.find_all("").is_empty());
738        assert_eq!(filter.replace("", '*'), "");
739        assert_eq!(filter.filter(""), "");
740    }
741
742    #[test]
743    fn test_empty_dictionary() {
744        let filter = Filter::new();
745
746        assert_eq!(filter.find_in("任何文本"), (false, String::new()));
747        assert!(filter.find_all("任何文本").is_empty());
748        assert_eq!(filter.replace("任何文本", '*'), "任何文本");
749        assert_eq!(filter.filter("任何文本"), "任何文本");
750    }
751
752    #[test]
753    fn test_unicode_emoji_does_not_interfere() {
754        let mut filter = Filter::new();
755        filter.add_word("赌博");
756
757        // Emoji are stripped by the noise regex; surrounding CJK still matches.
758        let (found, word) = filter.find_in("🎉 赌博 🎰");
759        assert!(found);
760        assert_eq!(word, "赌博");
761    }
762
763    #[test]
764    fn test_very_long_text() {
765        let mut filter = Filter::new();
766        filter.add_word("赌博");
767
768        // 100_000 chars = 300_000 bytes, exercises the >1000-byte parallel path.
769        let long_text = "正常".repeat(100_000) + "赌博";
770        let results = filter.find_all(&long_text);
771        assert!(results.contains(&"赌博".to_string()));
772    }
773
774    #[test]
775    fn test_cjk_extension_b_chars() {
776        let mut filter = Filter::new();
777        filter.add_word("赌博");
778
779        // CJK Extension B (outside BMP); preceding CJK still matches.
780        let text = "含有赌博内容 𠀀𠀁";
781        let (found, _) = filter.find_in(text);
782        assert!(found);
783    }
784}