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, 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
31/// Advanced sensitive word filter with variant detection
32pub struct Filter {
33    engine: MultiPatternEngine, // Multi-pattern matching engine
34    #[cfg(feature = "std")]
35    variant_detector: VariantDetector, // Variation detector (pinyin/shape)
36    noise: Regex,               // Noise processing rules
37    #[cfg(feature = "std")]
38    cache: Arc<Mutex<LruCache<String, Vec<String>>>>,
39}
40
41/// A sensitive-word match found by [`Filter::find_first_match`].
42///
43/// `word` is the matched word in its dictionary form; `is_variant` is `true` when
44/// the match came from pinyin/shape variant detection rather than an exact hit.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Match {
47    /// The matched sensitive word, in dictionary form.
48    pub word: String,
49    /// `true` if matched via a pinyin/shape variant rather than an exact hit.
50    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    /// Create a new filter with default settings.
61    ///
62    /// # Examples
63    ///
64    /// ```
65    /// use sensitive_rs::Filter;
66    ///
67    /// let mut filter = Filter::new();
68    /// filter.add_word("赌博");
69    /// assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
70    /// ```
71    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()))), // Cache 1000 results
79        }
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    /// Clear the cache
115    pub fn clear_cache(&self) {
116        #[cfg(feature = "std")]
117        self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
118    }
119
120    /// Create with specific algorithm
121    pub fn with_algorithm(algorithm: MatchAlgorithm) -> Self {
122        Self { engine: MultiPatternEngine::new(Some(algorithm), &[]), ..Self::new() }
123    }
124
125    /// Load default dictionary
126    #[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    /// Update noise pattern
134    ///
135    /// Sets the regex used to strip noise characters before matching. Characters
136    /// **not** matched by the regex are kept. Returns an error if `pattern` is invalid.
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// use sensitive_rs::Filter;
142    ///
143    /// let mut filter = Filter::new();
144    /// filter.add_word("赌博");
145    /// // Strip everything except CJK and ASCII word characters.
146    /// filter.update_noise_pattern(r"[^\w一-鿿]")?;
147    /// assert_eq!(filter.remove_noise("赌@#博"), "赌博");
148    /// # Ok::<(), regex::Error>(())
149    /// ```
150    pub fn update_noise_pattern(&mut self, pattern: &str) -> Result<(), regex::Error> {
151        self.noise = Regex::new(pattern)?;
152        Ok(())
153    }
154
155    /// Add a sensitive word
156    pub fn add_word(&mut self, word: &str) {
157        self.add_words(&[word]);
158    }
159
160    /// Add multiple words
161    ///
162    /// # Examples
163    ///
164    /// ```
165    /// use sensitive_rs::Filter;
166    ///
167    /// let mut filter = Filter::new();
168    /// filter.add_words(&["赌博", "色情"]);
169    /// assert!(filter.find_all("含有赌博和色情").contains(&"赌博".to_string()));
170    /// ```
171    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    /// Get the currently used algorithm
186    #[must_use]
187    pub fn current_algorithm(&self) -> MatchAlgorithm {
188        self.engine.current_algorithm()
189    }
190
191    /// Remove a word
192    pub fn del_word(&mut self, word: &str) {
193        self.del_words(&[word]);
194    }
195
196    /// Remove multiple words
197    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    /// Load dictionary from file
206    #[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    /// Load dictionary from reader
213    ///
214    /// Each line of the reader is one dictionary word.
215    ///
216    /// # Examples
217    ///
218    /// ```
219    /// use sensitive_rs::Filter;
220    /// use std::io::Cursor;
221    ///
222    /// let mut filter = Filter::new();
223    /// filter.load(Cursor::new("赌博\n色情"))?;
224    /// assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
225    /// # Ok::<(), std::io::Error>(())
226    /// ```
227    #[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    /// Load dictionary from URL
235    #[cfg(feature = "net")]
236    pub fn load_net_word_dict(&mut self, url: &str) -> io::Result<()> {
237        // Build a client per call (lazy) rather than storing one, so a `Filter` can
238        // be created/dropped inside an async runtime without panicking.
239        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    /// Find the first sensitive word, returning a [`Match`] with details, or `None`.
254    ///
255    /// Exact matches are preferred; pinyin/shape variants are only consulted when no
256    /// exact hit is found. `is_variant` on the returned [`Match`] records which path hit.
257    ///
258    /// # Examples
259    ///
260    /// ```
261    /// use sensitive_rs::{Filter, Match};
262    ///
263    /// let mut filter = Filter::new();
264    /// filter.add_word("赌博");
265    ///
266    /// // Exact hit:
267    /// assert_eq!(
268    ///     filter.find_first_match("含有赌博"),
269    ///     Some(Match { word: "赌博".to_string(), is_variant: false })
270    /// );
271    /// // Pinyin variant (no exact hit):
272    /// assert_eq!(
273    ///     filter.find_first_match("dubo"),
274    ///     Some(Match { word: "赌博".to_string(), is_variant: true })
275    /// );
276    /// // No match:
277    /// assert_eq!(filter.find_first_match("clean text"), None);
278    /// ```
279    #[must_use]
280    pub fn find_first_match(&self, text: &str) -> Option<Match> {
281        let clean_text = self.remove_noise(text);
282
283        // 1. Try exact match first
284        if let Some(word) = self.engine.find_first(&clean_text) {
285            return Some(Match { word, is_variant: false });
286        }
287
288        // 2. Try variant detection (requires `std`: pinyin/shape detection)
289        #[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    /// Find first sensitive word.
301    ///
302    /// Returns `(found, word)`; `word` is empty when nothing matched. For richer detail
303    /// (including whether the hit was a variant), use [`Filter::find_first_match`].
304    ///
305    /// # Examples
306    ///
307    /// ```
308    /// use sensitive_rs::Filter;
309    ///
310    /// let mut filter = Filter::new();
311    /// filter.add_word("赌博");
312    /// assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
313    /// assert_eq!(filter.find_in("clean text"), (false, String::new()));
314    /// ```
315    #[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    /// Replace sensitive words with replacement character.
324    ///
325    /// Each matched character is replaced by one `replacement` char. Only exact
326    /// dictionary matches are masked; variant forms are not (use [`Filter::find_all`]
327    /// to detect them).
328    ///
329    /// # Examples
330    ///
331    /// ```
332    /// use sensitive_rs::Filter;
333    ///
334    /// let mut filter = Filter::new();
335    /// filter.add_word("赌博");
336    /// assert_eq!(filter.replace("含有赌博内容", '*'), "含有**内容");
337    /// ```
338    #[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        // Single pass over leftmost-longest non-overlapping matches: one replacement
344        // char per matched character. Exact matches only — see the doc note above.
345        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    /// Filter out sensitive words (remove them completely).
358    ///
359    /// Only exact dictionary matches are removed; variant forms are not (see
360    /// [`Filter::replace`]). Use [`Filter::find_all`] to detect variants.
361    ///
362    /// # Examples
363    ///
364    /// ```
365    /// use sensitive_rs::Filter;
366    ///
367    /// let mut filter = Filter::new();
368    /// filter.add_word("赌博");
369    /// assert_eq!(filter.filter("含有赌博内容"), "含有内容");
370    /// ```
371    #[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    /// Validate text
378    ///
379    /// This is an alias for [`Filter::find_in`]: it returns `(found, word)` for the
380    /// first sensitive word encountered.
381    ///
382    /// # Examples
383    ///
384    /// ```
385    /// use sensitive_rs::Filter;
386    ///
387    /// let mut filter = Filter::new();
388    /// filter.add_word("赌博");
389    /// assert_eq!(filter.validate("含有赌博"), (true, "赌博".to_string()));
390    /// assert_eq!(filter.validate("clean text"), (false, String::new()));
391    /// ```
392    #[must_use]
393    pub fn validate(&self, text: &str) -> (bool, String) {
394        self.find_in(text)
395    }
396
397    /// Remove only specific noise characters, preserve spaces
398    #[must_use]
399    pub fn remove_noise(&self, text: &str) -> String {
400        self.noise.replace_all(text, "").to_string()
401    }
402
403    /// Get current noise pattern
404    #[must_use]
405    pub fn get_noise_pattern(&self) -> &Regex {
406        &self.noise
407    }
408
409    /// Greedy leftmost-longest non-overlapping exact matches (byte spans + pattern).
410    ///
411    /// Sorts by start ascending then end descending (longest first at each start) and
412    /// keeps a match only when it begins at or after the previous kept match's end.
413    /// Shared by [`Filter::replace`] and [`Filter::find_all_layered`].
414    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    /// Optimized method of finding all sensitive words.
429    ///
430    /// Returns the de-duplicated, sorted list of matched dictionary words (variants
431    /// included). Results are cached, so repeated calls on the same text are cheap.
432    ///
433    /// # Examples
434    ///
435    /// ```
436    /// use sensitive_rs::Filter;
437    ///
438    /// let mut filter = Filter::new();
439    /// filter.add_words(&["赌博", "色情"]);
440    /// // Results are sorted: 色 (U+8272) sorts before 赌 (U+8D4C).
441    /// assert_eq!(filter.find_all("含有赌博和色情内容"), vec!["色情".to_string(), "赌博".to_string()]);
442    /// ```
443    #[must_use]
444    pub fn find_all(&self, text: &str) -> Vec<String> {
445        let clean_text = self.remove_noise(text);
446
447        // 1. Caching mechanism - Check whether the results have been cached
448        #[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) // long text -> parallel
456        } else {
457            self.find_all_sequential(&clean_text) // short text -> sequential
458        };
459        #[cfg(not(feature = "parallel"))]
460        let results = self.find_all_sequential(&clean_text);
461
462        // 3. Cache results
463        #[cfg(feature = "std")]
464        self.cache_result(&clean_text, &results);
465
466        results
467    }
468
469    /// Parallel processing version — for long text.
470    ///
471    /// Exact scan and variant detection are independent, so they run concurrently via
472    /// [`rayon::join`]. Variant detection runs once over the full text (the previous
473    /// whitespace-split parallelization dropped cross-segment variants).
474    #[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    /// Sequential processing version - suitable for short text
489    fn find_all_sequential(&self, text: &str) -> Vec<String> {
490        let mut results = self.engine.find_all(text);
491
492        // Add variant detection results (std only: pinyin/shape detection)
493        #[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    /// Deduplication and sort
503    fn deduplicate_and_sort(&self, mut results: Vec<String>) -> Vec<String> {
504        results.sort_unstable();
505        results.dedup();
506        results
507    }
508
509    /// Bulk search for optimized versions
510    ///
511    /// Runs [`Filter::find_all`] over each text. With the `parallel` feature (default) the
512    /// texts are processed concurrently.
513    ///
514    /// # Examples
515    ///
516    /// ```
517    /// use sensitive_rs::Filter;
518    ///
519    /// let mut filter = Filter::new();
520    /// filter.add_words(&["赌博", "色情"]);
521    /// let results = filter.find_all_batch(&["含有赌博", "正常", "含有色情"]);
522    /// assert!(results[0].contains(&"赌博".to_string()));
523    /// assert!(results[1].is_empty());
524    /// assert!(results[2].contains(&"色情".to_string()));
525    /// ```
526    #[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    /// Hierarchical Matching - Preferential Matching by Sensitive Word Length
539    ///
540    /// Matches the longest words first and consumes their span, so shorter overlapping
541    /// entries are dropped. Useful when the dictionary contains both short and long forms.
542    ///
543    /// # Examples
544    ///
545    /// ```
546    /// use sensitive_rs::Filter;
547    ///
548    /// let mut filter = Filter::new();
549    /// filter.add_words(&["赌", "赌博", "赌博机"]);
550    /// let results = filter.find_all_layered("这里有赌博机");
551    /// assert!(results.contains(&"赌博机".to_string()));
552    /// assert!(!results.contains(&"赌博".to_string()));
553    /// ```
554    #[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        // The longest exact matches...
560        let mut results: Vec<String> = matches.iter().map(|m| m.pattern.clone()).collect();
561
562        // ...then blank those spans before variant detection, so a shorter word's
563        // pinyin/shape isn't re-discovered inside a longer exact match. (std only)
564        #[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    /// Streaming version - suitable for oversized text
583    ///
584    /// Reads line-by-line from any [`BufRead`] and returns the de-duplicated matches
585    /// across all lines. Handy for files too large to hold in memory.
586    ///
587    /// # Examples
588    ///
589    /// ```
590    /// use sensitive_rs::Filter;
591    /// use std::io::Cursor;
592    ///
593    /// let mut filter = Filter::new();
594    /// filter.add_words(&["赌博", "色情"]);
595    /// let input = "第一行含有赌博\n第二行含有色情\n第三行正常";
596    /// let results = filter.find_all_streaming(Cursor::new(input))?;
597    /// assert_eq!(results.len(), 2);
598    /// # Ok::<(), std::io::Error>(())
599    /// ```
600    #[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/// Async dictionary loading (non-blocking I/O). Enable with the `async-io` feature
615/// (and `net-async` for the URL loader). The synchronous API is unchanged.
616#[cfg(feature = "async-io")]
617impl Filter {
618    /// Load a dictionary from a file without blocking the caller's thread.
619    ///
620    /// # Examples
621    ///
622    /// ```no_run
623    /// # #[tokio::main] async fn main() -> std::io::Result<()> {
624    /// use sensitive_rs::Filter;
625    ///
626    /// let mut filter = Filter::new();
627    /// filter.load_word_dict_async("dict/dict.txt").await?;
628    /// # Ok(()) }
629    /// ```
630    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    /// Load a dictionary from a URL without blocking. Requires the `net-async` feature.
644    ///
645    /// # Examples
646    ///
647    /// ```no_run
648    /// # #[tokio::main] async fn main() -> std::io::Result<()> {
649    /// use sensitive_rs::Filter;
650    ///
651    /// let mut filter = Filter::new();
652    /// filter.load_net_word_dict_async("https://example.com/dict.txt").await?;
653    /// # Ok(()) }
654    /// ```
655    #[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        // Exact match
681        assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
682
683        // Pinyin variant
684        assert_eq!(filter.find_in("含有 dubo"), (true, "赌博".to_string()));
685
686        // Replacement
687        assert_eq!(filter.replace("赌博 色情", '*'), "** **");
688
689        // Filter
690        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        // 测试空格保留
699        assert_eq!(filter.remove_noise("赌 博"), "赌 博");
700
701        // 测试特殊符号移除
702        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        // replace should be replaced with characters
713        assert_eq!(filter.replace(text, '*'), "这里有**和**内容");
714
715        // filter should be completely removed
716        assert_eq!(filter.filter(text), "这里有和内容");
717    }
718
719    #[test]
720    fn test_replace_single_pass_rebuild() {
721        // R3: exact matches rebuilt in a single pass, one '*' per matched char,
722        // regardless of how many patterns hit.
723        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        // Pinyin variant path: word found, but is_variant = true.
751        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        // Exact hit wins even though a pinyin variant would also match.
760        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        // Use Wu-Manber in small quantities
766        let mut small = Filter::new();
767        small.add_words(&["a", "b", "c"]);
768        assert!(matches!(small.engine.current_algorithm(), MatchAlgorithm::WuManber));
769
770        // Aho-Corasick for medium quantity
771        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        // Use Wu-Manber in small quantities
833        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        // Aho-Corasick for medium quantity
839        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        // Verification algorithm selection logic
849        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        // First search populates cache
861        let results1 = filter.find_all("这里有赌博");
862        assert!(results1.contains(&"赌博".to_string()));
863
864        // Add a new word
865        filter.add_word("色情");
866
867        // Cache should be invalidated — new word must appear
868        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        // First search populates cache
879        let results1 = filter.find_all("这里有赌博和色情");
880        assert!(results1.contains(&"赌博".to_string()));
881        assert!(results1.contains(&"色情".to_string()));
882
883        // Delete a word
884        filter.del_word("赌博");
885
886        // Cache should be invalidated — deleted word must not appear
887        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        // Poison the mutex by panicking while holding the lock
900        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        // Filter should still work — recovers from poisoned mutex
907        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        // Build text > 1000 bytes so find_all uses parallel path
917        // Place "赌博" at a position that could land on a chunk boundary
918        let prefix: String = "安全文字".repeat(200); // 800 bytes (4 chars × 3 bytes × 200)
919        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        // Build text > 1000 bytes with the word in the middle
931        let prefix: String = "安全".repeat(300); // 1800 bytes
932        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    // ---- Task 2: advanced methods (batch / layered / streaming) ----
940
941    #[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        // Longest match consumes the span; shorter overlapping words are dropped.
968        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        // Two non-overlapping longest matches are both kept; shorter overlapping
977        // forms inside each span are dropped.
978        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        // Regression: a short word whose pinyin is a substring of a longer exact
990        // match must NOT be re-added by variant detection — exact spans are blanked.
991        let mut filter = Filter::new();
992        filter.add_words(&["赌", "赌博", "赌博机"]);
993
994        let results = filter.find_all_layered("这里有赌博机");
995        // "赌"/"赌博" pinyin (du/dubo) sits inside "赌博机" pinyin (duboji), but the
996        // exact span is blanked first, so only the longest match survives.
997        assert_eq!(results, vec!["赌博机".to_string()]);
998    }
999
1000    #[test]
1001    fn test_replace_and_filter_are_exact_only() {
1002        // Pins behavior: replace/filter mask exact dictionary matches only. A
1003        // pinyin variant present in the text passes through unchanged (the variant
1004        // detector reports word names, not the variant text spans to mask).
1005        let mut filter = Filter::new();
1006        filter.add_word("赌博");
1007
1008        // Exact match is masked / removed...
1009        assert_eq!(filter.replace("含有赌博", '*'), "含有**");
1010        assert_eq!(filter.filter("含有赌博"), "含有");
1011        // ...but the pinyin variant "dubo" is left untouched.
1012        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    // ---- Task 3: LRU cache behavior ----
1031
1032    #[test]
1033    fn test_cache_hit_returns_consistent_results() {
1034        let mut filter = Filter::new();
1035        filter.add_words(&["赌博", "色情"]);
1036
1037        // First call — cache miss; second call — cache hit.
1038        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("含有赌博"); // populate cache
1049        filter.clear_cache();
1050
1051        // After clear, the result is recomputed (still correct).
1052        let results = filter.find_all("含有赌博");
1053        assert!(results.contains(&"赌博".to_string()));
1054    }
1055
1056    // ---- Task 4: edge cases ----
1057
1058    #[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        // Emoji are stripped by the noise regex; surrounding CJK still matches.
1085        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        // 100_000 chars = 300_000 bytes, exercises the >1000-byte parallel path.
1096        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        // CJK Extension B (outside BMP); preceding CJK still matches.
1107        let text = "含有赌博内容 𠀀𠀁";
1108        let (found, _) = filter.find_in(text);
1109        assert!(found);
1110    }
1111
1112    // ---- Async loading (async-io feature) ----
1113
1114    #[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}