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::MatchInfo, 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        self.add_words(&[word]);
156    }
157
158    /// Add multiple words
159    ///
160    /// # Examples
161    ///
162    /// ```
163    /// use sensitive_rs::Filter;
164    ///
165    /// let mut filter = Filter::new();
166    /// filter.add_words(&["赌博", "色情"]);
167    /// assert!(filter.find_all("含有赌博和色情").contains(&"赌博".to_string()));
168    /// ```
169    pub fn add_words(&mut self, words: &[&str]) {
170        let mut patterns = self.engine.get_patterns().to_vec();
171        Self::extend_patterns_with_word_variants(&mut patterns, words);
172
173        self.engine.rebuild(&patterns);
174        for word in words {
175            for variant in Self::word_match_variants(word) {
176                self.variant_detector.add_word(&variant);
177            }
178        }
179        self.clear_cache();
180    }
181
182    /// Get the currently used algorithm
183    #[must_use]
184    pub fn current_algorithm(&self) -> MatchAlgorithm {
185        self.engine.current_algorithm()
186    }
187
188    /// Remove a word
189    pub fn del_word(&mut self, word: &str) {
190        self.del_words(&[word]);
191    }
192
193    /// Remove multiple words
194    pub fn del_words(&mut self, words: &[&str]) {
195        let word_set: HashSet<String> = words.iter().flat_map(|word| Self::word_match_variants(word)).collect();
196        let patterns: Vec<_> = self.engine.get_patterns().iter().filter(|w| !word_set.contains(*w)).cloned().collect();
197
198        self.engine.rebuild(&patterns);
199        self.clear_cache();
200    }
201
202    /// Load dictionary from file
203    pub fn load_word_dict<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
204        let file = File::open(path)?;
205        self.load(BufReader::new(file))
206    }
207
208    /// Load dictionary from reader
209    ///
210    /// Each line of the reader is one dictionary word.
211    ///
212    /// # Examples
213    ///
214    /// ```
215    /// use sensitive_rs::Filter;
216    /// use std::io::Cursor;
217    ///
218    /// let mut filter = Filter::new();
219    /// filter.load(Cursor::new("赌博\n色情"))?;
220    /// assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
221    /// # Ok::<(), std::io::Error>(())
222    /// ```
223    pub fn load<R: BufRead>(&mut self, reader: R) -> io::Result<()> {
224        let words: Vec<_> = reader.lines().collect::<Result<_, _>>()?;
225        self.add_words(&words.iter().map(|s| s.as_str()).collect::<Vec<_>>());
226        Ok(())
227    }
228
229    /// Load dictionary from URL
230    #[cfg(feature = "net")]
231    pub fn load_net_word_dict(&mut self, url: &str) -> io::Result<()> {
232        let response = self.http_client.get(url).send().map_err(io::Error::other)?;
233
234        if !response.status().is_success() {
235            return Err(io::Error::other(format!("HTTP request failed: {}", response.status())));
236        }
237
238        let reader = BufReader::new(response);
239        self.load(reader)
240    }
241
242    /// Find the first sensitive word, returning a [`Match`] with details, or `None`.
243    ///
244    /// Exact matches are preferred; pinyin/shape variants are only consulted when no
245    /// exact hit is found. `is_variant` on the returned [`Match`] records which path hit.
246    ///
247    /// # Examples
248    ///
249    /// ```
250    /// use sensitive_rs::{Filter, Match};
251    ///
252    /// let mut filter = Filter::new();
253    /// filter.add_word("赌博");
254    ///
255    /// // Exact hit:
256    /// assert_eq!(
257    ///     filter.find_first_match("含有赌博"),
258    ///     Some(Match { word: "赌博".to_string(), is_variant: false })
259    /// );
260    /// // Pinyin variant (no exact hit):
261    /// assert_eq!(
262    ///     filter.find_first_match("dubo"),
263    ///     Some(Match { word: "赌博".to_string(), is_variant: true })
264    /// );
265    /// // No match:
266    /// assert_eq!(filter.find_first_match("clean text"), None);
267    /// ```
268    #[must_use]
269    pub fn find_first_match(&self, text: &str) -> Option<Match> {
270        let clean_text = self.remove_noise(text);
271
272        // 1. Try exact match first
273        if let Some(word) = self.engine.find_first(&clean_text) {
274            return Some(Match { word, is_variant: false });
275        }
276
277        // 2. Try variant detection
278        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
279
280        if let Some(word) = self.variant_detector.detect(&clean_text, &patterns).first() {
281            return Some(Match { word: word.to_string(), is_variant: true });
282        }
283
284        None
285    }
286
287    /// Find first sensitive word.
288    ///
289    /// Returns `(found, word)`; `word` is empty when nothing matched. For richer detail
290    /// (including whether the hit was a variant), use [`Filter::find_first_match`].
291    ///
292    /// # Examples
293    ///
294    /// ```
295    /// use sensitive_rs::Filter;
296    ///
297    /// let mut filter = Filter::new();
298    /// filter.add_word("赌博");
299    /// assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
300    /// assert_eq!(filter.find_in("clean text"), (false, String::new()));
301    /// ```
302    #[must_use]
303    pub fn find_in(&self, text: &str) -> (bool, String) {
304        match self.find_first_match(text) {
305            Some(m) => (true, m.word),
306            None => (false, String::new()),
307        }
308    }
309
310    /// Replace sensitive words with replacement character.
311    ///
312    /// Each matched character is replaced by one `replacement` char. Only exact
313    /// dictionary matches are masked; variant forms are not (use [`Filter::find_all`]
314    /// to detect them).
315    ///
316    /// # Examples
317    ///
318    /// ```
319    /// use sensitive_rs::Filter;
320    ///
321    /// let mut filter = Filter::new();
322    /// filter.add_word("赌博");
323    /// assert_eq!(filter.replace("含有赌博内容", '*'), "含有**内容");
324    /// ```
325    #[must_use]
326    pub fn replace(&self, text: &str, replacement: char) -> String {
327        let clean_text = self.remove_noise(text);
328        let repl = replacement.to_string();
329
330        // Single pass over leftmost-longest non-overlapping matches: one replacement
331        // char per matched character. Exact matches only — see the doc note above.
332        let matches = self.leftmost_longest_matches(&clean_text);
333        let mut result = String::with_capacity(clean_text.len());
334        let mut cursor = 0usize;
335        for m in &matches {
336            result.push_str(&clean_text[cursor..m.start]);
337            result.push_str(&repl.repeat(clean_text[m.start..m.end].chars().count()));
338            cursor = m.end;
339        }
340        result.push_str(&clean_text[cursor..]);
341        result
342    }
343
344    /// Filter out sensitive words (remove them completely).
345    ///
346    /// Only exact dictionary matches are removed; variant forms are not (see
347    /// [`Filter::replace`]). Use [`Filter::find_all`] to detect variants.
348    ///
349    /// # Examples
350    ///
351    /// ```
352    /// use sensitive_rs::Filter;
353    ///
354    /// let mut filter = Filter::new();
355    /// filter.add_word("赌博");
356    /// assert_eq!(filter.filter("含有赌博内容"), "含有内容");
357    /// ```
358    #[must_use]
359    pub fn filter(&self, text: &str) -> String {
360        let clean_text = self.remove_noise(text);
361        self.engine.replace_all(&clean_text, "")
362    }
363
364    /// Validate text
365    ///
366    /// This is an alias for [`Filter::find_in`]: it returns `(found, word)` for the
367    /// first sensitive word encountered.
368    ///
369    /// # Examples
370    ///
371    /// ```
372    /// use sensitive_rs::Filter;
373    ///
374    /// let mut filter = Filter::new();
375    /// filter.add_word("赌博");
376    /// assert_eq!(filter.validate("含有赌博"), (true, "赌博".to_string()));
377    /// assert_eq!(filter.validate("clean text"), (false, String::new()));
378    /// ```
379    #[must_use]
380    pub fn validate(&self, text: &str) -> (bool, String) {
381        self.find_in(text)
382    }
383
384    /// Remove only specific noise characters, preserve spaces
385    #[must_use]
386    pub fn remove_noise(&self, text: &str) -> String {
387        self.noise.replace_all(text, "").to_string()
388    }
389
390    /// Get current noise pattern
391    #[must_use]
392    pub fn get_noise_pattern(&self) -> &Regex {
393        &self.noise
394    }
395
396    /// Greedy leftmost-longest non-overlapping exact matches (byte spans + pattern).
397    ///
398    /// Sorts by start ascending then end descending (longest first at each start) and
399    /// keeps a match only when it begins at or after the previous kept match's end.
400    /// Shared by [`Filter::replace`] and [`Filter::find_all_layered`].
401    fn leftmost_longest_matches(&self, clean_text: &str) -> Vec<MatchInfo> {
402        let mut matches = self.engine.find_matches_with_positions(clean_text);
403        matches.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)));
404        let mut kept = Vec::with_capacity(matches.len());
405        let mut cursor = 0usize;
406        for m in matches {
407            if m.start >= cursor {
408                cursor = m.end;
409                kept.push(m);
410            }
411        }
412        kept
413    }
414
415    /// Optimized method of finding all sensitive words.
416    ///
417    /// Returns the de-duplicated, sorted list of matched dictionary words (variants
418    /// included). Results are cached, so repeated calls on the same text are cheap.
419    ///
420    /// # Examples
421    ///
422    /// ```
423    /// use sensitive_rs::Filter;
424    ///
425    /// let mut filter = Filter::new();
426    /// filter.add_words(&["赌博", "色情"]);
427    /// // Results are sorted: 色 (U+8272) sorts before 赌 (U+8D4C).
428    /// assert_eq!(filter.find_all("含有赌博和色情内容"), vec!["色情".to_string(), "赌博".to_string()]);
429    /// ```
430    #[must_use]
431    pub fn find_all(&self, text: &str) -> Vec<String> {
432        let clean_text = self.remove_noise(text);
433
434        // 1. Caching mechanism - Check whether the results have been cached
435        if let Some(cached_result) = self.check_cache(&clean_text) {
436            return cached_result;
437        }
438
439        #[cfg(feature = "parallel")]
440        let results = if clean_text.len() > 1000 {
441            self.find_all_parallel(&clean_text) // long text -> parallel
442        } else {
443            self.find_all_sequential(&clean_text) // short text -> sequential
444        };
445        #[cfg(not(feature = "parallel"))]
446        let results = self.find_all_sequential(&clean_text);
447
448        // 3. Cache results
449        self.cache_result(&clean_text, &results);
450
451        results
452    }
453
454    /// Parallel processing version — for long text.
455    ///
456    /// Exact scan and variant detection are independent, so they run concurrently via
457    /// [`rayon::join`]. Variant detection runs once over the full text (the previous
458    /// whitespace-split parallelization dropped cross-segment variants).
459    #[cfg(feature = "parallel")]
460    fn find_all_parallel(&self, text: &str) -> Vec<String> {
461        let patterns: Vec<&str> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
462
463        let (engine_results, variant_results) = rayon::join(
464            || self.engine.find_all(text),
465            || self.variant_detector.detect(text, &patterns).into_iter().map(String::from).collect::<Vec<_>>(),
466        );
467
468        let mut results = engine_results;
469        results.extend(variant_results);
470        self.deduplicate_and_sort(results)
471    }
472
473    /// Sequential processing version - suitable for short text
474    fn find_all_sequential(&self, text: &str) -> Vec<String> {
475        let mut results = self.engine.find_all(text);
476        let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
477
478        // Add variant detection results
479        results.extend(self.variant_detector.detect(text, &patterns).into_iter().map(|s| s.to_string()));
480
481        self.deduplicate_and_sort(results)
482    }
483
484    /// Deduplication and sort
485    fn deduplicate_and_sort(&self, mut results: Vec<String>) -> Vec<String> {
486        results.sort_unstable();
487        results.dedup();
488        results
489    }
490
491    /// Bulk search for optimized versions
492    ///
493    /// Runs [`Filter::find_all`] over each text. With the `parallel` feature (default) the
494    /// texts are processed concurrently.
495    ///
496    /// # Examples
497    ///
498    /// ```
499    /// use sensitive_rs::Filter;
500    ///
501    /// let mut filter = Filter::new();
502    /// filter.add_words(&["赌博", "色情"]);
503    /// let results = filter.find_all_batch(&["含有赌博", "正常", "含有色情"]);
504    /// assert!(results[0].contains(&"赌博".to_string()));
505    /// assert!(results[1].is_empty());
506    /// assert!(results[2].contains(&"色情".to_string()));
507    /// ```
508    #[must_use]
509    pub fn find_all_batch(&self, texts: &[&str]) -> Vec<Vec<String>> {
510        #[cfg(feature = "parallel")]
511        {
512            texts.par_iter().map(|text| self.find_all(text)).collect()
513        }
514        #[cfg(not(feature = "parallel"))]
515        {
516            texts.iter().map(|text| self.find_all(text)).collect()
517        }
518    }
519
520    /// Hierarchical Matching - Preferential Matching by Sensitive Word Length
521    ///
522    /// Matches the longest words first and consumes their span, so shorter overlapping
523    /// entries are dropped. Useful when the dictionary contains both short and long forms.
524    ///
525    /// # Examples
526    ///
527    /// ```
528    /// use sensitive_rs::Filter;
529    ///
530    /// let mut filter = Filter::new();
531    /// filter.add_words(&["赌", "赌博", "赌博机"]);
532    /// let results = filter.find_all_layered("这里有赌博机");
533    /// assert!(results.contains(&"赌博机".to_string()));
534    /// assert!(!results.contains(&"赌博".to_string()));
535    /// ```
536    #[must_use]
537    pub fn find_all_layered(&self, text: &str) -> Vec<String> {
538        let clean_text = self.remove_noise(text);
539        let matches = self.leftmost_longest_matches(&clean_text);
540
541        // The longest exact matches...
542        let mut results: Vec<String> = matches.iter().map(|m| m.pattern.clone()).collect();
543
544        // ...then blank those spans before variant detection, so a shorter word's
545        // pinyin/shape isn't re-discovered inside a longer exact match.
546        let mut remaining = String::with_capacity(clean_text.len());
547        let mut cursor = 0usize;
548        for m in &matches {
549            remaining.push_str(&clean_text[cursor..m.start]);
550            remaining.push(' ');
551            cursor = m.end;
552        }
553        remaining.push_str(&clean_text[cursor..]);
554
555        let patterns: Vec<&str> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
556        results.extend(self.variant_detector.detect(&remaining, &patterns).into_iter().map(String::from));
557
558        self.deduplicate_and_sort(results)
559    }
560
561    /// Streaming version - suitable for oversized text
562    ///
563    /// Reads line-by-line from any [`BufRead`] and returns the de-duplicated matches
564    /// across all lines. Handy for files too large to hold in memory.
565    ///
566    /// # Examples
567    ///
568    /// ```
569    /// use sensitive_rs::Filter;
570    /// use std::io::Cursor;
571    ///
572    /// let mut filter = Filter::new();
573    /// filter.add_words(&["赌博", "色情"]);
574    /// let input = "第一行含有赌博\n第二行含有色情\n第三行正常";
575    /// let results = filter.find_all_streaming(Cursor::new(input))?;
576    /// assert_eq!(results.len(), 2);
577    /// # Ok::<(), std::io::Error>(())
578    /// ```
579    pub fn find_all_streaming<R: BufRead>(&self, reader: R) -> io::Result<Vec<String>> {
580        let mut all_results = Vec::new();
581
582        for line in reader.lines() {
583            let line = line?;
584            let results = self.find_all(&line);
585            all_results.extend(results);
586        }
587
588        Ok(self.deduplicate_and_sort(all_results))
589    }
590}
591
592impl Default for Filter {
593    fn default() -> Self {
594        Self::new()
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use std::io::Cursor;
602    #[test]
603    fn test_integration() {
604        let mut filter = Filter::new();
605        filter.add_words(&["赌博", "色情"]);
606
607        // Exact match
608        assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
609
610        // Pinyin variant
611        assert_eq!(filter.find_in("含有 dubo"), (true, "赌博".to_string()));
612
613        // Replacement
614        assert_eq!(filter.replace("赌博 色情", '*'), "** **");
615
616        // Filter
617        assert_eq!(filter.filter("赌博内容"), "内容");
618    }
619
620    #[test]
621    fn test_noise_handling() {
622        let mut filter = Filter::new();
623        filter.add_word("赌博");
624
625        // 测试空格保留
626        assert_eq!(filter.remove_noise("赌 博"), "赌 博");
627
628        // 测试特殊符号移除
629        assert_eq!(filter.remove_noise("赌@#$博"), "赌博");
630    }
631
632    #[test]
633    fn test_replace_vs_filter() {
634        let mut filter = Filter::new();
635        filter.add_words(&["赌博", "色情"]);
636
637        let text = "这里有赌博和色情内容";
638
639        // replace should be replaced with characters
640        assert_eq!(filter.replace(text, '*'), "这里有**和**内容");
641
642        // filter should be completely removed
643        assert_eq!(filter.filter(text), "这里有和内容");
644    }
645
646    #[test]
647    fn test_replace_single_pass_rebuild() {
648        // R3: exact matches rebuilt in a single pass, one '*' per matched char,
649        // regardless of how many patterns hit.
650        let mut filter = Filter::new();
651        filter.add_words(&["赌博", "色情"]);
652        assert_eq!(filter.replace("前缀赌博中间色情后缀", '*'), "前缀**中间**后缀");
653    }
654
655    #[test]
656    fn test_variant_detection() {
657        let mut filter = Filter::new();
658        filter.add_word("测试");
659
660        assert_eq!(filter.find_in("ceshi"), (true, "测试".to_string()));
661    }
662
663    #[test]
664    fn test_find_first_match_exact() {
665        let mut filter = Filter::new();
666        filter.add_words(&["赌博", "色情"]);
667
668        assert_eq!(filter.find_first_match("含有赌博"), Some(Match { word: "赌博".to_string(), is_variant: false }));
669        assert_eq!(filter.find_first_match("正常文本"), None);
670    }
671
672    #[test]
673    fn test_find_first_match_variant() {
674        let mut filter = Filter::new();
675        filter.add_word("赌博");
676
677        // Pinyin variant path: word found, but is_variant = true.
678        assert_eq!(filter.find_first_match("含有 dubo"), Some(Match { word: "赌博".to_string(), is_variant: true }));
679    }
680
681    #[test]
682    fn test_find_first_match_prefers_exact_over_variant() {
683        let mut filter = Filter::new();
684        filter.add_word("赌博");
685
686        // Exact hit wins even though a pinyin variant would also match.
687        assert_eq!(filter.find_first_match("赌博 dubo"), Some(Match { word: "赌博".to_string(), is_variant: false }));
688    }
689
690    #[test]
691    fn test_algorithm_switch_one() {
692        // Use Wu-Manber in small quantities
693        let mut small = Filter::new();
694        small.add_words(&["a", "b", "c"]);
695        assert!(matches!(small.engine.current_algorithm(), MatchAlgorithm::WuManber));
696
697        // Aho-Corasick for medium quantity
698        let words: Vec<_> = (0..150).map(|i| format!("word{i}")).collect();
699        let mut medium = Filter::new();
700        medium.add_words(&words.iter().map(|s| s.as_str()).collect::<Vec<_>>());
701        println!("Medium current_algorithm: {:?}", medium.engine.current_algorithm());
702        assert!(matches!(medium.engine.current_algorithm(), MatchAlgorithm::AhoCorasick));
703    }
704
705    #[test]
706    fn test_io_operations() -> io::Result<()> {
707        let mut filter = Filter::new();
708        let cursor = Cursor::new("word1\nword2\nword3");
709        filter.load(cursor)?;
710
711        assert_eq!(filter.find_in("word2"), (true, "word2".to_string()));
712        Ok(())
713    }
714
715    #[test]
716    fn test_space_folded_word_matches_both_forms() {
717        let mut filter = Filter::new();
718        filter.add_word("A 级");
719
720        assert_eq!(filter.find_in("含有 A 级 内容"), (true, "A 级".to_string()));
721        assert_eq!(filter.find_in("含有 A级 内容"), (true, "A级".to_string()));
722
723        let results = filter.find_all("A 级 和 A级");
724        assert!(results.contains(&"A 级".to_string()));
725        assert!(results.contains(&"A级".to_string()));
726    }
727
728    #[test]
729    fn test_loaded_space_word_adds_folded_match() -> io::Result<()> {
730        let mut filter = Filter::new();
731        filter.load(Cursor::new("A 级\n3 级片"))?;
732
733        assert_eq!(filter.find_in("这里有 A级 内容"), (true, "A级".to_string()));
734        assert_eq!(filter.find_in("这里有 3级片 内容"), (true, "3级片".to_string()));
735        Ok(())
736    }
737
738    #[test]
739    fn test_delete_space_word_removes_both_forms() {
740        let mut filter = Filter::new();
741        filter.add_words(&["A 级", "B 级"]);
742
743        filter.del_word("A 级");
744
745        assert_eq!(filter.find_in("含有 A 级 内容"), (false, String::new()));
746        assert_eq!(filter.find_in("含有 A级 内容"), (false, String::new()));
747        assert_eq!(filter.find_in("含有 B级 内容"), (true, "B级".to_string()));
748    }
749
750    #[test]
751    fn test_algorithm_recommendation() {
752        assert_eq!(MultiPatternEngine::recommend_algorithm(50), MatchAlgorithm::WuManber);
753        assert_eq!(MultiPatternEngine::recommend_algorithm(150), MatchAlgorithm::AhoCorasick);
754        assert_eq!(MultiPatternEngine::recommend_algorithm(15000), MatchAlgorithm::Regex);
755    }
756
757    #[test]
758    fn test_algorithm_switch() {
759        // Use Wu-Manber in small quantities
760        let mut small = Filter::new();
761        small.add_words(&["a", "b", "c"]);
762        println!("Small (3 words): {:?}", small.current_algorithm());
763        assert!(matches!(small.current_algorithm(), MatchAlgorithm::WuManber));
764
765        // Aho-Corasick for medium quantity
766        let words: Vec<_> = (0..150).map(|i| format!("word{i}")).collect();
767        let word_refs: Vec<&str> = words.iter().map(|s| s.as_str()).collect();
768
769        let mut medium = Filter::new();
770        medium.add_words(&word_refs);
771
772        println!("Medium (150 words): {:?}", medium.current_algorithm());
773        println!("Pattern count: {}", medium.engine.get_patterns().len());
774
775        // Verification algorithm selection logic
776        let recommended = MultiPatternEngine::recommend_algorithm(150);
777        println!("Recommended algorithm for 150 words: {recommended:?}");
778
779        assert!(matches!(medium.current_algorithm(), MatchAlgorithm::AhoCorasick));
780    }
781
782    #[test]
783    fn test_cache_invalidation_on_add_word() {
784        let mut filter = Filter::new();
785        filter.add_word("赌博");
786
787        // First search populates cache
788        let results1 = filter.find_all("这里有赌博");
789        assert!(results1.contains(&"赌博".to_string()));
790
791        // Add a new word
792        filter.add_word("色情");
793
794        // Cache should be invalidated — new word must appear
795        let results2 = filter.find_all("这里有赌博和色情");
796        assert!(results2.contains(&"赌博".to_string()));
797        assert!(results2.contains(&"色情".to_string()));
798    }
799
800    #[test]
801    fn test_cache_invalidation_on_del_word() {
802        let mut filter = Filter::new();
803        filter.add_words(&["赌博", "色情"]);
804
805        // First search populates cache
806        let results1 = filter.find_all("这里有赌博和色情");
807        assert!(results1.contains(&"赌博".to_string()));
808        assert!(results1.contains(&"色情".to_string()));
809
810        // Delete a word
811        filter.del_word("赌博");
812
813        // Cache should be invalidated — deleted word must not appear
814        let results2 = filter.find_all("这里有赌博和色情");
815        assert!(!results2.contains(&"赌博".to_string()));
816        assert!(results2.contains(&"色情".to_string()));
817    }
818
819    #[test]
820    fn test_mutex_poison_recovery() {
821        use std::sync::Arc;
822
823        let filter = Arc::new(Filter::new());
824        let filter_clone = Arc::clone(&filter);
825
826        // Poison the mutex by panicking while holding the lock
827        let handle = std::thread::spawn(move || {
828            let _guard = filter_clone.cache.lock().unwrap();
829            panic!("intentional panic to poison mutex");
830        });
831        let _ = handle.join();
832
833        // Filter should still work — recovers from poisoned mutex
834        let results = filter.find_all("test");
835        assert!(results.is_empty());
836    }
837
838    #[test]
839    fn test_parallel_search_cross_boundary() {
840        let mut filter = Filter::new();
841        filter.add_word("赌博");
842
843        // Build text > 1000 bytes so find_all uses parallel path
844        // Place "赌博" at a position that could land on a chunk boundary
845        let prefix: String = "安全文字".repeat(200); // 800 bytes (4 chars × 3 bytes × 200)
846        let text = format!("{prefix}这里有赌博内容");
847
848        let results = filter.find_all(&text);
849        assert!(results.contains(&"赌博".to_string()));
850    }
851
852    #[test]
853    fn test_parallel_search_no_duplicates() {
854        let mut filter = Filter::new();
855        filter.add_word("赌博");
856
857        // Build text > 1000 bytes with the word in the middle
858        let prefix: String = "安全".repeat(300); // 1800 bytes
859        let text = format!("{prefix}赌博{prefix}");
860
861        let results = filter.find_all(&text);
862        let count = results.iter().filter(|w| *w == "赌博").count();
863        assert_eq!(count, 1, "expected exactly 1 match, got {count}");
864    }
865
866    // ---- Task 2: advanced methods (batch / layered / streaming) ----
867
868    #[test]
869    fn test_find_all_batch() {
870        let mut filter = Filter::new();
871        filter.add_words(&["赌博", "色情"]);
872
873        let texts = vec!["含有赌博", "含有色情", "正常内容"];
874        let results = filter.find_all_batch(&texts);
875
876        assert_eq!(results.len(), 3);
877        assert!(results[0].contains(&"赌博".to_string()));
878        assert!(results[1].contains(&"色情".to_string()));
879        assert!(results[2].is_empty());
880    }
881
882    #[test]
883    fn test_find_all_batch_empty() {
884        let filter = Filter::new();
885        let results = filter.find_all_batch(&[]);
886        assert!(results.is_empty());
887    }
888
889    #[test]
890    fn test_find_all_layered_prefers_longest() {
891        let mut filter = Filter::new();
892        filter.add_words(&["赌", "赌博", "赌博机"]);
893
894        // Longest match consumes the span; shorter overlapping words are dropped.
895        let results = filter.find_all_layered("这里有赌博机");
896        assert!(results.contains(&"赌博机".to_string()));
897        assert!(!results.contains(&"赌".to_string()));
898        assert!(!results.contains(&"赌博".to_string()));
899    }
900
901    #[test]
902    fn test_find_all_layered_multi_occurrence() {
903        // Two non-overlapping longest matches are both kept; shorter overlapping
904        // forms inside each span are dropped.
905        let mut filter = Filter::new();
906        filter.add_words(&["赌", "赌博", "赌博机"]);
907
908        let results = filter.find_all_layered("赌博和赌博机");
909        assert!(results.contains(&"赌博".to_string()));
910        assert!(results.contains(&"赌博机".to_string()));
911        assert!(!results.contains(&"赌".to_string()));
912    }
913
914    #[test]
915    fn test_find_all_layered_blanks_exact_before_variant() {
916        // Regression: a short word whose pinyin is a substring of a longer exact
917        // match must NOT be re-added by variant detection — exact spans are blanked.
918        let mut filter = Filter::new();
919        filter.add_words(&["赌", "赌博", "赌博机"]);
920
921        let results = filter.find_all_layered("这里有赌博机");
922        // "赌"/"赌博" pinyin (du/dubo) sits inside "赌博机" pinyin (duboji), but the
923        // exact span is blanked first, so only the longest match survives.
924        assert_eq!(results, vec!["赌博机".to_string()]);
925    }
926
927    #[test]
928    fn test_replace_and_filter_are_exact_only() {
929        // Pins behavior: replace/filter mask exact dictionary matches only. A
930        // pinyin variant present in the text passes through unchanged (the variant
931        // detector reports word names, not the variant text spans to mask).
932        let mut filter = Filter::new();
933        filter.add_word("赌博");
934
935        // Exact match is masked / removed...
936        assert_eq!(filter.replace("含有赌博", '*'), "含有**");
937        assert_eq!(filter.filter("含有赌博"), "含有");
938        // ...but the pinyin variant "dubo" is left untouched.
939        assert_eq!(filter.replace("dubo", '*'), "dubo");
940        assert_eq!(filter.filter("dubo"), "dubo");
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}