Skip to main content

max / balanced_breakfast

7.9 KB · 268 lines History Blame Raw
1 //! Feed generator for aggregating, filtering, and ordering items.
2 //!
3 //! Reads items from the database, applies user-defined filters and
4 //! ordering, and returns paginated results for display.
5
6 mod items;
7 mod query;
8
9 use bb_db::Database;
10 use thiserror::Error;
11
12 use crate::{FeedFilter, OrderBy};
13
14 // items and query add impl blocks to FeedGenerator (no new public types to re-export).
15
16 #[derive(Error, Debug)]
17 /// Errors that can occur during feed generation
18 pub enum FeedError {
19 #[error("Database error: {0}")]
20 Database(#[from] sqlx::Error),
21 }
22
23 #[derive(Debug)]
24 /// A page of feed items with a flag indicating whether more pages exist
25 pub struct PaginatedItems {
26 /// The items for this page (at most `page_size` items).
27 pub items: Vec<bb_interface::FeedItem>,
28 /// Whether additional pages of results are available beyond this one.
29 pub has_more: bool,
30 }
31
32 #[derive(Debug, Clone)]
33 /// Source info with item counts
34 pub struct SourceInfo {
35 /// Busser/plugin identifier.
36 pub id: String,
37 /// Human-readable source name.
38 pub name: String,
39 /// Total number of items from this source.
40 pub total_count: i64,
41 /// Number of unread items from this source.
42 pub unread_count: i64,
43 /// User-assigned tags for this feed.
44 pub tags: Vec<String>,
45 /// Number of consecutive fetch failures (0 = healthy).
46 pub consecutive_failures: i64,
47 /// Error message from the last failed fetch.
48 pub last_error: Option<String>,
49 /// Whether the circuit breaker has tripped for this feed.
50 pub circuit_broken: bool,
51 }
52
53 /// The feed generator aggregates and orders items from the database
54 pub struct FeedGenerator {
55 db: Database,
56 order_by: OrderBy,
57 filter: FeedFilter,
58 page_size: i64,
59 }
60
61 impl FeedGenerator {
62 #[tracing::instrument(skip_all)]
63 pub fn new(db: Database) -> Self {
64 Self {
65 db,
66 order_by: OrderBy::default(),
67 filter: FeedFilter::default(),
68 page_size: 50,
69 }
70 }
71
72 #[tracing::instrument(skip_all)]
73 pub fn with_order(mut self, order: OrderBy) -> Self {
74 self.order_by = order;
75 self
76 }
77
78 #[tracing::instrument(skip_all)]
79 pub fn with_filter(mut self, filter: FeedFilter) -> Self {
80 self.filter = filter;
81 self
82 }
83
84 #[tracing::instrument(skip_all)]
85 pub fn with_page_size(mut self, size: i64) -> Self {
86 self.page_size = size;
87 self
88 }
89
90 #[tracing::instrument(skip_all)]
91 pub fn set_order(&mut self, order: OrderBy) {
92 self.order_by = order;
93 }
94
95 #[tracing::instrument(skip_all)]
96 pub fn set_filter(&mut self, filter: FeedFilter) {
97 self.filter = filter;
98 }
99
100 #[tracing::instrument(skip_all)]
101 pub fn order(&self) -> OrderBy {
102 self.order_by
103 }
104
105 #[tracing::instrument(skip_all)]
106 pub fn filter(&self) -> &FeedFilter {
107 &self.filter
108 }
109 }
110
111 #[cfg(test)]
112 mod tests {
113 use super::*;
114 use bb_db::{BusserId, CreateFeed, CreateFeedItem};
115 use chrono::{Duration, Utc};
116
117 pub(crate) async fn test_db() -> Database {
118 let db = Database::connect("sqlite::memory:").await.unwrap();
119 db.migrate().await.unwrap();
120 db
121 }
122
123 pub(crate) async fn seed_feed(db: &Database, busser_id: &str, name: &str) -> bb_db::DbFeed {
124 db.feeds()
125 .create(CreateFeed {
126 busser_id: BusserId::new(busser_id),
127 name: name.to_string(),
128 config: serde_json::json!({}),
129 })
130 .await
131 .unwrap()
132 }
133
134 pub(crate) async fn seed_item(
135 db: &Database,
136 feed: &bb_db::DbFeed,
137 external_id: &str,
138 hours_ago: i64,
139 ) -> bb_db::DbFeedItem {
140 db.items()
141 .upsert(CreateFeedItem {
142 external_id: external_id.to_string(),
143 feed_id: feed.id,
144 busser_id: feed.busser_id.clone(),
145 bite_author: "author".to_string(),
146 bite_text: format!("Item {external_id}"),
147 bite_secondary: None,
148 bite_indicator: None,
149 title: Some(format!("Title {external_id}")),
150 body: None,
151 url: None,
152 media: vec![],
153 published_at: Utc::now() - Duration::hours(hours_ago),
154 source_name: "test".to_string(),
155 score: None,
156 tags: vec![],
157 actions: vec![],
158 })
159 .await
160 .unwrap()
161 }
162
163 /// Seed an item with a score for ordering tests.
164 pub(crate) async fn seed_scored_item(
165 db: &Database,
166 feed: &bb_db::DbFeed,
167 external_id: &str,
168 hours_ago: i64,
169 score: Option<i64>,
170 ) -> bb_db::DbFeedItem {
171 db.items()
172 .upsert(CreateFeedItem {
173 external_id: external_id.to_string(),
174 feed_id: feed.id,
175 busser_id: feed.busser_id.clone(),
176 bite_author: "author".to_string(),
177 bite_text: format!("Item {external_id}"),
178 bite_secondary: None,
179 bite_indicator: None,
180 title: Some(format!("Title {external_id}")),
181 body: None,
182 url: None,
183 media: vec![],
184 published_at: Utc::now() - Duration::hours(hours_ago),
185 source_name: "test".to_string(),
186 score,
187 tags: vec![],
188 actions: vec![],
189 })
190 .await
191 .unwrap()
192 }
193
194 /// Seed an item with item-level tags.
195 pub(crate) async fn seed_tagged_item(
196 db: &Database,
197 feed: &bb_db::DbFeed,
198 external_id: &str,
199 hours_ago: i64,
200 tags: Vec<String>,
201 ) -> bb_db::DbFeedItem {
202 db.items()
203 .upsert(CreateFeedItem {
204 external_id: external_id.to_string(),
205 feed_id: feed.id,
206 busser_id: feed.busser_id.clone(),
207 bite_author: "author".to_string(),
208 bite_text: format!("Item {external_id}"),
209 bite_secondary: None,
210 bite_indicator: None,
211 title: Some(format!("Title {external_id}")),
212 body: Some(format!("Body of {external_id}")),
213 url: None,
214 media: vec![],
215 published_at: Utc::now() - Duration::hours(hours_ago),
216 source_name: "test".to_string(),
217 score: None,
218 tags,
219 actions: vec![],
220 })
221 .await
222 .unwrap()
223 }
224
225 // ── Constructor & builders ────────────────────────────────────
226
227 #[tokio::test]
228 async fn new_has_default_order_and_filter() {
229 let db = test_db().await;
230 let fg = FeedGenerator::new(db);
231 assert_eq!(fg.order(), OrderBy::default());
232 assert_eq!(fg.filter().source, None);
233 assert!(!fg.filter().unread_only);
234 assert!(!fg.filter().starred_only);
235 }
236
237 #[tokio::test]
238 async fn with_order_changes_order() {
239 let db = test_db().await;
240 let fg = FeedGenerator::new(db).with_order(OrderBy::Score);
241 assert_eq!(fg.order(), OrderBy::Score);
242 }
243
244 #[tokio::test]
245 async fn with_filter_changes_filter() {
246 let db = test_db().await;
247 let filter = FeedFilter::new().source("rss");
248 let fg = FeedGenerator::new(db).with_filter(filter);
249 assert_eq!(fg.filter().source.as_deref(), Some("rss"));
250 }
251
252 #[tokio::test]
253 async fn set_order_mutates() {
254 let db = test_db().await;
255 let mut fg = FeedGenerator::new(db);
256 fg.set_order(OrderBy::Score);
257 assert_eq!(fg.order(), OrderBy::Score);
258 }
259
260 #[tokio::test]
261 async fn set_filter_mutates() {
262 let db = test_db().await;
263 let mut fg = FeedGenerator::new(db);
264 fg.set_filter(FeedFilter::new().unread_only());
265 assert!(fg.filter().unread_only);
266 }
267 }
268