semantic bufo search
find-bufo.com
bufo
1//! composable result filters
2//!
3//! filters are predicates that can be combined to create complex filtering logic.
4
5use regex::Regex;
6
7/// a single search result that can be filtered
8pub trait Filterable {
9 fn name(&self) -> &str;
10}
11
12/// a predicate that can accept or reject items
13pub trait Filter<T: Filterable>: Send + Sync {
14 /// returns true if the item should be kept
15 fn matches(&self, item: &T) -> bool;
16}
17
18/// filters out inappropriate content based on a blocklist
19struct BlocklistFilter {
20 blocklist: Vec<&'static str>,
21}
22
23impl BlocklistFilter {
24 fn inappropriate_bufos() -> Self {
25 Self {
26 blocklist: vec![
27 "bufo-juicy",
28 "good-news-bufo-offers-suppository",
29 "bufo-declines-your-suppository-offer",
30 "tsa-bufo-gropes-you",
31 ],
32 }
33 }
34}
35
36impl<T: Filterable> Filter<T> for BlocklistFilter {
37 fn matches(&self, item: &T) -> bool {
38 !self.blocklist.iter().any(|blocked| item.name().contains(blocked))
39 }
40}
41
42/// filters out items matching any of the given regex patterns
43struct ExcludePatternFilter {
44 patterns: Vec<Regex>,
45}
46
47impl ExcludePatternFilter {
48 fn from_comma_separated(pattern_str: &str) -> Self {
49 let patterns = pattern_str
50 .split(',')
51 .map(|p| p.trim())
52 .filter(|p| !p.is_empty())
53 .filter_map(|p| Regex::new(p).ok())
54 .collect();
55
56 Self { patterns }
57 }
58
59 fn empty() -> Self {
60 Self { patterns: vec![] }
61 }
62}
63
64impl<T: Filterable> Filter<T> for ExcludePatternFilter {
65 fn matches(&self, item: &T) -> bool {
66 !self.patterns.iter().any(|p| p.is_match(item.name()))
67 }
68}
69
70/// combined filter that handles family-friendly mode and include/exclude patterns
71pub struct ContentFilter {
72 family_friendly: bool,
73 blocklist: BlocklistFilter,
74 exclude: ExcludePatternFilter,
75 include_patterns: Vec<Regex>,
76}
77
78impl ContentFilter {
79 pub fn new(
80 family_friendly: bool,
81 exclude_str: Option<&str>,
82 include_str: Option<&str>,
83 ) -> Self {
84 let exclude = exclude_str
85 .map(ExcludePatternFilter::from_comma_separated)
86 .unwrap_or_else(ExcludePatternFilter::empty);
87
88 let include_patterns: Vec<Regex> = include_str
89 .map(|s| {
90 s.split(',')
91 .map(|p| p.trim())
92 .filter(|p| !p.is_empty())
93 .filter_map(|p| Regex::new(p).ok())
94 .collect()
95 })
96 .unwrap_or_default();
97
98 Self {
99 family_friendly,
100 blocklist: BlocklistFilter::inappropriate_bufos(),
101 exclude,
102 include_patterns,
103 }
104 }
105
106 pub fn exclude_pattern_count(&self) -> usize {
107 self.exclude.patterns.len()
108 }
109
110 pub fn exclude_patterns_str(&self) -> String {
111 self.exclude
112 .patterns
113 .iter()
114 .map(|r| r.as_str())
115 .collect::<Vec<_>>()
116 .join(",")
117 }
118}
119
120impl<T: Filterable> Filter<T> for ContentFilter {
121 fn matches(&self, item: &T) -> bool {
122 // check family-friendly blocklist
123 if self.family_friendly && !self.blocklist.matches(item) {
124 return false;
125 }
126
127 // check if explicitly included (overrides exclude)
128 let matches_include = self.include_patterns.iter().any(|p| p.is_match(item.name()));
129 if matches_include {
130 return true;
131 }
132
133 // check exclude patterns
134 self.exclude.matches(item)
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 struct TestItem {
143 name: String,
144 }
145
146 impl Filterable for TestItem {
147 fn name(&self) -> &str {
148 &self.name
149 }
150 }
151
152 #[test]
153 fn test_blocklist_filter() {
154 let filter = BlocklistFilter::inappropriate_bufos();
155 let good = TestItem {
156 name: "bufo-happy".into(),
157 };
158 let bad = TestItem {
159 name: "bufo-juicy".into(),
160 };
161
162 assert!(filter.matches(&good));
163 assert!(!filter.matches(&bad));
164 }
165
166 #[test]
167 fn test_exclude_pattern_filter() {
168 let filter = ExcludePatternFilter::from_comma_separated("test, draft");
169 let good = TestItem {
170 name: "bufo-happy".into(),
171 };
172 let bad = TestItem {
173 name: "bufo-test-mode".into(),
174 };
175
176 assert!(filter.matches(&good));
177 assert!(!filter.matches(&bad));
178 }
179
180 #[test]
181 fn test_include_overrides_exclude() {
182 let filter = ContentFilter::new(false, Some("party"), Some("birthday-party"));
183 let excluded = TestItem {
184 name: "bufo-party".into(),
185 };
186 let included = TestItem {
187 name: "bufo-birthday-party".into(),
188 };
189
190 assert!(!filter.matches(&excluded));
191 assert!(filter.matches(&included));
192 }
193}