//! Feed ordering and filtering strategies. //! //! `OrderBy` controls sort order; `FeedFilter` narrows which items //! are shown. Both are applied in-memory after the database query. use std::collections::HashMap; use bb_db::QueryCondition; use bb_interface::FeedItem; use regex::Regex; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] /// How to order feed items pub enum OrderBy { /// Most recent first #[default] Chronological, /// Highest score first Score, /// Unread items first, then chronological UnreadFirst, /// Starred items first, then chronological StarredFirst, } impl OrderBy { /// Parse from a string (as sent by the frontend). #[tracing::instrument(skip_all)] pub fn from_str_loose(s: &str) -> Self { match s { "score" => OrderBy::Score, "unread" => OrderBy::UnreadFirst, "starred" => OrderBy::StarredFirst, _ => OrderBy::Chronological, } } /// Apply ordering to a list of items #[tracing::instrument(skip_all)] pub fn apply(&self, items: &mut [FeedItem]) { match self { OrderBy::Chronological => { items.sort_by(|a, b| b.meta.published_at.cmp(&a.meta.published_at)); } OrderBy::Score => { // Use i64::MIN for None so scoreless items sort after all scored items items.sort_by(|a, b| { let score_a = a.meta.score.unwrap_or(i64::MIN); let score_b = b.meta.score.unwrap_or(i64::MIN); score_b.cmp(&score_a).then_with(|| { b.meta.published_at.cmp(&a.meta.published_at) }) }); } OrderBy::UnreadFirst => { // `a.is_read.cmp(&b.is_read)` sorts false (unread) before true (read) // because false < true in Rust's bool Ord. items.sort_by(|a, b| { a.is_read .cmp(&b.is_read) .then_with(|| b.meta.published_at.cmp(&a.meta.published_at)) }); } OrderBy::StarredFirst => { items.sort_by(|a, b| { a.is_starred .cmp(&b.is_starred) .reverse() .then_with(|| b.meta.published_at.cmp(&a.meta.published_at)) }); } } } } #[derive(Debug, Clone, Default)] /// Filter criteria for feed items pub struct FeedFilter { /// Filter by source busser ID pub source: Option, /// Only show unread items pub unread_only: bool, /// Only show starred items pub starred_only: bool, /// Search text in title/body pub search: Option, /// Filter by item-level tags (from the source plugin) pub tags: Vec, /// Filter by user-assigned feed-level tags; items must belong to a feed /// with at least one of these tags. pub feed_tags: Vec, /// Query feed conditions (AND logic). Simple conditions (source, starred, /// unread, tag) are mapped to fast-path SQL fields by `from_conditions()`. /// Complex conditions (title/author/body contains/regex) run in-memory. pub conditions: Vec, } impl FeedFilter { /// Create an empty filter that matches all items. #[tracing::instrument(skip_all)] pub fn new() -> Self { Self::default() } /// Restrict to items from a specific busser source. #[tracing::instrument(skip_all)] pub fn source(mut self, source: impl Into) -> Self { self.source = Some(source.into()); self } /// Only show items that haven't been read. #[tracing::instrument(skip_all)] pub fn unread_only(mut self) -> Self { self.unread_only = true; self } /// Only show items that have been starred. #[tracing::instrument(skip_all)] pub fn starred_only(mut self) -> Self { self.starred_only = true; self } /// Full-text search across title, body, and bite text (case-insensitive). pub fn search(mut self, query: impl Into) -> Self { self.search = Some(query.into()); self } /// Require at least one of the item's tags to match the given tag. #[tracing::instrument(skip_all)] pub fn with_tag(mut self, tag: impl Into) -> Self { self.tags.push(tag.into()); self } /// Filter by user-assigned feed-level tag. #[tracing::instrument(skip_all)] pub fn with_feed_tag(mut self, tag: impl Into) -> Self { self.feed_tags.push(tag.into()); self } /// Build a filter from query feed conditions. /// /// Simple conditions (source, starred, unread, tag) are mapped to the /// corresponding fast-path SQL fields. Complex conditions (title/author/body /// contains, regex) are stored in `self.conditions` for in-memory evaluation. #[tracing::instrument(skip_all)] pub fn from_conditions(conditions: Vec) -> Self { let mut filter = Self::new(); for c in &conditions { match (c.field.as_str(), c.operator.as_str()) { ("source", "equals") => { filter.source = Some(c.value.clone()); } ("starred", "is") if c.value == "true" => { filter.starred_only = true; } ("unread", "is") if c.value == "true" => { filter.unread_only = true; } ("tag", "equals") => { filter.tags.push(c.value.clone()); } _ => {} } } filter.conditions = conditions; filter } /// Pre-compile regex patterns from conditions. Called once before filtering /// a batch of items to avoid recompiling per-item. pub fn compile_regexes(&self) -> HashMap { let mut cache = HashMap::new(); for (i, c) in self.conditions.iter().enumerate() { if c.operator == "matches_regex" { match Regex::new(&c.value) { Ok(re) => { cache.insert(i, re); } Err(e) => { tracing::warn!( regex = %c.value, error = %e, "Invalid regex in query feed condition, skipping" ); } } } } cache } /// Check if an item matches the filter #[tracing::instrument(skip_all)] pub fn matches(&self, item: &FeedItem) -> bool { let cache = self.compile_regexes(); self.matches_with_cache(item, &cache) } /// Check if an item matches using pre-compiled regex cache. pub fn matches_with_cache(&self, item: &FeedItem, regex_cache: &HashMap) -> bool { // Check source filter if let Some(ref source) = self.source { if item.id.source.as_str() != source { return false; } } // Check unread filter if self.unread_only && item.is_read { return false; } // Check starred filter if self.starred_only && !item.is_starred { return false; } // Check search filter if let Some(ref query) = self.search { let query_lower = query.to_lowercase(); let matches_title = item .content .title .as_ref() .map(|t| t.to_lowercase().contains(&query_lower)) .unwrap_or(false); let matches_body = item .content .body .as_ref() .map(|b| b.to_lowercase().contains(&query_lower)) .unwrap_or(false); let matches_bite = item.bite.text.to_lowercase().contains(&query_lower); if !matches_title && !matches_body && !matches_bite { return false; } } // Check tag filter if !self.tags.is_empty() { let item_tags: Vec = item.meta.tags.iter().map(|t| t.to_string()).collect(); let has_tag = self.tags.iter().any(|t| item_tags.contains(t)); if !has_tag { return false; } } // Check query feed conditions (in-memory filtering for text fields). // Source/starred/unread/tag conditions are already handled by the // fast-path fields above (set in `from_conditions()`), so we only // need to evaluate title/author/body conditions here. for (i, c) in self.conditions.iter().enumerate() { match c.field.as_str() { "title" | "author" | "body" => { let field_value = match c.field.as_str() { "title" => item.content.title.as_deref().unwrap_or(""), "author" => &item.bite.author, "body" => item.content.body.as_deref().unwrap_or(""), _ => "", }; match c.operator.as_str() { "contains" => { if !field_value.to_lowercase().contains(&c.value.to_lowercase()) { return false; } } "not_contains" => { if field_value.to_lowercase().contains(&c.value.to_lowercase()) { return false; } } "equals" => { if !field_value.eq_ignore_ascii_case(&c.value) { return false; } } "matches_regex" => { if let Some(re) = regex_cache.get(&i) { if !re.is_match(field_value) { return false; } } // Missing from cache = invalid regex, skip (already warned) } _ => {} } } // source/starred/unread/tag already handled by fast-path fields _ => {} } } true } /// Apply filter to a list of items #[tracing::instrument(skip_all)] pub fn apply(&self, items: Vec) -> Vec { let cache = self.compile_regexes(); items.into_iter().filter(|item| self.matches_with_cache(item, &cache)).collect() } /// Apply only the tag filter to a list of items. /// /// Used when search, source, unread, and starred filters have already /// been pushed into the SQL query, but tag filtering still needs to /// happen in-memory. #[tracing::instrument(skip_all)] pub fn apply_tags_only(&self, items: Vec) -> Vec { if self.tags.is_empty() { return items; } items .into_iter() .filter(|item| { let item_tags: Vec = item.meta.tags.iter().map(|t| t.to_string()).collect(); self.tags.iter().any(|t| item_tags.contains(t)) }) .collect() } } #[cfg(test)] mod tests { use super::*; use bb_interface::{BiteDisplay, FeedItemContent, FeedItemId, FeedItemMeta}; fn make_item(source: &str, id: &str, published_at: i64) -> FeedItem { FeedItem::new( FeedItemId::new(source, id), BiteDisplay::new("author", format!("text for {}", id)), FeedItemContent::new(), FeedItemMeta::new(source, published_at), ) } fn make_item_full( source: &str, id: &str, published_at: i64, score: Option, is_read: bool, is_starred: bool, ) -> FeedItem { let mut item = make_item(source, id, published_at); item.meta.score = score; item.is_read = is_read; item.is_starred = is_starred; item } // --- OrderBy::from_str_loose --- #[test] fn from_str_loose_score() { assert_eq!(OrderBy::from_str_loose("score"), OrderBy::Score); } #[test] fn from_str_loose_unread() { assert_eq!(OrderBy::from_str_loose("unread"), OrderBy::UnreadFirst); } #[test] fn from_str_loose_starred() { assert_eq!(OrderBy::from_str_loose("starred"), OrderBy::StarredFirst); } #[test] fn from_str_loose_chrono() { assert_eq!(OrderBy::from_str_loose("chrono"), OrderBy::Chronological); } #[test] fn from_str_loose_unknown_defaults_to_chrono() { assert_eq!(OrderBy::from_str_loose("gibberish"), OrderBy::Chronological); assert_eq!(OrderBy::from_str_loose(""), OrderBy::Chronological); } // --- OrderBy::apply --- #[test] fn apply_chronological_sorts_newest_first() { let mut items = vec![ make_item("s", "old", 100), make_item("s", "new", 300), make_item("s", "mid", 200), ]; OrderBy::Chronological.apply(&mut items); assert_eq!(items[0].id.item_id, "new"); assert_eq!(items[1].id.item_id, "mid"); assert_eq!(items[2].id.item_id, "old"); } #[test] fn apply_score_sorts_highest_first() { let mut items = vec![ make_item_full("s", "low", 100, Some(10), false, false), make_item_full("s", "high", 100, Some(100), false, false), make_item_full("s", "none", 100, None, false, false), ]; OrderBy::Score.apply(&mut items); assert_eq!(items[0].id.item_id, "high"); assert_eq!(items[1].id.item_id, "low"); assert_eq!(items[2].id.item_id, "none"); } #[test] fn apply_unread_first_groups_by_read_state() { let mut items = vec![ make_item_full("s", "read1", 300, None, true, false), make_item_full("s", "unread1", 200, None, false, false), make_item_full("s", "read2", 100, None, true, false), make_item_full("s", "unread2", 400, None, false, false), ]; OrderBy::UnreadFirst.apply(&mut items); // Unread items (is_read=false) come first, then read items. // Within each group, sorted by published_at descending. assert_eq!(items[0].id.item_id, "unread2"); assert_eq!(items[1].id.item_id, "unread1"); assert_eq!(items[2].id.item_id, "read1"); assert_eq!(items[3].id.item_id, "read2"); } #[test] fn apply_starred_first() { let mut items = vec![ make_item_full("s", "normal", 200, None, false, false), make_item_full("s", "starred", 100, None, false, true), ]; OrderBy::StarredFirst.apply(&mut items); assert!(items[0].is_starred); } // --- FeedFilter::matches --- #[test] fn filter_matches_all_by_default() { let filter = FeedFilter::new(); let item = make_item("src", "1", 100); assert!(filter.matches(&item)); } #[test] fn filter_source_match() { let filter = FeedFilter::new().source("rss"); let matching = make_item("rss", "1", 100); let non_matching = make_item("hn", "2", 100); assert!(filter.matches(&matching)); assert!(!filter.matches(&non_matching)); } #[test] fn filter_unread_only() { let filter = FeedFilter::new().unread_only(); let unread = make_item_full("s", "1", 100, None, false, false); let read = make_item_full("s", "2", 100, None, true, false); assert!(filter.matches(&unread)); assert!(!filter.matches(&read)); } #[test] fn filter_starred_only() { let filter = FeedFilter::new().starred_only(); let starred = make_item_full("s", "1", 100, None, false, true); let not_starred = make_item_full("s", "2", 100, None, false, false); assert!(filter.matches(&starred)); assert!(!filter.matches(¬_starred)); } #[test] fn filter_search_matches_bite_text() { let filter = FeedFilter::new().search("text for abc"); let item = make_item("s", "abc", 100); assert!(filter.matches(&item)); } #[test] fn filter_search_matches_title() { let filter = FeedFilter::new().search("hello"); let mut item = make_item("s", "1", 100); item.content.title = Some("Hello World".to_string()); assert!(filter.matches(&item)); } #[test] fn filter_search_no_match() { let filter = FeedFilter::new().search("zzzzz"); let item = make_item("s", "1", 100); assert!(!filter.matches(&item)); } #[test] fn filter_tag_match() { let filter = FeedFilter::new().with_tag("rust"); let mut item = make_item("s", "1", 100); item.meta.tags = vec!["rust".to_string(), "programming".to_string()]; assert!(filter.matches(&item)); } #[test] fn filter_tag_no_match() { let filter = FeedFilter::new().with_tag("python"); let mut item = make_item("s", "1", 100); item.meta.tags = vec!["rust".to_string()]; assert!(!filter.matches(&item)); } #[test] fn filter_combined() { let filter = FeedFilter::new().source("rss").unread_only(); let good = make_item_full("rss", "1", 100, None, false, false); let wrong_source = make_item_full("hn", "2", 100, None, false, false); let is_read = make_item_full("rss", "3", 100, None, true, false); assert!(filter.matches(&good)); assert!(!filter.matches(&wrong_source)); assert!(!filter.matches(&is_read)); } #[test] fn filter_apply_returns_matching_items() { let filter = FeedFilter::new().source("rss"); let items = vec![ make_item("rss", "1", 100), make_item("hn", "2", 200), make_item("rss", "3", 300), ]; let result = filter.apply(items); assert_eq!(result.len(), 2); assert!(result.iter().all(|i| i.id.source == "rss")); } // --- FeedFilter::apply_tags_only --- #[test] fn apply_tags_only_empty_filter_returns_all() { let filter = FeedFilter::new(); let items = vec![make_item("s", "1", 100), make_item("s", "2", 200)]; let result = filter.apply_tags_only(items); assert_eq!(result.len(), 2); } #[test] fn apply_tags_only_matching_tag() { let filter = FeedFilter::new().with_tag("rust"); let mut item = make_item("s", "1", 100); item.meta.tags = vec!["rust".to_string()]; let result = filter.apply_tags_only(vec![item]); assert_eq!(result.len(), 1); } #[test] fn apply_tags_only_no_match() { let filter = FeedFilter::new().with_tag("rust"); let mut item = make_item("s", "1", 100); item.meta.tags = vec!["go".to_string()]; let result = filter.apply_tags_only(vec![item]); assert_eq!(result.len(), 0); } #[test] fn apply_tags_only_any_match() { let filter = FeedFilter::new().with_tag("rust").with_tag("go"); let mut item = make_item("s", "1", 100); item.meta.tags = vec!["rust".to_string()]; let result = filter.apply_tags_only(vec![item]); assert_eq!(result.len(), 1, "OR logic: item with 'rust' matches filter ['rust', 'go']"); } #[test] fn apply_tags_only_multiple_item_tags() { let filter = FeedFilter::new().with_tag("news"); let mut item = make_item("s", "1", 100); item.meta.tags = vec!["rust".to_string(), "news".to_string()]; let result = filter.apply_tags_only(vec![item]); assert_eq!(result.len(), 1); } #[test] fn apply_tags_only_mixed_items() { let filter = FeedFilter::new().with_tag("rust"); let mut item1 = make_item("s", "1", 100); item1.meta.tags = vec!["rust".to_string()]; let mut item2 = make_item("s", "2", 200); item2.meta.tags = vec!["python".to_string()]; let mut item3 = make_item("s", "3", 300); item3.meta.tags = vec!["rust".to_string(), "wasm".to_string()]; let result = filter.apply_tags_only(vec![item1, item2, item3]); assert_eq!(result.len(), 2); assert!(result.iter().all(|i| i.meta.tags.contains(&"rust".to_string()))); } // --- FeedFilter::matches (tag-related paths) --- #[test] fn filter_matches_with_tag() { let filter = FeedFilter::new().with_tag("release"); let mut item = make_item("s", "1", 100); item.meta.tags = vec!["release".to_string(), "v2".to_string()]; assert!(filter.matches(&item)); } #[test] fn filter_rejects_without_tag() { let filter = FeedFilter::new().with_tag("release"); let mut item = make_item("s", "1", 100); item.meta.tags = vec!["discussion".to_string()]; assert!(!filter.matches(&item)); } // --- FeedFilter::from_conditions --- fn cond(field: &str, operator: &str, value: &str) -> QueryCondition { QueryCondition { field: field.to_string(), operator: operator.to_string(), value: value.to_string(), } } #[test] fn from_conditions_source_equals_sets_fast_path() { let filter = FeedFilter::from_conditions(vec![cond("source", "equals", "rss")]); assert_eq!(filter.source, Some("rss".to_string())); } #[test] fn from_conditions_starred_sets_fast_path() { let filter = FeedFilter::from_conditions(vec![cond("starred", "is", "true")]); assert!(filter.starred_only); } #[test] fn from_conditions_unread_sets_fast_path() { let filter = FeedFilter::from_conditions(vec![cond("unread", "is", "true")]); assert!(filter.unread_only); } #[test] fn from_conditions_tag_sets_fast_path() { let filter = FeedFilter::from_conditions(vec![cond("tag", "equals", "rust")]); assert_eq!(filter.tags, vec!["rust".to_string()]); } #[test] fn from_conditions_stores_all_conditions() { let conditions = vec![ cond("source", "equals", "rss"), cond("title", "contains", "rust"), ]; let filter = FeedFilter::from_conditions(conditions); assert_eq!(filter.conditions.len(), 2); } #[test] fn from_conditions_starred_false_no_fast_path() { let filter = FeedFilter::from_conditions(vec![cond("starred", "is", "false")]); assert!(!filter.starred_only); } // --- Condition matching (title/author/body) --- #[test] fn condition_title_contains_matches() { let filter = FeedFilter::from_conditions(vec![cond("title", "contains", "rust")]); let mut item = make_item("s", "1", 100); item.content.title = Some("Learning Rust today".to_string()); assert!(filter.matches(&item)); } #[test] fn condition_title_contains_case_insensitive() { let filter = FeedFilter::from_conditions(vec![cond("title", "contains", "RUST")]); let mut item = make_item("s", "1", 100); item.content.title = Some("Learning rust today".to_string()); assert!(filter.matches(&item)); } #[test] fn condition_title_contains_no_match() { let filter = FeedFilter::from_conditions(vec![cond("title", "contains", "python")]); let mut item = make_item("s", "1", 100); item.content.title = Some("Learning Rust today".to_string()); assert!(!filter.matches(&item)); } #[test] fn condition_title_not_contains() { let filter = FeedFilter::from_conditions(vec![cond("title", "not_contains", "python")]); let mut item = make_item("s", "1", 100); item.content.title = Some("Learning Rust today".to_string()); assert!(filter.matches(&item)); } #[test] fn condition_title_not_contains_rejects() { let filter = FeedFilter::from_conditions(vec![cond("title", "not_contains", "rust")]); let mut item = make_item("s", "1", 100); item.content.title = Some("Learning Rust today".to_string()); assert!(!filter.matches(&item)); } #[test] fn condition_title_equals() { let filter = FeedFilter::from_conditions(vec![cond("title", "equals", "Hello World")]); let mut item = make_item("s", "1", 100); item.content.title = Some("hello world".to_string()); assert!(filter.matches(&item), "equals should be case-insensitive"); } #[test] fn condition_title_matches_regex() { let filter = FeedFilter::from_conditions(vec![cond("title", "matches_regex", r"^Rust \d+")]); let mut item = make_item("s", "1", 100); item.content.title = Some("Rust 2024 edition".to_string()); assert!(filter.matches(&item)); } #[test] fn condition_title_matches_regex_no_match() { let filter = FeedFilter::from_conditions(vec![cond("title", "matches_regex", r"^Python \d+")]); let mut item = make_item("s", "1", 100); item.content.title = Some("Rust 2024 edition".to_string()); assert!(!filter.matches(&item)); } #[test] fn condition_invalid_regex_skipped() { let filter = FeedFilter::from_conditions(vec![cond("title", "matches_regex", r"[invalid")]); let mut item = make_item("s", "1", 100); item.content.title = Some("anything".to_string()); // Invalid regex should be skipped (not reject the item) assert!(filter.matches(&item)); } #[test] fn condition_author_contains() { let filter = FeedFilter::from_conditions(vec![cond("author", "contains", "alice")]); let mut item = make_item("s", "1", 100); item.bite.author = "Alice Smith".to_string(); assert!(filter.matches(&item)); } #[test] fn condition_body_contains() { let filter = FeedFilter::from_conditions(vec![cond("body", "contains", "important")]); let mut item = make_item("s", "1", 100); item.content.body = Some("This is an important article".to_string()); assert!(filter.matches(&item)); } #[test] fn condition_body_not_contains() { let filter = FeedFilter::from_conditions(vec![cond("body", "not_contains", "spam")]); let mut item = make_item("s", "1", 100); item.content.body = Some("This is a good article".to_string()); assert!(filter.matches(&item)); } #[test] fn conditions_and_logic() { let filter = FeedFilter::from_conditions(vec![ cond("title", "contains", "rust"), cond("author", "contains", "alice"), ]); let mut item = make_item("s", "1", 100); item.content.title = Some("Rust tips".to_string()); item.bite.author = "Alice".to_string(); assert!(filter.matches(&item)); let mut wrong_author = make_item("s", "2", 100); wrong_author.content.title = Some("Rust tips".to_string()); wrong_author.bite.author = "Bob".to_string(); assert!(!filter.matches(&wrong_author)); } #[test] fn empty_conditions_matches_all() { let filter = FeedFilter::from_conditions(vec![]); let item = make_item("s", "1", 100); assert!(filter.matches(&item)); } }