Skip to main content

sensitive_rs/
filter.rs

1//! Main filtering API.
2//!
3//! [`Filter`] is the primary entry point: load a dictionary with [`Filter::add_word`] /
4//! [`Filter::add_words`] / [`Filter::load_word_dict`], then query with [`Filter::find_all`]
5//! (all matches), [`Filter::find_in`] or [`Filter::find_first_match`] (first match),
6//! [`Filter::replace`] (mask), or [`Filter::filter`] (remove). Input text is first cleaned of
7//! noise via a configurable regex, then matched exactly against the dictionary, and finally
8//! checked for pinyin/shape variants.
9
10use crate::engine::MatchAlgorithm;
11use crate::{engine::MultiPatternEngine, variant::VariantDetector};
12use lru::LruCache;
13#[cfg(feature = "parallel")]
14use rayon::prelude::*;
15use regex::Regex;
16use std::collections::HashSet;
17use std::num::NonZero;
18use std::sync::{Arc, Mutex};
19use std::{
20    fs::File,
21    io::{self, BufRead, BufReader},
22    path::Path,
23};
24
25/// Advanced sensitive word filter with variant detection
26pub struct Filter {
27    engine: MultiPatternEngine,        // Multi-pattern matching engine
28    variant_detector: VariantDetector, // Variation detector
29    noise: Regex,                      // Noise processing rules
30    cache: Arc<Mutex<LruCache<String, Vec<String>>>>,
31    #[cfg(feature = "net")]
32    http_client: reqwest::blocking::Client, // Network request client
33}
34
35/// A sensitive-word match found by [`Filter::find_first_match`].
36///
37/// `word` is the matched word in its dictionary form; `is_variant` is `true` when
38/// the match came from pinyin/shape variant detection rather than an exact hit.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Match {
41    /// The matched sensitive word, in dictionary form.
42    pub word: String,
43    /// `true` if matched via a pinyin/shape variant rather than an exact hit.
44    pub is_variant: bool,
45}
46
47impl std::fmt::Debug for Filter {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_struct("Filter")
50            .field("engine", &self.engine)
51            .field("variant_detector", &self.variant_detector)
52            .field("noise", &self.noise)
53            .field("cache", &"<LruCache>")
54            .finish()
55    }
56}
57
58impl Filter {
59    /// Create a new filter with default settings.
60    ///
61    /// # Examples
62    ///
63    /// ```
64    /// use sensitive_rs::Filter;
65    ///
66    /// let mut filter = Filter::new();
67    /// filter.add_word("赌博");
68    /// assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
69    /// ```
70    pub fn new() -> Self {
71        Self {
72            engine: MultiPatternEngine::new(None, &[]),
73            variant_detector: VariantDetector::new(),
74            noise: Regex::new(r"[^\w\s\u4e00-\u9fff]").unwrap(),
75            cache: Arc::new(Mutex::new(LruCache::new(NonZero::new(1000).unwrap()))), // Cache 1000 results
76            #[cfg(feature = "net")]
77            http_client: reqwest::blocking::Client::builder()
78                .timeout(std::time::Duration::from_secs(5))
79                .build()
80                .unwrap(),
81        }
82    }
83
84    fn check_cache(&self, text: &str) -> Option<Vec<String>> {
85        self.cache.lock().unwrap_or_else(|e| e.into_inner()).get(text).cloned()
86    }
87
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    /// Clear the cache
115    pub fn clear_cache(&self) {
116        self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
117    }
118
119    /// Create with specific algorithm
120    pub fn with_algorithm(algorithm: MatchAlgorithm) -> Self {
121        Self { engine: MultiPatternEngine::new(Some(algorithm), &[]), ..Self::new() }
122    }
123
124    /// Load default dictionary
125    pub fn with_default_dict() -> io::Result<Self> {
126        let mut filter = Self::new();
127        filter.load_word_dict("dict/dict.txt")?;
128        Ok(filter)
129    }
130
131    /// Update noise pattern
132    ///
133    /// Sets the regex used to strip noise characters before matching. Characters
134    /// **not** matched by the regex are kept. Returns an error if `pattern` is invalid.
135    ///
136    /// # Examples
137    ///
138    /// ```
139    /// use sensitive_rs::Filter;
140    ///
141    /// let mut filter = Filter::new();
142    /// filter.add_word("赌博");
143    /// // Strip everything except CJK and ASCII word characters.
144    /// filter.update_noise_pattern(r"[^\w一-鿿]")?;
145    /// assert_eq!(filter.remove_noise("赌@#博"), "赌博");
146    /// # Ok::<(), regex::Error>(())
147    /// ```
148    pub fn update_noise_pattern(&mut self, pattern: &str) -> Result<(), regex::Error> {
149        self.noise = Regex::new(pattern)?;
150        Ok(())
151    }
152
153    /// Add a sensitive word
154    pub fn add_word(&mut self, word: &str) {
155        let mut patterns = self.engine.get_patterns().to_vec();
156        Self::extend_patterns_with_word_variants(&mut patterns, &[word]);
157        self.engine.rebuild(&patterns);
158        for variant in Self::word_match_variants(word) {
159            self.variant_detector.add_word(&variant);
160        }
161        self.clear_cache();
162    }
163
164    /// Add multiple words
165    ///
166    /// # Examples
167    ///
168    /// ```
169    /// use sensitive_rs::Filter;
170    ///
171    /// let mut filter = Filter::new();
172    /// filter.add_words(&["赌博", "色情"]);
173    /// assert!(filter.find_all("含有赌博和色情").contains(&"赌博".to_string()));
174    /// ```
175    pub fn add_words(&mut self, words: &[&str]) {
176        let mut patterns = self.engine.get_patterns().to_vec();
177        Self::extend_patterns_with_word_variants(&mut patterns, words);
178
179        self.engine.rebuild(&patterns);
180        for word in words {
181            for variant in Self::word_match_variants(word) {
182                self.variant_detector.add_word(&variant);
183            }
184        }
185        self.clear_cache();
186    }
187
188    /// Get the currently used algorithm
189    #[must_use]
190    pub fn current_algorithm(&self) -> MatchAlgorithm {
191        self.engine.current_algorithm()
192    }
193
194    /// Remove a word
195    pub fn del_word(&mut self, word: &str) {
196        let word_set: HashSet<_> = Self::word_match_variants(word).into_iter().collect();
197        let patterns: Vec<_> = self.engine.get_patterns().iter().filter(|w| !word_set.contains(*w)).cloned().collect();
198
199        self.engine.rebuild(&patterns);
200        self.clear_cache();
201    }
202
203    /// Remove multiple words
204    pub fn del_words(&mut self, words: &[&str]) {
205        let word_set: HashSet<String> = words.iter().flat_map(|word| Self::word_match_variants(word)).collect();
206        let patterns: Vec<_> = self.engine.get_patterns().iter().filter(|w| !word_set.contains(*w)).cloned().collect();
207
208        self.engine.rebuild(&patterns);
209        self.clear_cache();
210    }
211
212    /// Load dictionary from file
213    pub fn load_word_dict<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
214        let file = File::open(path)?;
215        self.load(BufReader::new(file))
216    }
217
218    /// Load dictionary from reader
219    ///
220    /// Each line of the reader is one dictionary word.
221    ///
222    /// # Examples
223    ///
224    /// ```
225    /// use sensitive_rs::Filter;
226    /// use std::io::Cursor;
227    ///
228    /// let mut filter = Filter::new();
229    /// filter.load(Cursor::new("赌博\n色情"))?;
230    /// assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
231    /// # Ok::<(), std::io::Error>(())
232    /// ```
233    pub fn load<R: BufRead>(&mut self, reader: R) -> io::Result<()> {
234        let words: Vec<_> = reader.lines().collect::<Result<_, _>>()?;
235        self.add_words(&words.iter().map(|s| s.as_str()).collect::<Vec<_>>());
236        Ok(())
237    }
238
239    /// Load dictionary from URL
240    #[cfg(feature = "net")]
241    pub fn load_net_word_dict(&mut self, url: &str) -> io::Result<()> {
242        let response = self.http_client.get(url).send().map_err(io::Error::other)?;
243
244        if !response.status().is_success() {
245            return Err(io::Error::other(format!("HTTP request failed: {}", response.status())));
246        }
247
248        let reader = BufReader::new(response);
249        self.load(reader)
250    }
251
252    /// Find the first sensitive word, returning a [`Match`] with details, or `None`.
253    ///
254    /// Exact matches are preferred; pinyin/shape variants are only consulted when no
255    /// exact hit is found. `is_variant` on the returned [`Match`] records which path hit.
256    ///
257    /// # Examples
258    ///
259    /// ```
260    /// use sensitive_rs::{Filter, Match};
261    ///
262    /// let mut filter = Filter::new();
263    /// filter.add_word("赌博");
264    ///
265    /// // Exact hit:
266    /// assert_eq!(
267    ///     filter.find_first_match("含有赌博"),
268    ///     Some(Match { word: "赌博".to_string(), is_variant: false })
269    /// );
270    /// // Pinyin variant (no exact hit):
271    /// assert_eq!(
272    ///     filter.find_first_match("dubo"),
273    ///     Some(Match { word: "赌博".to_string(), is_variant: true })
274    /// );
275    /// // No match:
276    /// assert_eq!(filter.find_first_match("clean text"), None);
277    /// ```
278    #[must_use]
279    pub fn find_first_match(&self, text: &str) -> Option<Match> {
280        let clean_text = self.remove_noise(text);
281
282        // 1. Try exact match first
283        if let Some(word) = self.engine.find_first(&clean_text) {
284            return Some(Match { word, is_variant: false });
285        }
286
287        // 2. Try variant detection
288        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
289
290        if let Some(word) = self.variant_detector.detect(&clean_text, &patterns).first() {
291            return Some(Match { word: word.to_string(), is_variant: true });
292        }
293
294        None
295    }
296
297    /// Find first sensitive word.
298    ///
299    /// Returns `(found, word)`; `word` is empty when nothing matched. For richer detail
300    /// (including whether the hit was a variant), use [`Filter::find_first_match`].
301    ///
302    /// # Examples
303    ///
304    /// ```
305    /// use sensitive_rs::Filter;
306    ///
307    /// let mut filter = Filter::new();
308    /// filter.add_word("赌博");
309    /// assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
310    /// assert_eq!(filter.find_in("clean text"), (false, String::new()));
311    /// ```
312    #[must_use]
313    pub fn find_in(&self, text: &str) -> (bool, String) {
314        match self.find_first_match(text) {
315            Some(m) => (true, m.word),
316            None => (false, String::new()),
317        }
318    }
319
320    /// Replace sensitive words with replacement character.
321    ///
322    /// Each matched character is replaced by one `replacement` char.
323    ///
324    /// # Examples
325    ///
326    /// ```
327    /// use sensitive_rs::Filter;
328    ///
329    /// let mut filter = Filter::new();
330    /// filter.add_word("赌博");
331    /// assert_eq!(filter.replace("含有赌博内容", '*'), "含有**内容");
332    /// ```
333    #[must_use]
334    pub fn replace(&self, text: &str, replacement: char) -> String {
335        let clean_text = self.remove_noise(text);
336        let repl = replacement.to_string();
337
338        // Exact matches: single-pass rebuild from engine match positions
339        // (leftmost-longest, so overlapping dict words resolve cleanly), one
340        // replacement char per matched character.
341        let mut positions = self.engine.find_matches_with_positions(&clean_text);
342        positions.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)));
343        let mut result = String::with_capacity(clean_text.len());
344        let mut cursor = 0usize;
345        for m in &positions {
346            if m.start < cursor {
347                continue; // covered by a previously kept (longer-leftmost) span
348            }
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
355        // Variant branch: `detect` returns original word names, not the variant
356        // text actually present, so these replaces are no-ops when only variants
357        // occur. Kept unchanged in this perf pass; revisit separately.
358        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
359        let variants = self.variant_detector.detect(&result, &patterns);
360        for variant in variants {
361            let repl_str = repl.repeat(variant.chars().count());
362            result = result.replace(variant, &repl_str);
363        }
364
365        result
366    }
367
368    /// Filter out sensitive words (remove them completely).
369    ///
370    /// # Examples
371    ///
372    /// ```
373    /// use sensitive_rs::Filter;
374    ///
375    /// let mut filter = Filter::new();
376    /// filter.add_word("赌博");
377    /// assert_eq!(filter.filter("含有赌博内容"), "含有内容");
378    /// ```
379    #[must_use]
380    pub fn filter(&self, text: &str) -> String {
381        let clean_text = self.remove_noise(text);
382
383        // Use engine's optimized replace_all for pattern removal
384        let mut result = self.engine.replace_all(&clean_text, "");
385
386        // Remove sensitive words detected by variants
387        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
388        let variants = self.variant_detector.detect(&result, &patterns);
389
390        for variant in variants {
391            result = result.replace(variant, "");
392        }
393
394        result
395    }
396
397    /// Validate text
398    ///
399    /// This is an alias for [`Filter::find_in`]: it returns `(found, word)` for the
400    /// first sensitive word encountered.
401    ///
402    /// # Examples
403    ///
404    /// ```
405    /// use sensitive_rs::Filter;
406    ///
407    /// let mut filter = Filter::new();
408    /// filter.add_word("赌博");
409    /// assert_eq!(filter.validate("含有赌博"), (true, "赌博".to_string()));
410    /// assert_eq!(filter.validate("clean text"), (false, String::new()));
411    /// ```
412    #[must_use]
413    pub fn validate(&self, text: &str) -> (bool, String) {
414        self.find_in(text)
415    }
416
417    /// Remove only specific noise characters, preserve spaces
418    #[must_use]
419    pub fn remove_noise(&self, text: &str) -> String {
420        self.noise.replace_all(text, "").to_string()
421    }
422
423    /// Get current noise pattern
424    #[must_use]
425    pub fn get_noise_pattern(&self) -> &Regex {
426        &self.noise
427    }
428}
429
430impl Filter {
431    /// Optimized method of finding all sensitive words.
432    ///
433    /// Returns the de-duplicated, sorted list of matched dictionary words (variants
434    /// included). Results are cached, so repeated calls on the same text are cheap.
435    ///
436    /// # Examples
437    ///
438    /// ```
439    /// use sensitive_rs::Filter;
440    ///
441    /// let mut filter = Filter::new();
442    /// filter.add_words(&["赌博", "色情"]);
443    /// // Results are sorted: 色 (U+8272) sorts before 赌 (U+8D4C).
444    /// assert_eq!(filter.find_all("含有赌博和色情内容"), vec!["色情".to_string(), "赌博".to_string()]);
445    /// ```
446    #[must_use]
447    pub fn find_all(&self, text: &str) -> Vec<String> {
448        let clean_text = self.remove_noise(text);
449
450        // 1. Caching mechanism - Check whether the results have been cached
451        if let Some(cached_result) = self.check_cache(&clean_text) {
452            return cached_result;
453        }
454
455        #[cfg(feature = "parallel")]
456        let results = if clean_text.len() > 1000 {
457            self.find_all_parallel(&clean_text) // long text -> parallel
458        } else {
459            self.find_all_sequential(&clean_text) // short text -> sequential
460        };
461        #[cfg(not(feature = "parallel"))]
462        let results = self.find_all_sequential(&clean_text);
463
464        // 3. Cache results
465        self.cache_result(&clean_text, &results);
466
467        results
468    }
469
470    /// Parallel Processing Version - For Long Text
471    #[cfg(feature = "parallel")]
472    fn find_all_parallel(&self, text: &str) -> Vec<String> {
473        let chunk_size = std::cmp::max(text.len() / rayon::current_num_threads(), 100);
474        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
475
476        // Compute overlap to catch patterns spanning chunk boundaries
477        let max_pattern_len = patterns.iter().map(|p| p.chars().count()).max().unwrap_or(0);
478        let overlap = max_pattern_len.min(chunk_size);
479
480        // Build overlapping chunks for parallel processing
481        let chars: Vec<char> = text.chars().collect();
482        let engine_results: Vec<String> = if chars.len() <= chunk_size {
483            self.engine.find_all(text)
484        } else {
485            let step = chunk_size;
486            chars
487                .windows(chunk_size + overlap)
488                .step_by(step)
489                .collect::<Vec<_>>()
490                .par_iter()
491                .flat_map(|window| {
492                    let chunk_text: String = window.iter().collect();
493                    self.engine.find_all(&chunk_text)
494                })
495                .collect()
496        };
497
498        // Parallel variant detection - Fixed parallel iterator problem
499        let variant_results: Vec<String> = text
500            .split_whitespace()
501            .collect::<Vec<_>>()
502            .par_iter()
503            .map(|segment| self.variant_detector.detect(segment, &patterns))
504            .flatten()
505            .map(|s| s.to_string())
506            .collect();
507
508        // Merge and remove repetition
509        let mut results = engine_results;
510        results.extend(variant_results);
511        self.deduplicate_and_sort(results)
512    }
513
514    /// Sequential processing version - suitable for short text
515    fn find_all_sequential(&self, text: &str) -> Vec<String> {
516        let mut results = self.engine.find_all(text);
517        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
518
519        // Add variant detection results
520        results.extend(self.variant_detector.detect(text, &patterns).into_iter().map(|s| s.to_string()));
521
522        self.deduplicate_and_sort(results)
523    }
524
525    /// Deduplication and sort
526    fn deduplicate_and_sort(&self, mut results: Vec<String>) -> Vec<String> {
527        results.sort_unstable();
528        results.dedup();
529        results
530    }
531
532    /// Bulk search for optimized versions
533    ///
534    /// Runs [`Filter::find_all`] over each text. With the `parallel` feature (default) the
535    /// texts are processed concurrently.
536    ///
537    /// # Examples
538    ///
539    /// ```
540    /// use sensitive_rs::Filter;
541    ///
542    /// let mut filter = Filter::new();
543    /// filter.add_words(&["赌博", "色情"]);
544    /// let results = filter.find_all_batch(&["含有赌博", "正常", "含有色情"]);
545    /// assert!(results[0].contains(&"赌博".to_string()));
546    /// assert!(results[1].is_empty());
547    /// assert!(results[2].contains(&"色情".to_string()));
548    /// ```
549    #[must_use]
550    pub fn find_all_batch(&self, texts: &[&str]) -> Vec<Vec<String>> {
551        #[cfg(feature = "parallel")]
552        {
553            texts.par_iter().map(|text| self.find_all(text)).collect()
554        }
555        #[cfg(not(feature = "parallel"))]
556        {
557            texts.iter().map(|text| self.find_all(text)).collect()
558        }
559    }
560
561    /// Hierarchical Matching - Preferential Matching by Sensitive Word Length
562    ///
563    /// Matches the longest words first and consumes their span, so shorter overlapping
564    /// entries are dropped. Useful when the dictionary contains both short and long forms.
565    ///
566    /// # Examples
567    ///
568    /// ```
569    /// use sensitive_rs::Filter;
570    ///
571    /// let mut filter = Filter::new();
572    /// filter.add_words(&["赌", "赌博", "赌博机"]);
573    /// let results = filter.find_all_layered("这里有赌博机");
574    /// assert!(results.contains(&"赌博机".to_string()));
575    /// assert!(!results.contains(&"赌博".to_string()));
576    /// ```
577    #[must_use]
578    pub fn find_all_layered(&self, text: &str) -> Vec<String> {
579        let clean_text = self.remove_noise(text);
580        let mut results = Vec::new();
581        let mut remaining_text = clean_text.clone();
582
583        // Arrange patterns in descending order of length, prioritize long words
584        let mut sorted_patterns = self.engine.get_patterns().to_vec();
585        sorted_patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
586
587        // Hierarchical matching
588        for pattern in &sorted_patterns {
589            if remaining_text.contains(pattern) {
590                results.push(pattern.clone());
591                // Remove matching parts to avoid duplicate matches
592                remaining_text = remaining_text.replace(pattern, " ");
593            }
594        }
595
596        // Variation detection (for remaining text)
597        let patterns: Vec<_> = sorted_patterns.iter().map(|s| s.as_str()).collect();
598        results.extend(self.variant_detector.detect(&remaining_text, &patterns).into_iter().map(|s| s.to_string()));
599
600        self.deduplicate_and_sort(results)
601    }
602
603    /// Streaming version - suitable for oversized text
604    ///
605    /// Reads line-by-line from any [`BufRead`] and returns the de-duplicated matches
606    /// across all lines. Handy for files too large to hold in memory.
607    ///
608    /// # Examples
609    ///
610    /// ```
611    /// use sensitive_rs::Filter;
612    /// use std::io::Cursor;
613    ///
614    /// let mut filter = Filter::new();
615    /// filter.add_words(&["赌博", "色情"]);
616    /// let input = "第一行含有赌博\n第二行含有色情\n第三行正常";
617    /// let results = filter.find_all_streaming(Cursor::new(input))?;
618    /// assert_eq!(results.len(), 2);
619    /// # Ok::<(), std::io::Error>(())
620    /// ```
621    pub fn find_all_streaming<R: BufRead>(&self, reader: R) -> io::Result<Vec<String>> {
622        let mut all_results = Vec::new();
623
624        for line in reader.lines() {
625            let line = line?;
626            let results = self.find_all(&line);
627            all_results.extend(results);
628        }
629
630        Ok(self.deduplicate_and_sort(all_results))
631    }
632}
633
634impl Default for Filter {
635    fn default() -> Self {
636        Self::new()
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use std::io::Cursor;
644    #[test]
645    fn test_integration() {
646        let mut filter = Filter::new();
647        filter.add_words(&["赌博", "色情"]);
648
649        // Exact match
650        assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
651
652        // Pinyin variant
653        assert_eq!(filter.find_in("含有 dubo"), (true, "赌博".to_string()));
654
655        // Replacement
656        assert_eq!(filter.replace("赌博 色情", '*'), "** **");
657
658        // Filter
659        assert_eq!(filter.filter("赌博内容"), "内容");
660    }
661
662    #[test]
663    fn test_noise_handling() {
664        let mut filter = Filter::new();
665        filter.add_word("赌博");
666
667        // 测试空格保留
668        assert_eq!(filter.remove_noise("赌 博"), "赌 博");
669
670        // 测试特殊符号移除
671        assert_eq!(filter.remove_noise("赌@#$博"), "赌博");
672    }
673
674    #[test]
675    fn test_replace_vs_filter() {
676        let mut filter = Filter::new();
677        filter.add_words(&["赌博", "色情"]);
678
679        let text = "这里有赌博和色情内容";
680
681        // replace should be replaced with characters
682        assert_eq!(filter.replace(text, '*'), "这里有**和**内容");
683
684        // filter should be completely removed
685        assert_eq!(filter.filter(text), "这里有和内容");
686    }
687
688    #[test]
689    fn test_replace_single_pass_rebuild() {
690        // R3: exact matches rebuilt in a single pass, one '*' per matched char,
691        // regardless of how many patterns hit.
692        let mut filter = Filter::new();
693        filter.add_words(&["赌博", "色情"]);
694        assert_eq!(filter.replace("前缀赌博中间色情后缀", '*'), "前缀**中间**后缀");
695    }
696
697    #[test]
698    fn test_variant_detection() {
699        let mut filter = Filter::new();
700        filter.add_word("测试");
701
702        assert_eq!(filter.find_in("ceshi"), (true, "测试".to_string()));
703    }
704
705    #[test]
706    fn test_find_first_match_exact() {
707        let mut filter = Filter::new();
708        filter.add_words(&["赌博", "色情"]);
709
710        assert_eq!(filter.find_first_match("含有赌博"), Some(Match { word: "赌博".to_string(), is_variant: false }));
711        assert_eq!(filter.find_first_match("正常文本"), None);
712    }
713
714    #[test]
715    fn test_find_first_match_variant() {
716        let mut filter = Filter::new();
717        filter.add_word("赌博");
718
719        // Pinyin variant path: word found, but is_variant = true.
720        assert_eq!(filter.find_first_match("含有 dubo"), Some(Match { word: "赌博".to_string(), is_variant: true }));
721    }
722
723    #[test]
724    fn test_find_first_match_prefers_exact_over_variant() {
725        let mut filter = Filter::new();
726        filter.add_word("赌博");
727
728        // Exact hit wins even though a pinyin variant would also match.
729        assert_eq!(filter.find_first_match("赌博 dubo"), Some(Match { word: "赌博".to_string(), is_variant: false }));
730    }
731
732    #[test]
733    fn test_algorithm_switch_one() {
734        // Use Wu-Manber in small quantities
735        let mut small = Filter::new();
736        small.add_words(&["a", "b", "c"]);
737        assert!(matches!(small.engine.current_algorithm(), MatchAlgorithm::WuManber));
738
739        // Aho-Corasick for medium quantity
740        let words: Vec<_> = (0..150).map(|i| format!("word{i}")).collect();
741        let mut medium = Filter::new();
742        medium.add_words(&words.iter().map(|s| s.as_str()).collect::<Vec<_>>());
743        println!("Medium current_algorithm: {:?}", medium.engine.current_algorithm());
744        assert!(matches!(medium.engine.current_algorithm(), MatchAlgorithm::AhoCorasick));
745    }
746
747    #[test]
748    fn test_io_operations() -> io::Result<()> {
749        let mut filter = Filter::new();
750        let cursor = Cursor::new("word1\nword2\nword3");
751        filter.load(cursor)?;
752
753        assert_eq!(filter.find_in("word2"), (true, "word2".to_string()));
754        Ok(())
755    }
756
757    #[test]
758    fn test_space_folded_word_matches_both_forms() {
759        let mut filter = Filter::new();
760        filter.add_word("A 级");
761
762        assert_eq!(filter.find_in("含有 A 级 内容"), (true, "A 级".to_string()));
763        assert_eq!(filter.find_in("含有 A级 内容"), (true, "A级".to_string()));
764
765        let results = filter.find_all("A 级 和 A级");
766        assert!(results.contains(&"A 级".to_string()));
767        assert!(results.contains(&"A级".to_string()));
768    }
769
770    #[test]
771    fn test_loaded_space_word_adds_folded_match() -> io::Result<()> {
772        let mut filter = Filter::new();
773        filter.load(Cursor::new("A 级\n3 级片"))?;
774
775        assert_eq!(filter.find_in("这里有 A级 内容"), (true, "A级".to_string()));
776        assert_eq!(filter.find_in("这里有 3级片 内容"), (true, "3级片".to_string()));
777        Ok(())
778    }
779
780    #[test]
781    fn test_delete_space_word_removes_both_forms() {
782        let mut filter = Filter::new();
783        filter.add_words(&["A 级", "B 级"]);
784
785        filter.del_word("A 级");
786
787        assert_eq!(filter.find_in("含有 A 级 内容"), (false, String::new()));
788        assert_eq!(filter.find_in("含有 A级 内容"), (false, String::new()));
789        assert_eq!(filter.find_in("含有 B级 内容"), (true, "B级".to_string()));
790    }
791
792    #[test]
793    fn test_algorithm_recommendation() {
794        assert_eq!(MultiPatternEngine::recommend_algorithm(50), MatchAlgorithm::WuManber);
795        assert_eq!(MultiPatternEngine::recommend_algorithm(150), MatchAlgorithm::AhoCorasick);
796        assert_eq!(MultiPatternEngine::recommend_algorithm(15000), MatchAlgorithm::Regex);
797    }
798
799    #[test]
800    fn test_algorithm_switch() {
801        // Use Wu-Manber in small quantities
802        let mut small = Filter::new();
803        small.add_words(&["a", "b", "c"]);
804        println!("Small (3 words): {:?}", small.current_algorithm());
805        assert!(matches!(small.current_algorithm(), MatchAlgorithm::WuManber));
806
807        // Aho-Corasick for medium quantity
808        let words: Vec<_> = (0..150).map(|i| format!("word{i}")).collect();
809        let word_refs: Vec<&str> = words.iter().map(|s| s.as_str()).collect();
810
811        let mut medium = Filter::new();
812        medium.add_words(&word_refs);
813
814        println!("Medium (150 words): {:?}", medium.current_algorithm());
815        println!("Pattern count: {}", medium.engine.get_patterns().len());
816
817        // Verification algorithm selection logic
818        let recommended = MultiPatternEngine::recommend_algorithm(150);
819        println!("Recommended algorithm for 150 words: {recommended:?}");
820
821        assert!(matches!(medium.current_algorithm(), MatchAlgorithm::AhoCorasick));
822    }
823
824    #[test]
825    fn test_cache_invalidation_on_add_word() {
826        let mut filter = Filter::new();
827        filter.add_word("赌博");
828
829        // First search populates cache
830        let results1 = filter.find_all("这里有赌博");
831        assert!(results1.contains(&"赌博".to_string()));
832
833        // Add a new word
834        filter.add_word("色情");
835
836        // Cache should be invalidated — new word must appear
837        let results2 = filter.find_all("这里有赌博和色情");
838        assert!(results2.contains(&"赌博".to_string()));
839        assert!(results2.contains(&"色情".to_string()));
840    }
841
842    #[test]
843    fn test_cache_invalidation_on_del_word() {
844        let mut filter = Filter::new();
845        filter.add_words(&["赌博", "色情"]);
846
847        // First search populates cache
848        let results1 = filter.find_all("这里有赌博和色情");
849        assert!(results1.contains(&"赌博".to_string()));
850        assert!(results1.contains(&"色情".to_string()));
851
852        // Delete a word
853        filter.del_word("赌博");
854
855        // Cache should be invalidated — deleted word must not appear
856        let results2 = filter.find_all("这里有赌博和色情");
857        assert!(!results2.contains(&"赌博".to_string()));
858        assert!(results2.contains(&"色情".to_string()));
859    }
860
861    #[test]
862    fn test_mutex_poison_recovery() {
863        use std::sync::Arc;
864
865        let filter = Arc::new(Filter::new());
866        let filter_clone = Arc::clone(&filter);
867
868        // Poison the mutex by panicking while holding the lock
869        let handle = std::thread::spawn(move || {
870            let _guard = filter_clone.cache.lock().unwrap();
871            panic!("intentional panic to poison mutex");
872        });
873        let _ = handle.join();
874
875        // Filter should still work — recovers from poisoned mutex
876        let results = filter.find_all("test");
877        assert!(results.is_empty());
878    }
879
880    #[test]
881    fn test_parallel_search_cross_boundary() {
882        let mut filter = Filter::new();
883        filter.add_word("赌博");
884
885        // Build text > 1000 bytes so find_all uses parallel path
886        // Place "赌博" at a position that could land on a chunk boundary
887        let prefix: String = "安全文字".repeat(200); // 800 bytes (4 chars × 3 bytes × 200)
888        let text = format!("{prefix}这里有赌博内容");
889
890        let results = filter.find_all(&text);
891        assert!(results.contains(&"赌博".to_string()));
892    }
893
894    #[test]
895    fn test_parallel_search_no_duplicates() {
896        let mut filter = Filter::new();
897        filter.add_word("赌博");
898
899        // Build text > 1000 bytes with the word in the middle
900        let prefix: String = "安全".repeat(300); // 1800 bytes
901        let text = format!("{prefix}赌博{prefix}");
902
903        let results = filter.find_all(&text);
904        let count = results.iter().filter(|w| *w == "赌博").count();
905        assert_eq!(count, 1, "expected exactly 1 match, got {count}");
906    }
907
908    // ---- Task 2: advanced methods (batch / layered / streaming) ----
909
910    #[test]
911    fn test_find_all_batch() {
912        let mut filter = Filter::new();
913        filter.add_words(&["赌博", "色情"]);
914
915        let texts = vec!["含有赌博", "含有色情", "正常内容"];
916        let results = filter.find_all_batch(&texts);
917
918        assert_eq!(results.len(), 3);
919        assert!(results[0].contains(&"赌博".to_string()));
920        assert!(results[1].contains(&"色情".to_string()));
921        assert!(results[2].is_empty());
922    }
923
924    #[test]
925    fn test_find_all_batch_empty() {
926        let filter = Filter::new();
927        let results = filter.find_all_batch(&[]);
928        assert!(results.is_empty());
929    }
930
931    #[test]
932    fn test_find_all_layered_prefers_longest() {
933        let mut filter = Filter::new();
934        filter.add_words(&["赌", "赌博", "赌博机"]);
935
936        // Longest match consumes the span; shorter overlapping words are dropped.
937        let results = filter.find_all_layered("这里有赌博机");
938        assert!(results.contains(&"赌博机".to_string()));
939        assert!(!results.contains(&"赌".to_string()));
940        assert!(!results.contains(&"赌博".to_string()));
941    }
942
943    #[test]
944    fn test_find_all_streaming_multiline() {
945        let mut filter = Filter::new();
946        filter.add_words(&["赌博", "色情"]);
947
948        let input = "第一行含有赌博\n第二行含有色情\n第三行正常";
949        let cursor = std::io::Cursor::new(input);
950        let results = filter.find_all_streaming(cursor).unwrap();
951
952        assert!(results.contains(&"赌博".to_string()));
953        assert!(results.contains(&"色情".to_string()));
954        assert_eq!(results.len(), 2);
955    }
956
957    // ---- Task 3: LRU cache behavior ----
958
959    #[test]
960    fn test_cache_hit_returns_consistent_results() {
961        let mut filter = Filter::new();
962        filter.add_words(&["赌博", "色情"]);
963
964        // First call — cache miss; second call — cache hit.
965        let r1 = filter.find_all("含有赌博和色情内容");
966        let r2 = filter.find_all("含有赌博和色情内容");
967        assert_eq!(r1, r2);
968    }
969
970    #[test]
971    fn test_cache_clear() {
972        let mut filter = Filter::new();
973        filter.add_word("赌博");
974
975        let _ = filter.find_all("含有赌博"); // populate cache
976        filter.clear_cache();
977
978        // After clear, the result is recomputed (still correct).
979        let results = filter.find_all("含有赌博");
980        assert!(results.contains(&"赌博".to_string()));
981    }
982
983    // ---- Task 4: edge cases ----
984
985    #[test]
986    fn test_empty_text() {
987        let mut filter = Filter::new();
988        filter.add_word("赌博");
989
990        assert_eq!(filter.find_in(""), (false, String::new()));
991        assert!(filter.find_all("").is_empty());
992        assert_eq!(filter.replace("", '*'), "");
993        assert_eq!(filter.filter(""), "");
994    }
995
996    #[test]
997    fn test_empty_dictionary() {
998        let filter = Filter::new();
999
1000        assert_eq!(filter.find_in("任何文本"), (false, String::new()));
1001        assert!(filter.find_all("任何文本").is_empty());
1002        assert_eq!(filter.replace("任何文本", '*'), "任何文本");
1003        assert_eq!(filter.filter("任何文本"), "任何文本");
1004    }
1005
1006    #[test]
1007    fn test_unicode_emoji_does_not_interfere() {
1008        let mut filter = Filter::new();
1009        filter.add_word("赌博");
1010
1011        // Emoji are stripped by the noise regex; surrounding CJK still matches.
1012        let (found, word) = filter.find_in("🎉 赌博 🎰");
1013        assert!(found);
1014        assert_eq!(word, "赌博");
1015    }
1016
1017    #[test]
1018    fn test_very_long_text() {
1019        let mut filter = Filter::new();
1020        filter.add_word("赌博");
1021
1022        // 100_000 chars = 300_000 bytes, exercises the >1000-byte parallel path.
1023        let long_text = "正常".repeat(100_000) + "赌博";
1024        let results = filter.find_all(&long_text);
1025        assert!(results.contains(&"赌博".to_string()));
1026    }
1027
1028    #[test]
1029    fn test_cjk_extension_b_chars() {
1030        let mut filter = Filter::new();
1031        filter.add_word("赌博");
1032
1033        // CJK Extension B (outside BMP); preceding CJK still matches.
1034        let text = "含有赌博内容 𠀀𠀁";
1035        let (found, _) = filter.find_in(text);
1036        assert!(found);
1037    }
1038}