1use crate::engine::{MatchAlgorithm, MatchInfo, MultiPatternEngine};
11use alloc::string::String;
12use alloc::string::ToString;
13use alloc::vec;
14use alloc::vec::Vec;
15use hashbrown::HashSet;
16use regex::Regex;
17
18#[cfg(feature = "std")]
19use crate::variant::VariantDetector;
20#[cfg(feature = "parallel")]
21use rayon::prelude::*;
22#[cfg(feature = "std")]
23use std::{
24 fs::File,
25 io::{self, BufRead, BufReader},
26 path::Path,
27};
28#[cfg(feature = "std")]
29use {alloc::sync::Arc, lru::LruCache, std::num::NonZero, std::sync::Mutex};
30
31pub struct Filter {
33 engine: MultiPatternEngine, #[cfg(feature = "std")]
35 variant_detector: VariantDetector, noise: Regex, #[cfg(feature = "std")]
38 cache: Arc<Mutex<LruCache<String, Vec<String>>>>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Match {
47 pub word: String,
49 pub is_variant: bool,
51}
52
53impl core::fmt::Debug for Filter {
54 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
55 f.debug_struct("Filter").field("engine", &self.engine).field("noise", &self.noise).finish_non_exhaustive()
56 }
57}
58
59impl Filter {
60 pub fn new() -> Self {
72 Self {
73 engine: MultiPatternEngine::new(None, &[]),
74 #[cfg(feature = "std")]
75 variant_detector: VariantDetector::new(),
76 noise: Regex::new(r"[^\w\s\u4e00-\u9fff]").unwrap(),
77 #[cfg(feature = "std")]
78 cache: Arc::new(Mutex::new(LruCache::new(NonZero::new(1000).unwrap()))), }
80 }
81
82 #[cfg(feature = "std")]
83 fn check_cache(&self, text: &str) -> Option<Vec<String>> {
84 self.cache.lock().unwrap_or_else(|e| e.into_inner()).get(text).cloned()
85 }
86
87 #[cfg(feature = "std")]
88 fn cache_result(&self, text: &str, results: &[String]) {
89 self.cache.lock().unwrap_or_else(|e| e.into_inner()).put(text.to_string(), results.to_vec());
90 }
91
92 fn word_match_variants(word: &str) -> Vec<String> {
93 let mut variants = vec![word.to_string()];
94 if word.chars().any(char::is_whitespace) {
95 let folded: String = word.chars().filter(|c| !c.is_whitespace()).collect();
96 if !folded.is_empty() && folded != word {
97 variants.push(folded);
98 }
99 }
100 variants
101 }
102
103 fn extend_patterns_with_word_variants(patterns: &mut Vec<String>, words: &[&str]) {
104 let mut seen: HashSet<String> = patterns.iter().cloned().collect();
105 for word in words {
106 for variant in Self::word_match_variants(word) {
107 if seen.insert(variant.clone()) {
108 patterns.push(variant);
109 }
110 }
111 }
112 }
113
114 pub fn clear_cache(&self) {
116 #[cfg(feature = "std")]
117 self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
118 }
119
120 pub fn with_algorithm(algorithm: MatchAlgorithm) -> Self {
122 Self { engine: MultiPatternEngine::new(Some(algorithm), &[]), ..Self::new() }
123 }
124
125 #[cfg(feature = "std")]
127 pub fn with_default_dict() -> io::Result<Self> {
128 let mut filter = Self::new();
129 filter.load_word_dict("dict/dict.txt")?;
130 Ok(filter)
131 }
132
133 pub fn update_noise_pattern(&mut self, pattern: &str) -> Result<(), regex::Error> {
151 self.noise = Regex::new(pattern)?;
152 Ok(())
153 }
154
155 pub fn add_word(&mut self, word: &str) {
157 self.add_words(&[word]);
158 }
159
160 pub fn add_words(&mut self, words: &[&str]) {
172 let mut patterns = self.engine.get_patterns().to_vec();
173 Self::extend_patterns_with_word_variants(&mut patterns, words);
174
175 self.engine.rebuild(&patterns);
176 #[cfg(feature = "std")]
177 for word in words {
178 for variant in Self::word_match_variants(word) {
179 self.variant_detector.add_word(&variant);
180 }
181 }
182 self.clear_cache();
183 }
184
185 #[must_use]
187 pub fn current_algorithm(&self) -> MatchAlgorithm {
188 self.engine.current_algorithm()
189 }
190
191 pub fn del_word(&mut self, word: &str) {
193 self.del_words(&[word]);
194 }
195
196 pub fn del_words(&mut self, words: &[&str]) {
198 let word_set: HashSet<String> = words.iter().flat_map(|word| Self::word_match_variants(word)).collect();
199 let patterns: Vec<_> = self.engine.get_patterns().iter().filter(|w| !word_set.contains(*w)).cloned().collect();
200
201 self.engine.rebuild(&patterns);
202 self.clear_cache();
203 }
204
205 #[cfg(feature = "std")]
207 pub fn load_word_dict<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
208 let file = File::open(path)?;
209 self.load(BufReader::new(file))
210 }
211
212 #[cfg(feature = "std")]
228 pub fn load<R: BufRead>(&mut self, reader: R) -> io::Result<()> {
229 let words: Vec<_> = reader.lines().collect::<Result<_, _>>()?;
230 self.add_words(&words.iter().map(|s| s.as_str()).collect::<Vec<_>>());
231 Ok(())
232 }
233
234 #[cfg(feature = "net")]
236 pub fn load_net_word_dict(&mut self, url: &str) -> io::Result<()> {
237 let client = reqwest::blocking::Client::builder()
240 .timeout(std::time::Duration::from_secs(5))
241 .build()
242 .map_err(io::Error::other)?;
243 let response = client.get(url).send().map_err(io::Error::other)?;
244
245 if !response.status().is_success() {
246 return Err(io::Error::other(format!("HTTP request failed: {}", response.status())));
247 }
248
249 let reader = BufReader::new(response);
250 self.load(reader)
251 }
252
253 #[must_use]
280 pub fn find_first_match(&self, text: &str) -> Option<Match> {
281 let clean_text = self.remove_noise(text);
282
283 if let Some(word) = self.engine.find_first(&clean_text) {
285 return Some(Match { word, is_variant: false });
286 }
287
288 #[cfg(feature = "std")]
290 {
291 let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
292 if let Some(word) = self.variant_detector.detect(&clean_text, &patterns).first() {
293 return Some(Match { word: word.to_string(), is_variant: true });
294 }
295 }
296
297 None
298 }
299
300 #[must_use]
316 pub fn find_in(&self, text: &str) -> (bool, String) {
317 match self.find_first_match(text) {
318 Some(m) => (true, m.word),
319 None => (false, String::new()),
320 }
321 }
322
323 #[must_use]
339 pub fn replace(&self, text: &str, replacement: char) -> String {
340 let clean_text = self.remove_noise(text);
341 let repl = replacement.to_string();
342
343 let matches = self.leftmost_longest_matches(&clean_text);
346 let mut result = String::with_capacity(clean_text.len());
347 let mut cursor = 0usize;
348 for m in &matches {
349 result.push_str(&clean_text[cursor..m.start]);
350 result.push_str(&repl.repeat(clean_text[m.start..m.end].chars().count()));
351 cursor = m.end;
352 }
353 result.push_str(&clean_text[cursor..]);
354 result
355 }
356
357 #[must_use]
372 pub fn filter(&self, text: &str) -> String {
373 let clean_text = self.remove_noise(text);
374 self.engine.replace_all(&clean_text, "")
375 }
376
377 #[must_use]
393 pub fn validate(&self, text: &str) -> (bool, String) {
394 self.find_in(text)
395 }
396
397 #[must_use]
399 pub fn remove_noise(&self, text: &str) -> String {
400 self.noise.replace_all(text, "").to_string()
401 }
402
403 #[must_use]
405 pub fn get_noise_pattern(&self) -> &Regex {
406 &self.noise
407 }
408
409 fn leftmost_longest_matches(&self, clean_text: &str) -> Vec<MatchInfo> {
415 let mut matches = self.engine.find_matches_with_positions(clean_text);
416 matches.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)));
417 let mut kept = Vec::with_capacity(matches.len());
418 let mut cursor = 0usize;
419 for m in matches {
420 if m.start >= cursor {
421 cursor = m.end;
422 kept.push(m);
423 }
424 }
425 kept
426 }
427
428 #[must_use]
444 pub fn find_all(&self, text: &str) -> Vec<String> {
445 let clean_text = self.remove_noise(text);
446
447 #[cfg(feature = "std")]
449 if let Some(cached_result) = self.check_cache(&clean_text) {
450 return cached_result;
451 }
452
453 #[cfg(feature = "parallel")]
454 let results = if clean_text.len() > 1000 {
455 self.find_all_parallel(&clean_text) } else {
457 self.find_all_sequential(&clean_text) };
459 #[cfg(not(feature = "parallel"))]
460 let results = self.find_all_sequential(&clean_text);
461
462 #[cfg(feature = "std")]
464 self.cache_result(&clean_text, &results);
465
466 results
467 }
468
469 #[cfg(feature = "parallel")]
475 fn find_all_parallel(&self, text: &str) -> Vec<String> {
476 let patterns: Vec<&str> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
477
478 let (engine_results, variant_results) = rayon::join(
479 || self.engine.find_all(text),
480 || self.variant_detector.detect(text, &patterns).into_iter().map(String::from).collect::<Vec<_>>(),
481 );
482
483 let mut results = engine_results;
484 results.extend(variant_results);
485 self.deduplicate_and_sort(results)
486 }
487
488 fn find_all_sequential(&self, text: &str) -> Vec<String> {
490 let mut results = self.engine.find_all(text);
491
492 #[cfg(feature = "std")]
494 {
495 let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
496 results.extend(self.variant_detector.detect(text, &patterns).into_iter().map(|s| s.to_string()));
497 }
498
499 self.deduplicate_and_sort(results)
500 }
501
502 fn deduplicate_and_sort(&self, mut results: Vec<String>) -> Vec<String> {
504 results.sort_unstable();
505 results.dedup();
506 results
507 }
508
509 #[must_use]
527 pub fn find_all_batch(&self, texts: &[&str]) -> Vec<Vec<String>> {
528 #[cfg(feature = "parallel")]
529 {
530 texts.par_iter().map(|text| self.find_all(text)).collect()
531 }
532 #[cfg(not(feature = "parallel"))]
533 {
534 texts.iter().map(|text| self.find_all(text)).collect()
535 }
536 }
537
538 #[must_use]
555 pub fn find_all_layered(&self, text: &str) -> Vec<String> {
556 let clean_text = self.remove_noise(text);
557 let matches = self.leftmost_longest_matches(&clean_text);
558
559 let mut results: Vec<String> = matches.iter().map(|m| m.pattern.clone()).collect();
561
562 #[cfg(feature = "std")]
565 {
566 let mut remaining = String::with_capacity(clean_text.len());
567 let mut cursor = 0usize;
568 for m in &matches {
569 remaining.push_str(&clean_text[cursor..m.start]);
570 remaining.push(' ');
571 cursor = m.end;
572 }
573 remaining.push_str(&clean_text[cursor..]);
574
575 let patterns: Vec<&str> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
576 results.extend(self.variant_detector.detect(&remaining, &patterns).into_iter().map(String::from));
577 }
578
579 self.deduplicate_and_sort(results)
580 }
581
582 #[cfg(feature = "std")]
601 pub fn find_all_streaming<R: BufRead>(&self, reader: R) -> io::Result<Vec<String>> {
602 let mut all_results = Vec::new();
603
604 for line in reader.lines() {
605 let line = line?;
606 let results = self.find_all(&line);
607 all_results.extend(results);
608 }
609
610 Ok(self.deduplicate_and_sort(all_results))
611 }
612}
613
614#[cfg(feature = "async-io")]
617impl Filter {
618 pub async fn load_word_dict_async<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
631 use tokio::io::{AsyncBufReadExt, BufReader};
632 let file = tokio::fs::File::open(path).await?;
633 let mut lines = BufReader::new(file).lines();
634 let mut words = Vec::new();
635 while let Some(line) = lines.next_line().await? {
636 words.push(line);
637 }
638 let refs: Vec<&str> = words.iter().map(|s| s.as_str()).collect();
639 self.add_words(&refs);
640 Ok(())
641 }
642
643 #[cfg(feature = "net-async")]
656 pub async fn load_net_word_dict_async(&mut self, url: &str) -> io::Result<()> {
657 let response = reqwest::get(url).await.map_err(io::Error::other)?;
658 let content = response.text().await.map_err(io::Error::other)?;
659 let words: Vec<&str> = content.lines().map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
660 self.add_words(&words);
661 Ok(())
662 }
663}
664
665impl Default for Filter {
666 fn default() -> Self {
667 Self::new()
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use std::io::Cursor;
675 #[test]
676 fn test_integration() {
677 let mut filter = Filter::new();
678 filter.add_words(&["赌博", "色情"]);
679
680 assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
682
683 assert_eq!(filter.find_in("含有 dubo"), (true, "赌博".to_string()));
685
686 assert_eq!(filter.replace("赌博 色情", '*'), "** **");
688
689 assert_eq!(filter.filter("赌博内容"), "内容");
691 }
692
693 #[test]
694 fn test_noise_handling() {
695 let mut filter = Filter::new();
696 filter.add_word("赌博");
697
698 assert_eq!(filter.remove_noise("赌 博"), "赌 博");
700
701 assert_eq!(filter.remove_noise("赌@#$博"), "赌博");
703 }
704
705 #[test]
706 fn test_replace_vs_filter() {
707 let mut filter = Filter::new();
708 filter.add_words(&["赌博", "色情"]);
709
710 let text = "这里有赌博和色情内容";
711
712 assert_eq!(filter.replace(text, '*'), "这里有**和**内容");
714
715 assert_eq!(filter.filter(text), "这里有和内容");
717 }
718
719 #[test]
720 fn test_replace_single_pass_rebuild() {
721 let mut filter = Filter::new();
724 filter.add_words(&["赌博", "色情"]);
725 assert_eq!(filter.replace("前缀赌博中间色情后缀", '*'), "前缀**中间**后缀");
726 }
727
728 #[test]
729 fn test_variant_detection() {
730 let mut filter = Filter::new();
731 filter.add_word("测试");
732
733 assert_eq!(filter.find_in("ceshi"), (true, "测试".to_string()));
734 }
735
736 #[test]
737 fn test_find_first_match_exact() {
738 let mut filter = Filter::new();
739 filter.add_words(&["赌博", "色情"]);
740
741 assert_eq!(filter.find_first_match("含有赌博"), Some(Match { word: "赌博".to_string(), is_variant: false }));
742 assert_eq!(filter.find_first_match("正常文本"), None);
743 }
744
745 #[test]
746 fn test_find_first_match_variant() {
747 let mut filter = Filter::new();
748 filter.add_word("赌博");
749
750 assert_eq!(filter.find_first_match("含有 dubo"), Some(Match { word: "赌博".to_string(), is_variant: true }));
752 }
753
754 #[test]
755 fn test_find_first_match_prefers_exact_over_variant() {
756 let mut filter = Filter::new();
757 filter.add_word("赌博");
758
759 assert_eq!(filter.find_first_match("赌博 dubo"), Some(Match { word: "赌博".to_string(), is_variant: false }));
761 }
762
763 #[test]
764 fn test_algorithm_switch_one() {
765 let mut small = Filter::new();
767 small.add_words(&["a", "b", "c"]);
768 assert!(matches!(small.engine.current_algorithm(), MatchAlgorithm::WuManber));
769
770 let words: Vec<_> = (0..150).map(|i| format!("word{i}")).collect();
772 let mut medium = Filter::new();
773 medium.add_words(&words.iter().map(|s| s.as_str()).collect::<Vec<_>>());
774 println!("Medium current_algorithm: {:?}", medium.engine.current_algorithm());
775 assert!(matches!(medium.engine.current_algorithm(), MatchAlgorithm::AhoCorasick));
776 }
777
778 #[test]
779 fn test_io_operations() -> io::Result<()> {
780 let mut filter = Filter::new();
781 let cursor = Cursor::new("word1\nword2\nword3");
782 filter.load(cursor)?;
783
784 assert_eq!(filter.find_in("word2"), (true, "word2".to_string()));
785 Ok(())
786 }
787
788 #[test]
789 fn test_space_folded_word_matches_both_forms() {
790 let mut filter = Filter::new();
791 filter.add_word("A 级");
792
793 assert_eq!(filter.find_in("含有 A 级 内容"), (true, "A 级".to_string()));
794 assert_eq!(filter.find_in("含有 A级 内容"), (true, "A级".to_string()));
795
796 let results = filter.find_all("A 级 和 A级");
797 assert!(results.contains(&"A 级".to_string()));
798 assert!(results.contains(&"A级".to_string()));
799 }
800
801 #[test]
802 fn test_loaded_space_word_adds_folded_match() -> io::Result<()> {
803 let mut filter = Filter::new();
804 filter.load(Cursor::new("A 级\n3 级片"))?;
805
806 assert_eq!(filter.find_in("这里有 A级 内容"), (true, "A级".to_string()));
807 assert_eq!(filter.find_in("这里有 3级片 内容"), (true, "3级片".to_string()));
808 Ok(())
809 }
810
811 #[test]
812 fn test_delete_space_word_removes_both_forms() {
813 let mut filter = Filter::new();
814 filter.add_words(&["A 级", "B 级"]);
815
816 filter.del_word("A 级");
817
818 assert_eq!(filter.find_in("含有 A 级 内容"), (false, String::new()));
819 assert_eq!(filter.find_in("含有 A级 内容"), (false, String::new()));
820 assert_eq!(filter.find_in("含有 B级 内容"), (true, "B级".to_string()));
821 }
822
823 #[test]
824 fn test_algorithm_recommendation() {
825 assert_eq!(MultiPatternEngine::recommend_algorithm(50), MatchAlgorithm::WuManber);
826 assert_eq!(MultiPatternEngine::recommend_algorithm(150), MatchAlgorithm::AhoCorasick);
827 assert_eq!(MultiPatternEngine::recommend_algorithm(15000), MatchAlgorithm::Regex);
828 }
829
830 #[test]
831 fn test_algorithm_switch() {
832 let mut small = Filter::new();
834 small.add_words(&["a", "b", "c"]);
835 println!("Small (3 words): {:?}", small.current_algorithm());
836 assert!(matches!(small.current_algorithm(), MatchAlgorithm::WuManber));
837
838 let words: Vec<_> = (0..150).map(|i| format!("word{i}")).collect();
840 let word_refs: Vec<&str> = words.iter().map(|s| s.as_str()).collect();
841
842 let mut medium = Filter::new();
843 medium.add_words(&word_refs);
844
845 println!("Medium (150 words): {:?}", medium.current_algorithm());
846 println!("Pattern count: {}", medium.engine.get_patterns().len());
847
848 let recommended = MultiPatternEngine::recommend_algorithm(150);
850 println!("Recommended algorithm for 150 words: {recommended:?}");
851
852 assert!(matches!(medium.current_algorithm(), MatchAlgorithm::AhoCorasick));
853 }
854
855 #[test]
856 fn test_cache_invalidation_on_add_word() {
857 let mut filter = Filter::new();
858 filter.add_word("赌博");
859
860 let results1 = filter.find_all("这里有赌博");
862 assert!(results1.contains(&"赌博".to_string()));
863
864 filter.add_word("色情");
866
867 let results2 = filter.find_all("这里有赌博和色情");
869 assert!(results2.contains(&"赌博".to_string()));
870 assert!(results2.contains(&"色情".to_string()));
871 }
872
873 #[test]
874 fn test_cache_invalidation_on_del_word() {
875 let mut filter = Filter::new();
876 filter.add_words(&["赌博", "色情"]);
877
878 let results1 = filter.find_all("这里有赌博和色情");
880 assert!(results1.contains(&"赌博".to_string()));
881 assert!(results1.contains(&"色情".to_string()));
882
883 filter.del_word("赌博");
885
886 let results2 = filter.find_all("这里有赌博和色情");
888 assert!(!results2.contains(&"赌博".to_string()));
889 assert!(results2.contains(&"色情".to_string()));
890 }
891
892 #[test]
893 fn test_mutex_poison_recovery() {
894 use std::sync::Arc;
895
896 let filter = Arc::new(Filter::new());
897 let filter_clone = Arc::clone(&filter);
898
899 let handle = std::thread::spawn(move || {
901 let _guard = filter_clone.cache.lock().unwrap();
902 panic!("intentional panic to poison mutex");
903 });
904 let _ = handle.join();
905
906 let results = filter.find_all("test");
908 assert!(results.is_empty());
909 }
910
911 #[test]
912 fn test_parallel_search_cross_boundary() {
913 let mut filter = Filter::new();
914 filter.add_word("赌博");
915
916 let prefix: String = "安全文字".repeat(200); let text = format!("{prefix}这里有赌博内容");
920
921 let results = filter.find_all(&text);
922 assert!(results.contains(&"赌博".to_string()));
923 }
924
925 #[test]
926 fn test_parallel_search_no_duplicates() {
927 let mut filter = Filter::new();
928 filter.add_word("赌博");
929
930 let prefix: String = "安全".repeat(300); let text = format!("{prefix}赌博{prefix}");
933
934 let results = filter.find_all(&text);
935 let count = results.iter().filter(|w| *w == "赌博").count();
936 assert_eq!(count, 1, "expected exactly 1 match, got {count}");
937 }
938
939 #[test]
942 fn test_find_all_batch() {
943 let mut filter = Filter::new();
944 filter.add_words(&["赌博", "色情"]);
945
946 let texts = vec!["含有赌博", "含有色情", "正常内容"];
947 let results = filter.find_all_batch(&texts);
948
949 assert_eq!(results.len(), 3);
950 assert!(results[0].contains(&"赌博".to_string()));
951 assert!(results[1].contains(&"色情".to_string()));
952 assert!(results[2].is_empty());
953 }
954
955 #[test]
956 fn test_find_all_batch_empty() {
957 let filter = Filter::new();
958 let results = filter.find_all_batch(&[]);
959 assert!(results.is_empty());
960 }
961
962 #[test]
963 fn test_find_all_layered_prefers_longest() {
964 let mut filter = Filter::new();
965 filter.add_words(&["赌", "赌博", "赌博机"]);
966
967 let results = filter.find_all_layered("这里有赌博机");
969 assert!(results.contains(&"赌博机".to_string()));
970 assert!(!results.contains(&"赌".to_string()));
971 assert!(!results.contains(&"赌博".to_string()));
972 }
973
974 #[test]
975 fn test_find_all_layered_multi_occurrence() {
976 let mut filter = Filter::new();
979 filter.add_words(&["赌", "赌博", "赌博机"]);
980
981 let results = filter.find_all_layered("赌博和赌博机");
982 assert!(results.contains(&"赌博".to_string()));
983 assert!(results.contains(&"赌博机".to_string()));
984 assert!(!results.contains(&"赌".to_string()));
985 }
986
987 #[test]
988 fn test_find_all_layered_blanks_exact_before_variant() {
989 let mut filter = Filter::new();
992 filter.add_words(&["赌", "赌博", "赌博机"]);
993
994 let results = filter.find_all_layered("这里有赌博机");
995 assert_eq!(results, vec!["赌博机".to_string()]);
998 }
999
1000 #[test]
1001 fn test_replace_and_filter_are_exact_only() {
1002 let mut filter = Filter::new();
1006 filter.add_word("赌博");
1007
1008 assert_eq!(filter.replace("含有赌博", '*'), "含有**");
1010 assert_eq!(filter.filter("含有赌博"), "含有");
1011 assert_eq!(filter.replace("dubo", '*'), "dubo");
1013 assert_eq!(filter.filter("dubo"), "dubo");
1014 }
1015
1016 #[test]
1017 fn test_find_all_streaming_multiline() {
1018 let mut filter = Filter::new();
1019 filter.add_words(&["赌博", "色情"]);
1020
1021 let input = "第一行含有赌博\n第二行含有色情\n第三行正常";
1022 let cursor = std::io::Cursor::new(input);
1023 let results = filter.find_all_streaming(cursor).unwrap();
1024
1025 assert!(results.contains(&"赌博".to_string()));
1026 assert!(results.contains(&"色情".to_string()));
1027 assert_eq!(results.len(), 2);
1028 }
1029
1030 #[test]
1033 fn test_cache_hit_returns_consistent_results() {
1034 let mut filter = Filter::new();
1035 filter.add_words(&["赌博", "色情"]);
1036
1037 let r1 = filter.find_all("含有赌博和色情内容");
1039 let r2 = filter.find_all("含有赌博和色情内容");
1040 assert_eq!(r1, r2);
1041 }
1042
1043 #[test]
1044 fn test_cache_clear() {
1045 let mut filter = Filter::new();
1046 filter.add_word("赌博");
1047
1048 let _ = filter.find_all("含有赌博"); filter.clear_cache();
1050
1051 let results = filter.find_all("含有赌博");
1053 assert!(results.contains(&"赌博".to_string()));
1054 }
1055
1056 #[test]
1059 fn test_empty_text() {
1060 let mut filter = Filter::new();
1061 filter.add_word("赌博");
1062
1063 assert_eq!(filter.find_in(""), (false, String::new()));
1064 assert!(filter.find_all("").is_empty());
1065 assert_eq!(filter.replace("", '*'), "");
1066 assert_eq!(filter.filter(""), "");
1067 }
1068
1069 #[test]
1070 fn test_empty_dictionary() {
1071 let filter = Filter::new();
1072
1073 assert_eq!(filter.find_in("任何文本"), (false, String::new()));
1074 assert!(filter.find_all("任何文本").is_empty());
1075 assert_eq!(filter.replace("任何文本", '*'), "任何文本");
1076 assert_eq!(filter.filter("任何文本"), "任何文本");
1077 }
1078
1079 #[test]
1080 fn test_unicode_emoji_does_not_interfere() {
1081 let mut filter = Filter::new();
1082 filter.add_word("赌博");
1083
1084 let (found, word) = filter.find_in("🎉 赌博 🎰");
1086 assert!(found);
1087 assert_eq!(word, "赌博");
1088 }
1089
1090 #[test]
1091 fn test_very_long_text() {
1092 let mut filter = Filter::new();
1093 filter.add_word("赌博");
1094
1095 let long_text = "正常".repeat(100_000) + "赌博";
1097 let results = filter.find_all(&long_text);
1098 assert!(results.contains(&"赌博".to_string()));
1099 }
1100
1101 #[test]
1102 fn test_cjk_extension_b_chars() {
1103 let mut filter = Filter::new();
1104 filter.add_word("赌博");
1105
1106 let text = "含有赌博内容 𠀀𠀁";
1108 let (found, _) = filter.find_in(text);
1109 assert!(found);
1110 }
1111
1112 #[cfg(feature = "async-io")]
1115 #[tokio::test(flavor = "multi_thread")]
1116 async fn test_load_word_dict_async() {
1117 let path = std::env::temp_dir().join(format!("sensitive-rs-async-{}.txt", std::process::id()));
1118 std::fs::write(&path, "赌博\n色情\n").unwrap();
1119
1120 let mut filter = Filter::new();
1121 filter.load_word_dict_async(&path).await.unwrap();
1122
1123 assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
1124 assert!(filter.find_all("赌博和色情").iter().any(|w| w == "色情"));
1125 let _ = std::fs::remove_file(&path);
1126 }
1127}