//! Feed generator for aggregating, filtering, and ordering items. //! //! Reads items from the database, applies user-defined filters and //! ordering, and returns paginated results for display. mod items; mod query; use bb_db::Database; use thiserror::Error; use crate::{FeedFilter, OrderBy}; // items and query add impl blocks to FeedGenerator (no new public types to re-export). #[derive(Error, Debug)] /// Errors that can occur during feed generation pub enum FeedError { #[error("Database error: {0}")] Database(#[from] sqlx::Error), } #[derive(Debug)] /// A page of feed items with a flag indicating whether more pages exist pub struct PaginatedItems { /// The items for this page (at most `page_size` items). pub items: Vec, /// Whether additional pages of results are available beyond this one. pub has_more: bool, } #[derive(Debug, Clone)] /// Source info with item counts pub struct SourceInfo { /// Busser/plugin identifier. pub id: String, /// Human-readable source name. pub name: String, /// Total number of items from this source. pub total_count: i64, /// Number of unread items from this source. pub unread_count: i64, /// User-assigned tags for this feed. pub tags: Vec, /// Number of consecutive fetch failures (0 = healthy). pub consecutive_failures: i64, /// Error message from the last failed fetch. pub last_error: Option, /// Whether the circuit breaker has tripped for this feed. pub circuit_broken: bool, } /// The feed generator aggregates and orders items from the database pub struct FeedGenerator { db: Database, order_by: OrderBy, filter: FeedFilter, page_size: i64, } impl FeedGenerator { #[tracing::instrument(skip_all)] pub fn new(db: Database) -> Self { Self { db, order_by: OrderBy::default(), filter: FeedFilter::default(), page_size: 50, } } #[tracing::instrument(skip_all)] pub fn with_order(mut self, order: OrderBy) -> Self { self.order_by = order; self } #[tracing::instrument(skip_all)] pub fn with_filter(mut self, filter: FeedFilter) -> Self { self.filter = filter; self } #[tracing::instrument(skip_all)] pub fn with_page_size(mut self, size: i64) -> Self { self.page_size = size; self } #[tracing::instrument(skip_all)] pub fn set_order(&mut self, order: OrderBy) { self.order_by = order; } #[tracing::instrument(skip_all)] pub fn set_filter(&mut self, filter: FeedFilter) { self.filter = filter; } #[tracing::instrument(skip_all)] pub fn order(&self) -> OrderBy { self.order_by } #[tracing::instrument(skip_all)] pub fn filter(&self) -> &FeedFilter { &self.filter } } #[cfg(test)] mod tests { use super::*; use bb_db::{BusserId, CreateFeed, CreateFeedItem}; use chrono::{Duration, Utc}; pub(crate) async fn test_db() -> Database { let db = Database::connect("sqlite::memory:").await.unwrap(); db.migrate().await.unwrap(); db } pub(crate) async fn seed_feed(db: &Database, busser_id: &str, name: &str) -> bb_db::DbFeed { db.feeds() .create(CreateFeed { busser_id: BusserId::new(busser_id), name: name.to_string(), config: serde_json::json!({}), }) .await .unwrap() } pub(crate) async fn seed_item( db: &Database, feed: &bb_db::DbFeed, external_id: &str, hours_ago: i64, ) -> bb_db::DbFeedItem { db.items() .upsert(CreateFeedItem { external_id: external_id.to_string(), feed_id: feed.id, busser_id: feed.busser_id.clone(), bite_author: "author".to_string(), bite_text: format!("Item {external_id}"), bite_secondary: None, bite_indicator: None, title: Some(format!("Title {external_id}")), body: None, url: None, media: vec![], published_at: Utc::now() - Duration::hours(hours_ago), source_name: "test".to_string(), score: None, tags: vec![], actions: vec![], }) .await .unwrap() } /// Seed an item with a score for ordering tests. pub(crate) async fn seed_scored_item( db: &Database, feed: &bb_db::DbFeed, external_id: &str, hours_ago: i64, score: Option, ) -> bb_db::DbFeedItem { db.items() .upsert(CreateFeedItem { external_id: external_id.to_string(), feed_id: feed.id, busser_id: feed.busser_id.clone(), bite_author: "author".to_string(), bite_text: format!("Item {external_id}"), bite_secondary: None, bite_indicator: None, title: Some(format!("Title {external_id}")), body: None, url: None, media: vec![], published_at: Utc::now() - Duration::hours(hours_ago), source_name: "test".to_string(), score, tags: vec![], actions: vec![], }) .await .unwrap() } /// Seed an item with item-level tags. pub(crate) async fn seed_tagged_item( db: &Database, feed: &bb_db::DbFeed, external_id: &str, hours_ago: i64, tags: Vec, ) -> bb_db::DbFeedItem { db.items() .upsert(CreateFeedItem { external_id: external_id.to_string(), feed_id: feed.id, busser_id: feed.busser_id.clone(), bite_author: "author".to_string(), bite_text: format!("Item {external_id}"), bite_secondary: None, bite_indicator: None, title: Some(format!("Title {external_id}")), body: Some(format!("Body of {external_id}")), url: None, media: vec![], published_at: Utc::now() - Duration::hours(hours_ago), source_name: "test".to_string(), score: None, tags, actions: vec![], }) .await .unwrap() } // ── Constructor & builders ──────────────────────────────────── #[tokio::test] async fn new_has_default_order_and_filter() { let db = test_db().await; let fg = FeedGenerator::new(db); assert_eq!(fg.order(), OrderBy::default()); assert_eq!(fg.filter().source, None); assert!(!fg.filter().unread_only); assert!(!fg.filter().starred_only); } #[tokio::test] async fn with_order_changes_order() { let db = test_db().await; let fg = FeedGenerator::new(db).with_order(OrderBy::Score); assert_eq!(fg.order(), OrderBy::Score); } #[tokio::test] async fn with_filter_changes_filter() { let db = test_db().await; let filter = FeedFilter::new().source("rss"); let fg = FeedGenerator::new(db).with_filter(filter); assert_eq!(fg.filter().source.as_deref(), Some("rss")); } #[tokio::test] async fn set_order_mutates() { let db = test_db().await; let mut fg = FeedGenerator::new(db); fg.set_order(OrderBy::Score); assert_eq!(fg.order(), OrderBy::Score); } #[tokio::test] async fn set_filter_mutates() { let db = test_db().await; let mut fg = FeedGenerator::new(db); fg.set_filter(FeedFilter::new().unread_only()); assert!(fg.filter().unread_only); } }