Skip to main content

max / balanced_breakfast

27.2 KB · 793 lines History Blame Raw
1 //! Feed ordering and filtering strategies.
2 //!
3 //! `OrderBy` controls sort order; `FeedFilter` narrows which items
4 //! are shown. Both are applied in-memory after the database query.
5
6 use std::collections::HashMap;
7
8 use bb_db::QueryCondition;
9 use bb_interface::FeedItem;
10 use regex::Regex;
11
12 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13 /// How to order feed items
14 pub enum OrderBy {
15 /// Most recent first
16 #[default]
17 Chronological,
18 /// Highest score first
19 Score,
20 /// Unread items first, then chronological
21 UnreadFirst,
22 /// Starred items first, then chronological
23 StarredFirst,
24 }
25
26 impl OrderBy {
27 /// Parse from a string (as sent by the frontend).
28 #[tracing::instrument(skip_all)]
29 pub fn from_str_loose(s: &str) -> Self {
30 match s {
31 "score" => OrderBy::Score,
32 "unread" => OrderBy::UnreadFirst,
33 "starred" => OrderBy::StarredFirst,
34 _ => OrderBy::Chronological,
35 }
36 }
37
38 /// Apply ordering to a list of items
39 #[tracing::instrument(skip_all)]
40 pub fn apply(&self, items: &mut [FeedItem]) {
41 match self {
42 OrderBy::Chronological => {
43 items.sort_by(|a, b| b.meta.published_at.cmp(&a.meta.published_at));
44 }
45 OrderBy::Score => {
46 // Use i64::MIN for None so scoreless items sort after all scored items
47 items.sort_by(|a, b| {
48 let score_a = a.meta.score.unwrap_or(i64::MIN);
49 let score_b = b.meta.score.unwrap_or(i64::MIN);
50 score_b.cmp(&score_a).then_with(|| {
51 b.meta.published_at.cmp(&a.meta.published_at)
52 })
53 });
54 }
55 OrderBy::UnreadFirst => {
56 // `a.is_read.cmp(&b.is_read)` sorts false (unread) before true (read)
57 // because false < true in Rust's bool Ord.
58 items.sort_by(|a, b| {
59 a.is_read
60 .cmp(&b.is_read)
61 .then_with(|| b.meta.published_at.cmp(&a.meta.published_at))
62 });
63 }
64 OrderBy::StarredFirst => {
65 items.sort_by(|a, b| {
66 a.is_starred
67 .cmp(&b.is_starred)
68 .reverse()
69 .then_with(|| b.meta.published_at.cmp(&a.meta.published_at))
70 });
71 }
72 }
73 }
74 }
75
76 #[derive(Debug, Clone, Default)]
77 /// Filter criteria for feed items
78 pub struct FeedFilter {
79 /// Filter by source busser ID
80 pub source: Option<String>,
81 /// Only show unread items
82 pub unread_only: bool,
83 /// Only show starred items
84 pub starred_only: bool,
85 /// Search text in title/body
86 pub search: Option<String>,
87 /// Filter by item-level tags (from the source plugin)
88 pub tags: Vec<String>,
89 /// Filter by user-assigned feed-level tags; items must belong to a feed
90 /// with at least one of these tags.
91 pub feed_tags: Vec<String>,
92 /// Query feed conditions (AND logic). Simple conditions (source, starred,
93 /// unread, tag) are mapped to fast-path SQL fields by `from_conditions()`.
94 /// Complex conditions (title/author/body contains/regex) run in-memory.
95 pub conditions: Vec<QueryCondition>,
96 }
97
98 impl FeedFilter {
99 /// Create an empty filter that matches all items.
100 #[tracing::instrument(skip_all)]
101 pub fn new() -> Self {
102 Self::default()
103 }
104
105 /// Restrict to items from a specific busser source.
106 #[tracing::instrument(skip_all)]
107 pub fn source(mut self, source: impl Into<String>) -> Self {
108 self.source = Some(source.into());
109 self
110 }
111
112 /// Only show items that haven't been read.
113 #[tracing::instrument(skip_all)]
114 pub fn unread_only(mut self) -> Self {
115 self.unread_only = true;
116 self
117 }
118
119 /// Only show items that have been starred.
120 #[tracing::instrument(skip_all)]
121 pub fn starred_only(mut self) -> Self {
122 self.starred_only = true;
123 self
124 }
125
126 /// Full-text search across title, body, and bite text (case-insensitive).
127 pub fn search(mut self, query: impl Into<String>) -> Self {
128 self.search = Some(query.into());
129 self
130 }
131
132 /// Require at least one of the item's tags to match the given tag.
133 #[tracing::instrument(skip_all)]
134 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
135 self.tags.push(tag.into());
136 self
137 }
138
139 /// Filter by user-assigned feed-level tag.
140 #[tracing::instrument(skip_all)]
141 pub fn with_feed_tag(mut self, tag: impl Into<String>) -> Self {
142 self.feed_tags.push(tag.into());
143 self
144 }
145
146 /// Build a filter from query feed conditions.
147 ///
148 /// Simple conditions (source, starred, unread, tag) are mapped to the
149 /// corresponding fast-path SQL fields. Complex conditions (title/author/body
150 /// contains, regex) are stored in `self.conditions` for in-memory evaluation.
151 #[tracing::instrument(skip_all)]
152 pub fn from_conditions(conditions: Vec<QueryCondition>) -> Self {
153 let mut filter = Self::new();
154 for c in &conditions {
155 match (c.field.as_str(), c.operator.as_str()) {
156 ("source", "equals") => {
157 filter.source = Some(c.value.clone());
158 }
159 ("starred", "is") if c.value == "true" => {
160 filter.starred_only = true;
161 }
162 ("unread", "is") if c.value == "true" => {
163 filter.unread_only = true;
164 }
165 ("tag", "equals") => {
166 filter.tags.push(c.value.clone());
167 }
168 _ => {}
169 }
170 }
171 filter.conditions = conditions;
172 filter
173 }
174
175 /// Pre-compile regex patterns from conditions. Called once before filtering
176 /// a batch of items to avoid recompiling per-item.
177 pub fn compile_regexes(&self) -> HashMap<usize, Regex> {
178 let mut cache = HashMap::new();
179 for (i, c) in self.conditions.iter().enumerate() {
180 if c.operator == "matches_regex" {
181 match Regex::new(&c.value) {
182 Ok(re) => { cache.insert(i, re); }
183 Err(e) => {
184 tracing::warn!(
185 regex = %c.value,
186 error = %e,
187 "Invalid regex in query feed condition, skipping"
188 );
189 }
190 }
191 }
192 }
193 cache
194 }
195
196 /// Check if an item matches the filter
197 #[tracing::instrument(skip_all)]
198 pub fn matches(&self, item: &FeedItem) -> bool {
199 let cache = self.compile_regexes();
200 self.matches_with_cache(item, &cache)
201 }
202
203 /// Check if an item matches using pre-compiled regex cache.
204 pub fn matches_with_cache(&self, item: &FeedItem, regex_cache: &HashMap<usize, Regex>) -> bool {
205 // Check source filter
206 if let Some(ref source) = self.source {
207 if item.id.source.as_str() != source {
208 return false;
209 }
210 }
211
212 // Check unread filter
213 if self.unread_only && item.is_read {
214 return false;
215 }
216
217 // Check starred filter
218 if self.starred_only && !item.is_starred {
219 return false;
220 }
221
222 // Check search filter
223 if let Some(ref query) = self.search {
224 let query_lower = query.to_lowercase();
225 let matches_title = item
226 .content
227 .title
228 .as_ref()
229 .map(|t| t.to_lowercase().contains(&query_lower))
230 .unwrap_or(false);
231 let matches_body = item
232 .content
233 .body
234 .as_ref()
235 .map(|b| b.to_lowercase().contains(&query_lower))
236 .unwrap_or(false);
237 let matches_bite = item.bite.text.to_lowercase().contains(&query_lower);
238
239 if !matches_title && !matches_body && !matches_bite {
240 return false;
241 }
242 }
243
244 // Check tag filter
245 if !self.tags.is_empty() {
246 let item_tags: Vec<String> = item.meta.tags.iter().map(|t| t.to_string()).collect();
247 let has_tag = self.tags.iter().any(|t| item_tags.contains(t));
248 if !has_tag {
249 return false;
250 }
251 }
252
253 // Check query feed conditions (in-memory filtering for text fields).
254 // Source/starred/unread/tag conditions are already handled by the
255 // fast-path fields above (set in `from_conditions()`), so we only
256 // need to evaluate title/author/body conditions here.
257 for (i, c) in self.conditions.iter().enumerate() {
258 match c.field.as_str() {
259 "title" | "author" | "body" => {
260 let field_value = match c.field.as_str() {
261 "title" => item.content.title.as_deref().unwrap_or(""),
262 "author" => &item.bite.author,
263 "body" => item.content.body.as_deref().unwrap_or(""),
264 _ => "",
265 };
266 match c.operator.as_str() {
267 "contains" => {
268 if !field_value.to_lowercase().contains(&c.value.to_lowercase()) {
269 return false;
270 }
271 }
272 "not_contains" => {
273 if field_value.to_lowercase().contains(&c.value.to_lowercase()) {
274 return false;
275 }
276 }
277 "equals" => {
278 if !field_value.eq_ignore_ascii_case(&c.value) {
279 return false;
280 }
281 }
282 "matches_regex" => {
283 if let Some(re) = regex_cache.get(&i) {
284 if !re.is_match(field_value) {
285 return false;
286 }
287 }
288 // Missing from cache = invalid regex, skip (already warned)
289 }
290 _ => {}
291 }
292 }
293 // source/starred/unread/tag already handled by fast-path fields
294 _ => {}
295 }
296 }
297
298 true
299 }
300
301 /// Apply filter to a list of items
302 #[tracing::instrument(skip_all)]
303 pub fn apply(&self, items: Vec<FeedItem>) -> Vec<FeedItem> {
304 let cache = self.compile_regexes();
305 items.into_iter().filter(|item| self.matches_with_cache(item, &cache)).collect()
306 }
307
308 /// Apply only the tag filter to a list of items.
309 ///
310 /// Used when search, source, unread, and starred filters have already
311 /// been pushed into the SQL query, but tag filtering still needs to
312 /// happen in-memory.
313 #[tracing::instrument(skip_all)]
314 pub fn apply_tags_only(&self, items: Vec<FeedItem>) -> Vec<FeedItem> {
315 if self.tags.is_empty() {
316 return items;
317 }
318 items
319 .into_iter()
320 .filter(|item| {
321 let item_tags: Vec<String> = item.meta.tags.iter().map(|t| t.to_string()).collect();
322 self.tags.iter().any(|t| item_tags.contains(t))
323 })
324 .collect()
325 }
326 }
327
328 #[cfg(test)]
329 mod tests {
330 use super::*;
331 use bb_interface::{BiteDisplay, FeedItemContent, FeedItemId, FeedItemMeta};
332
333 fn make_item(source: &str, id: &str, published_at: i64) -> FeedItem {
334 FeedItem::new(
335 FeedItemId::new(source, id),
336 BiteDisplay::new("author", format!("text for {}", id)),
337 FeedItemContent::new(),
338 FeedItemMeta::new(source, published_at),
339 )
340 }
341
342 fn make_item_full(
343 source: &str,
344 id: &str,
345 published_at: i64,
346 score: Option<i64>,
347 is_read: bool,
348 is_starred: bool,
349 ) -> FeedItem {
350 let mut item = make_item(source, id, published_at);
351 item.meta.score = score;
352 item.is_read = is_read;
353 item.is_starred = is_starred;
354 item
355 }
356
357 // --- OrderBy::from_str_loose ---
358
359 #[test]
360 fn from_str_loose_score() {
361 assert_eq!(OrderBy::from_str_loose("score"), OrderBy::Score);
362 }
363
364 #[test]
365 fn from_str_loose_unread() {
366 assert_eq!(OrderBy::from_str_loose("unread"), OrderBy::UnreadFirst);
367 }
368
369 #[test]
370 fn from_str_loose_starred() {
371 assert_eq!(OrderBy::from_str_loose("starred"), OrderBy::StarredFirst);
372 }
373
374 #[test]
375 fn from_str_loose_chrono() {
376 assert_eq!(OrderBy::from_str_loose("chrono"), OrderBy::Chronological);
377 }
378
379 #[test]
380 fn from_str_loose_unknown_defaults_to_chrono() {
381 assert_eq!(OrderBy::from_str_loose("gibberish"), OrderBy::Chronological);
382 assert_eq!(OrderBy::from_str_loose(""), OrderBy::Chronological);
383 }
384
385 // --- OrderBy::apply ---
386
387 #[test]
388 fn apply_chronological_sorts_newest_first() {
389 let mut items = vec![
390 make_item("s", "old", 100),
391 make_item("s", "new", 300),
392 make_item("s", "mid", 200),
393 ];
394 OrderBy::Chronological.apply(&mut items);
395 assert_eq!(items[0].id.item_id, "new");
396 assert_eq!(items[1].id.item_id, "mid");
397 assert_eq!(items[2].id.item_id, "old");
398 }
399
400 #[test]
401 fn apply_score_sorts_highest_first() {
402 let mut items = vec![
403 make_item_full("s", "low", 100, Some(10), false, false),
404 make_item_full("s", "high", 100, Some(100), false, false),
405 make_item_full("s", "none", 100, None, false, false),
406 ];
407 OrderBy::Score.apply(&mut items);
408 assert_eq!(items[0].id.item_id, "high");
409 assert_eq!(items[1].id.item_id, "low");
410 assert_eq!(items[2].id.item_id, "none");
411 }
412
413 #[test]
414 fn apply_unread_first_groups_by_read_state() {
415 let mut items = vec![
416 make_item_full("s", "read1", 300, None, true, false),
417 make_item_full("s", "unread1", 200, None, false, false),
418 make_item_full("s", "read2", 100, None, true, false),
419 make_item_full("s", "unread2", 400, None, false, false),
420 ];
421 OrderBy::UnreadFirst.apply(&mut items);
422 // Unread items (is_read=false) come first, then read items.
423 // Within each group, sorted by published_at descending.
424 assert_eq!(items[0].id.item_id, "unread2");
425 assert_eq!(items[1].id.item_id, "unread1");
426 assert_eq!(items[2].id.item_id, "read1");
427 assert_eq!(items[3].id.item_id, "read2");
428 }
429
430 #[test]
431 fn apply_starred_first() {
432 let mut items = vec![
433 make_item_full("s", "normal", 200, None, false, false),
434 make_item_full("s", "starred", 100, None, false, true),
435 ];
436 OrderBy::StarredFirst.apply(&mut items);
437 assert!(items[0].is_starred);
438 }
439
440 // --- FeedFilter::matches ---
441
442 #[test]
443 fn filter_matches_all_by_default() {
444 let filter = FeedFilter::new();
445 let item = make_item("src", "1", 100);
446 assert!(filter.matches(&item));
447 }
448
449 #[test]
450 fn filter_source_match() {
451 let filter = FeedFilter::new().source("rss");
452 let matching = make_item("rss", "1", 100);
453 let non_matching = make_item("hn", "2", 100);
454 assert!(filter.matches(&matching));
455 assert!(!filter.matches(&non_matching));
456 }
457
458 #[test]
459 fn filter_unread_only() {
460 let filter = FeedFilter::new().unread_only();
461 let unread = make_item_full("s", "1", 100, None, false, false);
462 let read = make_item_full("s", "2", 100, None, true, false);
463 assert!(filter.matches(&unread));
464 assert!(!filter.matches(&read));
465 }
466
467 #[test]
468 fn filter_starred_only() {
469 let filter = FeedFilter::new().starred_only();
470 let starred = make_item_full("s", "1", 100, None, false, true);
471 let not_starred = make_item_full("s", "2", 100, None, false, false);
472 assert!(filter.matches(&starred));
473 assert!(!filter.matches(&not_starred));
474 }
475
476 #[test]
477 fn filter_search_matches_bite_text() {
478 let filter = FeedFilter::new().search("text for abc");
479 let item = make_item("s", "abc", 100);
480 assert!(filter.matches(&item));
481 }
482
483 #[test]
484 fn filter_search_matches_title() {
485 let filter = FeedFilter::new().search("hello");
486 let mut item = make_item("s", "1", 100);
487 item.content.title = Some("Hello World".to_string());
488 assert!(filter.matches(&item));
489 }
490
491 #[test]
492 fn filter_search_no_match() {
493 let filter = FeedFilter::new().search("zzzzz");
494 let item = make_item("s", "1", 100);
495 assert!(!filter.matches(&item));
496 }
497
498 #[test]
499 fn filter_tag_match() {
500 let filter = FeedFilter::new().with_tag("rust");
501 let mut item = make_item("s", "1", 100);
502 item.meta.tags = vec!["rust".to_string(), "programming".to_string()];
503 assert!(filter.matches(&item));
504 }
505
506 #[test]
507 fn filter_tag_no_match() {
508 let filter = FeedFilter::new().with_tag("python");
509 let mut item = make_item("s", "1", 100);
510 item.meta.tags = vec!["rust".to_string()];
511 assert!(!filter.matches(&item));
512 }
513
514 #[test]
515 fn filter_combined() {
516 let filter = FeedFilter::new().source("rss").unread_only();
517 let good = make_item_full("rss", "1", 100, None, false, false);
518 let wrong_source = make_item_full("hn", "2", 100, None, false, false);
519 let is_read = make_item_full("rss", "3", 100, None, true, false);
520 assert!(filter.matches(&good));
521 assert!(!filter.matches(&wrong_source));
522 assert!(!filter.matches(&is_read));
523 }
524
525 #[test]
526 fn filter_apply_returns_matching_items() {
527 let filter = FeedFilter::new().source("rss");
528 let items = vec![
529 make_item("rss", "1", 100),
530 make_item("hn", "2", 200),
531 make_item("rss", "3", 300),
532 ];
533 let result = filter.apply(items);
534 assert_eq!(result.len(), 2);
535 assert!(result.iter().all(|i| i.id.source == "rss"));
536 }
537
538 // --- FeedFilter::apply_tags_only ---
539
540 #[test]
541 fn apply_tags_only_empty_filter_returns_all() {
542 let filter = FeedFilter::new();
543 let items = vec![make_item("s", "1", 100), make_item("s", "2", 200)];
544 let result = filter.apply_tags_only(items);
545 assert_eq!(result.len(), 2);
546 }
547
548 #[test]
549 fn apply_tags_only_matching_tag() {
550 let filter = FeedFilter::new().with_tag("rust");
551 let mut item = make_item("s", "1", 100);
552 item.meta.tags = vec!["rust".to_string()];
553 let result = filter.apply_tags_only(vec![item]);
554 assert_eq!(result.len(), 1);
555 }
556
557 #[test]
558 fn apply_tags_only_no_match() {
559 let filter = FeedFilter::new().with_tag("rust");
560 let mut item = make_item("s", "1", 100);
561 item.meta.tags = vec!["go".to_string()];
562 let result = filter.apply_tags_only(vec![item]);
563 assert_eq!(result.len(), 0);
564 }
565
566 #[test]
567 fn apply_tags_only_any_match() {
568 let filter = FeedFilter::new().with_tag("rust").with_tag("go");
569 let mut item = make_item("s", "1", 100);
570 item.meta.tags = vec!["rust".to_string()];
571 let result = filter.apply_tags_only(vec![item]);
572 assert_eq!(result.len(), 1, "OR logic: item with 'rust' matches filter ['rust', 'go']");
573 }
574
575 #[test]
576 fn apply_tags_only_multiple_item_tags() {
577 let filter = FeedFilter::new().with_tag("news");
578 let mut item = make_item("s", "1", 100);
579 item.meta.tags = vec!["rust".to_string(), "news".to_string()];
580 let result = filter.apply_tags_only(vec![item]);
581 assert_eq!(result.len(), 1);
582 }
583
584 #[test]
585 fn apply_tags_only_mixed_items() {
586 let filter = FeedFilter::new().with_tag("rust");
587 let mut item1 = make_item("s", "1", 100);
588 item1.meta.tags = vec!["rust".to_string()];
589 let mut item2 = make_item("s", "2", 200);
590 item2.meta.tags = vec!["python".to_string()];
591 let mut item3 = make_item("s", "3", 300);
592 item3.meta.tags = vec!["rust".to_string(), "wasm".to_string()];
593
594 let result = filter.apply_tags_only(vec![item1, item2, item3]);
595 assert_eq!(result.len(), 2);
596 assert!(result.iter().all(|i| i.meta.tags.contains(&"rust".to_string())));
597 }
598
599 // --- FeedFilter::matches (tag-related paths) ---
600
601 #[test]
602 fn filter_matches_with_tag() {
603 let filter = FeedFilter::new().with_tag("release");
604 let mut item = make_item("s", "1", 100);
605 item.meta.tags = vec!["release".to_string(), "v2".to_string()];
606 assert!(filter.matches(&item));
607 }
608
609 #[test]
610 fn filter_rejects_without_tag() {
611 let filter = FeedFilter::new().with_tag("release");
612 let mut item = make_item("s", "1", 100);
613 item.meta.tags = vec!["discussion".to_string()];
614 assert!(!filter.matches(&item));
615 }
616
617 // --- FeedFilter::from_conditions ---
618
619 fn cond(field: &str, operator: &str, value: &str) -> QueryCondition {
620 QueryCondition {
621 field: field.to_string(),
622 operator: operator.to_string(),
623 value: value.to_string(),
624 }
625 }
626
627 #[test]
628 fn from_conditions_source_equals_sets_fast_path() {
629 let filter = FeedFilter::from_conditions(vec![cond("source", "equals", "rss")]);
630 assert_eq!(filter.source, Some("rss".to_string()));
631 }
632
633 #[test]
634 fn from_conditions_starred_sets_fast_path() {
635 let filter = FeedFilter::from_conditions(vec![cond("starred", "is", "true")]);
636 assert!(filter.starred_only);
637 }
638
639 #[test]
640 fn from_conditions_unread_sets_fast_path() {
641 let filter = FeedFilter::from_conditions(vec![cond("unread", "is", "true")]);
642 assert!(filter.unread_only);
643 }
644
645 #[test]
646 fn from_conditions_tag_sets_fast_path() {
647 let filter = FeedFilter::from_conditions(vec![cond("tag", "equals", "rust")]);
648 assert_eq!(filter.tags, vec!["rust".to_string()]);
649 }
650
651 #[test]
652 fn from_conditions_stores_all_conditions() {
653 let conditions = vec![
654 cond("source", "equals", "rss"),
655 cond("title", "contains", "rust"),
656 ];
657 let filter = FeedFilter::from_conditions(conditions);
658 assert_eq!(filter.conditions.len(), 2);
659 }
660
661 #[test]
662 fn from_conditions_starred_false_no_fast_path() {
663 let filter = FeedFilter::from_conditions(vec![cond("starred", "is", "false")]);
664 assert!(!filter.starred_only);
665 }
666
667 // --- Condition matching (title/author/body) ---
668
669 #[test]
670 fn condition_title_contains_matches() {
671 let filter = FeedFilter::from_conditions(vec![cond("title", "contains", "rust")]);
672 let mut item = make_item("s", "1", 100);
673 item.content.title = Some("Learning Rust today".to_string());
674 assert!(filter.matches(&item));
675 }
676
677 #[test]
678 fn condition_title_contains_case_insensitive() {
679 let filter = FeedFilter::from_conditions(vec![cond("title", "contains", "RUST")]);
680 let mut item = make_item("s", "1", 100);
681 item.content.title = Some("Learning rust today".to_string());
682 assert!(filter.matches(&item));
683 }
684
685 #[test]
686 fn condition_title_contains_no_match() {
687 let filter = FeedFilter::from_conditions(vec![cond("title", "contains", "python")]);
688 let mut item = make_item("s", "1", 100);
689 item.content.title = Some("Learning Rust today".to_string());
690 assert!(!filter.matches(&item));
691 }
692
693 #[test]
694 fn condition_title_not_contains() {
695 let filter = FeedFilter::from_conditions(vec![cond("title", "not_contains", "python")]);
696 let mut item = make_item("s", "1", 100);
697 item.content.title = Some("Learning Rust today".to_string());
698 assert!(filter.matches(&item));
699 }
700
701 #[test]
702 fn condition_title_not_contains_rejects() {
703 let filter = FeedFilter::from_conditions(vec![cond("title", "not_contains", "rust")]);
704 let mut item = make_item("s", "1", 100);
705 item.content.title = Some("Learning Rust today".to_string());
706 assert!(!filter.matches(&item));
707 }
708
709 #[test]
710 fn condition_title_equals() {
711 let filter = FeedFilter::from_conditions(vec![cond("title", "equals", "Hello World")]);
712 let mut item = make_item("s", "1", 100);
713 item.content.title = Some("hello world".to_string());
714 assert!(filter.matches(&item), "equals should be case-insensitive");
715 }
716
717 #[test]
718 fn condition_title_matches_regex() {
719 let filter =
720 FeedFilter::from_conditions(vec![cond("title", "matches_regex", r"^Rust \d+")]);
721 let mut item = make_item("s", "1", 100);
722 item.content.title = Some("Rust 2024 edition".to_string());
723 assert!(filter.matches(&item));
724 }
725
726 #[test]
727 fn condition_title_matches_regex_no_match() {
728 let filter =
729 FeedFilter::from_conditions(vec![cond("title", "matches_regex", r"^Python \d+")]);
730 let mut item = make_item("s", "1", 100);
731 item.content.title = Some("Rust 2024 edition".to_string());
732 assert!(!filter.matches(&item));
733 }
734
735 #[test]
736 fn condition_invalid_regex_skipped() {
737 let filter =
738 FeedFilter::from_conditions(vec![cond("title", "matches_regex", r"[invalid")]);
739 let mut item = make_item("s", "1", 100);
740 item.content.title = Some("anything".to_string());
741 // Invalid regex should be skipped (not reject the item)
742 assert!(filter.matches(&item));
743 }
744
745 #[test]
746 fn condition_author_contains() {
747 let filter = FeedFilter::from_conditions(vec![cond("author", "contains", "alice")]);
748 let mut item = make_item("s", "1", 100);
749 item.bite.author = "Alice Smith".to_string();
750 assert!(filter.matches(&item));
751 }
752
753 #[test]
754 fn condition_body_contains() {
755 let filter = FeedFilter::from_conditions(vec![cond("body", "contains", "important")]);
756 let mut item = make_item("s", "1", 100);
757 item.content.body = Some("This is an important article".to_string());
758 assert!(filter.matches(&item));
759 }
760
761 #[test]
762 fn condition_body_not_contains() {
763 let filter = FeedFilter::from_conditions(vec![cond("body", "not_contains", "spam")]);
764 let mut item = make_item("s", "1", 100);
765 item.content.body = Some("This is a good article".to_string());
766 assert!(filter.matches(&item));
767 }
768
769 #[test]
770 fn conditions_and_logic() {
771 let filter = FeedFilter::from_conditions(vec![
772 cond("title", "contains", "rust"),
773 cond("author", "contains", "alice"),
774 ]);
775 let mut item = make_item("s", "1", 100);
776 item.content.title = Some("Rust tips".to_string());
777 item.bite.author = "Alice".to_string();
778 assert!(filter.matches(&item));
779
780 let mut wrong_author = make_item("s", "2", 100);
781 wrong_author.content.title = Some("Rust tips".to_string());
782 wrong_author.bite.author = "Bob".to_string();
783 assert!(!filter.matches(&wrong_author));
784 }
785
786 #[test]
787 fn empty_conditions_matches_all() {
788 let filter = FeedFilter::from_conditions(vec![]);
789 let item = make_item("s", "1", 100);
790 assert!(filter.matches(&item));
791 }
792 }
793