Skip to main content

max / balanced_breakfast

Apply rustfmt across the crate Formatting only, no behavior change. cargo check --all-targets passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-21 23:46 UTC
Commit: 802b36187b9c9fde9c6aa24ed57148f71833751d
Parent: bf0fcf6
57 files changed, +1526 insertions, -900 deletions
@@ -9,8 +9,8 @@
9 9
10 10 use aes_gcm::aead::{Aead, OsRng};
11 11 use aes_gcm::{AeadCore, Aes256Gcm, KeyInit};
12 - use base64::engine::general_purpose::STANDARD as BASE64;
13 12 use base64::Engine;
13 + use base64::engine::general_purpose::STANDARD as BASE64;
14 14 use bb_interface::{ConfigFieldType, ConfigSchema};
15 15 use rand::Rng;
16 16 use std::path::Path;
@@ -86,9 +86,14 @@ pub fn load_or_create_key_from_keychain(file_path: &Path) -> Result<EncryptionKe
86 86 if let Ok(entry) = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_KEY) {
87 87 match entry.get_password() {
88 88 Ok(b64) => {
89 - let bytes = BASE64.decode(&b64).map_err(|e| format!("Keychain key decode failed: {e}"))?;
89 + let bytes = BASE64
90 + .decode(&b64)
91 + .map_err(|e| format!("Keychain key decode failed: {e}"))?;
90 92 if bytes.len() != 32 {
91 - return Err(format!("Keychain key wrong size: {} (expected 32)", bytes.len()));
93 + return Err(format!(
94 + "Keychain key wrong size: {} (expected 32)",
95 + bytes.len()
96 + ));
92 97 }
93 98 let mut key = Zeroizing::new([0u8; 32]);
94 99 key.copy_from_slice(&bytes);
@@ -109,7 +114,9 @@ pub fn load_or_create_key_from_keychain(file_path: &Path) -> Result<EncryptionKe
109 114 }
110 115 tracing::info!("Migrated encryption key from file to keychain");
111 116 } else {
112 - tracing::warn!("Keychain read-back mismatch, keeping file fallback");
117 + tracing::warn!(
118 + "Keychain read-back mismatch, keeping file fallback"
119 + );
113 120 }
114 121 } else {
115 122 tracing::warn!("Keychain read-back failed, keeping file fallback");
@@ -21,5 +21,5 @@ pub mod url_cleaner;
21 21 pub use orchestrator::*;
22 22 pub use plugin_manager::*;
23 23 pub use rhai_plugin::{
24 - classify_error, ReaderResult, RhaiPlugin, RhaiPluginError, RhaiPluginManager,
24 + ReaderResult, RhaiPlugin, RhaiPluginError, RhaiPluginManager, classify_error,
25 25 };
@@ -13,9 +13,9 @@ use thiserror::Error;
13 13 use tokio::sync::RwLock;
14 14 use tracing::{debug, error, info, instrument};
15 15
16 + use crate::PluginManager;
16 17 use crate::rhai_plugin::classify_error;
17 18 use crate::url_cleaner;
18 - use crate::PluginManager;
19 19
20 20 #[derive(Error, Debug)]
21 21 /// Errors from orchestrator operations (database, plugin, or feed failures)
@@ -87,9 +87,10 @@ impl Orchestrator {
87 87 /// Run database migrations
88 88 #[instrument(skip_all)]
89 89 pub async fn migrate(&self) -> Result<(), OrchestratorError> {
90 - self.db.migrate().await.map_err(|e| {
91 - OrchestratorError::Config(format!("Migration failed: {}", e))
92 - })?;
90 + self.db
91 + .migrate()
92 + .await
93 + .map_err(|e| OrchestratorError::Config(format!("Migration failed: {}", e)))?;
93 94 info!("Database migrations complete");
94 95 Ok(())
95 96 }
@@ -105,10 +106,7 @@ impl Orchestrator {
105 106
106 107 /// Initialize a plugin with config from database
107 108 #[instrument(skip_all)]
108 - pub async fn init_plugin_from_db(
109 - &self,
110 - plugin_id: &str,
111 - ) -> Result<(), OrchestratorError> {
109 + pub async fn init_plugin_from_db(&self, plugin_id: &str) -> Result<(), OrchestratorError> {
112 110 // Get feeds for this busser from database
113 111 let feeds = self.db.feeds().get_by_busser(plugin_id).await?;
114 112
@@ -188,10 +186,7 @@ impl Orchestrator {
188 186 /// error is recorded against the feed (and may trip the circuit breaker)
189 187 /// before the error is propagated.
190 188 #[instrument(skip_all)]
191 - pub async fn fetch_plugin(
192 - &self,
193 - plugin_id: &str,
194 - ) -> Result<usize, OrchestratorError> {
189 + pub async fn fetch_plugin(&self, plugin_id: &str) -> Result<usize, OrchestratorError> {
195 190 // Get all feed IDs for this busser (usually one, but can be multiple)
196 191 let feeds = self.db.feeds().get_by_busser(plugin_id).await?;
197 192 let feed_ids: Vec<bb_db::FeedId> = feeds.iter().map(|f| f.id).collect();
@@ -517,11 +512,7 @@ mod tests {
517 512 db.items().upsert(item).await.unwrap();
518 513
519 514 // Verify the item is persisted.
520 - let page = db
521 - .items()
522 - .list_by_feed(feed_id, 10, 0)
523 - .await
524 - .unwrap();
515 + let page = db.items().list_by_feed(feed_id, 10, 0).await.unwrap();
525 516 assert_eq!(page.len(), 1);
526 517 assert_eq!(page[0].bite_text, "Hello world");
527 518 }
@@ -569,11 +560,7 @@ mod tests {
569 560 db.items().upsert(make_item()).await.unwrap();
570 561
571 562 // Verify only one copy exists.
572 - let page = db
573 - .items()
574 - .list_by_feed(feed_id, 10, 0)
575 - .await
576 - .unwrap();
563 + let page = db.items().list_by_feed(feed_id, 10, 0).await.unwrap();
577 564 assert_eq!(page.len(), 1);
578 565 }
579 566 }
@@ -13,7 +13,7 @@ use tracing::{debug, error, info, warn};
13 13
14 14 use bb_interface::StructuredError;
15 15
16 - use crate::rhai_plugin::{classify_error, RhaiPluginError, RhaiPluginManager};
16 + use crate::rhai_plugin::{RhaiPluginError, RhaiPluginManager, classify_error};
17 17
18 18 #[derive(Error, Debug)]
19 19 /// Errors from plugin loading, initialization, and fetching
@@ -224,15 +224,15 @@ impl PluginManager {
224 224 /// Get plugin configuration schema
225 225 #[tracing::instrument(skip_all)]
226 226 pub fn get_config_schema(&self, plugin_id: &str) -> Option<ConfigSchema> {
227 - self.rhai_manager.get(plugin_id).and_then(|p| {
228 - match p.config_schema() {
227 + self.rhai_manager
228 + .get(plugin_id)
229 + .and_then(|p| match p.config_schema() {
229 230 Ok(s) => Some(s),
230 231 Err(e) => {
231 232 warn!(error = %e, %plugin_id, "Plugin config_schema() failed");
232 233 None
233 234 }
234 - }
235 - })
235 + })
236 236 }
237 237 }
238 238
@@ -22,10 +22,7 @@ pub(crate) fn busser_config_to_dynamic(config: &BusserConfig) -> Dynamic {
22 22 /// Convert Dynamic to ConfigSchema.
23 23 pub(crate) fn dynamic_to_config_schema(val: Dynamic) -> Result<ConfigSchema, RhaiPluginError> {
24 24 let map = val.try_cast::<Map>().ok_or_else(|| {
25 - RhaiPluginError::InvalidReturnType(
26 - "config_schema".into(),
27 - "expected object/map".into(),
28 - )
25 + RhaiPluginError::InvalidReturnType("config_schema".into(), "expected object/map".into())
29 26 })?;
30 27
31 28 let description = map
@@ -2,8 +2,7 @@ use super::*;
2 2
3 3 /// Parse XML into a simplified Dynamic structure.
4 4 pub(crate) fn parse_xml_to_dynamic(xml_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
5 - let doc =
6 - roxmltree::Document::parse(xml_str).map_err(|e| format!("XML parse error: {}", e))?;
5 + let doc = roxmltree::Document::parse(xml_str).map_err(|e| format!("XML parse error: {}", e))?;
7 6
8 7 fn element_to_dynamic(node: roxmltree::Node) -> Dynamic {
9 8 let mut map = Map::new();
@@ -50,8 +49,7 @@ pub(crate) fn parse_xml_to_dynamic(xml_str: &str) -> Result<Dynamic, Box<rhai::E
50 49
51 50 /// Parse RSS/Atom feed XML into a feed-friendly structure.
52 51 pub(crate) fn parse_feed_xml(xml_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
53 - let doc =
54 - roxmltree::Document::parse(xml_str).map_err(|e| format!("XML parse error: {}", e))?;
52 + let doc = roxmltree::Document::parse(xml_str).map_err(|e| format!("XML parse error: {}", e))?;
55 53 let root = doc.root_element();
56 54
57 55 let mut feed = Map::new();
@@ -139,9 +137,7 @@ fn parse_atom_entry(node: &roxmltree::Node) -> Dynamic {
139 137 }
140 138 }
141 139 "author" => {
142 - if let Some(name_node) =
143 - child.children().find(|n| n.tag_name().name() == "name")
144 - {
140 + if let Some(name_node) = child.children().find(|n| n.tag_name().name() == "name") {
145 141 entry.insert("author".into(), get_text_content(&name_node).into());
146 142 }
147 143 }
@@ -37,15 +37,10 @@ pub(crate) fn parse_json_feed(json_str: &str) -> Result<Dynamic, Box<rhai::EvalA
37 37 let json: serde_json::Value =
38 38 serde_json::from_str(json_str).map_err(|e| format!("JSON parse error: {}", e))?;
39 39
40 - let obj = json
41 - .as_object()
42 - .ok_or("JSON Feed root must be an object")?;
40 + let obj = json.as_object().ok_or("JSON Feed root must be an object")?;
43 41
44 42 // Validate it's a JSON Feed by checking the version field
45 - let version = obj
46 - .get("version")
47 - .and_then(|v| v.as_str())
48 - .unwrap_or("");
43 + let version = obj.get("version").and_then(|v| v.as_str()).unwrap_or("");
49 44 if !version.starts_with("https://jsonfeed.org/version/") {
50 45 return Err(format!("Not a valid JSON Feed: version = {:?}", version).into());
51 46 }
@@ -87,10 +82,7 @@ pub(crate) fn parse_json_feed(json_str: &str) -> Result<Dynamic, Box<rhai::EvalA
87 82 }
88 83
89 84 // Published date
90 - if let Some(date_str) = item_obj
91 - .get("date_published")
92 - .and_then(|v| v.as_str())
93 - {
85 + if let Some(date_str) = item_obj.get("date_published").and_then(|v| v.as_str()) {
94 86 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(date_str) {
95 87 entry.insert("published".into(), dt.timestamp().into());
96 88 }
@@ -25,13 +25,13 @@ use rhai::{Dynamic, Map};
25 25
26 26 use super::RhaiPluginError;
27 27
28 - mod json;
29 - mod feed_parse;
30 28 mod domain;
29 + mod feed_parse;
30 + mod json;
31 31
32 - pub(crate) use json::*;
33 - pub(crate) use feed_parse::*;
34 32 pub(crate) use domain::*;
33 + pub(crate) use feed_parse::*;
34 + pub(crate) use json::*;
35 35
36 36 #[cfg(test)]
37 37 mod tests {
@@ -86,7 +86,11 @@ mod tests {
86 86 let d = json_to_dynamic(serde_json::json!({"key": "value"})).unwrap();
87 87 let map = d.try_cast::<Map>().unwrap();
88 88 assert_eq!(
89 - map.get("key").unwrap().clone().try_cast::<String>().unwrap(),
89 + map.get("key")
90 + .unwrap()
91 + .clone()
92 + .try_cast::<String>()
93 + .unwrap(),
90 94 "value"
91 95 );
92 96 }
@@ -111,20 +115,49 @@ mod tests {
111 115 let map = result.try_cast::<Map>().unwrap();
112 116
113 117 assert_eq!(
114 - map.get("title").unwrap().clone().try_cast::<String>().unwrap(),
118 + map.get("title")
119 + .unwrap()
120 + .clone()
121 + .try_cast::<String>()
122 + .unwrap(),
115 123 "My Feed"
116 124 );
117 125 assert_eq!(
118 - map.get("link").unwrap().clone().try_cast::<String>().unwrap(),
126 + map.get("link")
127 + .unwrap()
128 + .clone()
129 + .try_cast::<String>()
130 + .unwrap(),
119 131 "https://example.com"
120 132 );
121 133
122 - let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
134 + let entries = map
135 + .get("entries")
136 + .unwrap()
137 + .clone()
138 + .try_cast::<rhai::Array>()
139 + .unwrap();
123 140 assert_eq!(entries.len(), 1);
124 141
125 142 let entry = entries[0].clone().try_cast::<Map>().unwrap();
126 - assert_eq!(entry.get("id").unwrap().clone().try_cast::<String>().unwrap(), "urn:entry:1");
127 - assert_eq!(entry.get("title").unwrap().clone().try_cast::<String>().unwrap(), "First Post");
143 + assert_eq!(
144 + entry
145 + .get("id")
146 + .unwrap()
147 + .clone()
148 + .try_cast::<String>()
149 + .unwrap(),
150 + "urn:entry:1"
151 + );
152 + assert_eq!(
153 + entry
154 + .get("title")
155 + .unwrap()
156 + .clone()
157 + .try_cast::<String>()
158 + .unwrap(),
159 + "First Post"
160 + );
128 161 }
129 162
130 163 // --- parse_feed_xml (RSS) ---
@@ -150,17 +183,47 @@ mod tests {
150 183 let map = result.try_cast::<Map>().unwrap();
151 184
152 185 assert_eq!(
153 - map.get("title").unwrap().clone().try_cast::<String>().unwrap(),
186 + map.get("title")
187 + .unwrap()
188 + .clone()
189 + .try_cast::<String>()
190 + .unwrap(),
154 191 "RSS Feed"
155 192 );
156 193
157 - let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
194 + let entries = map
195 + .get("entries")
196 + .unwrap()
197 + .clone()
198 + .try_cast::<rhai::Array>()
199 + .unwrap();
158 200 assert_eq!(entries.len(), 1);
159 201
160 202 let item = entries[0].clone().try_cast::<Map>().unwrap();
161 - assert_eq!(item.get("id").unwrap().clone().try_cast::<String>().unwrap(), "item-1");
162 - assert_eq!(item.get("title").unwrap().clone().try_cast::<String>().unwrap(), "RSS Post");
163 - assert_eq!(item.get("summary").unwrap().clone().try_cast::<String>().unwrap(), "Post body");
203 + assert_eq!(
204 + item.get("id")
205 + .unwrap()
206 + .clone()
207 + .try_cast::<String>()
208 + .unwrap(),
209 + "item-1"
210 + );
211 + assert_eq!(
212 + item.get("title")
213 + .unwrap()
214 + .clone()
215 + .try_cast::<String>()
216 + .unwrap(),
217 + "RSS Post"
218 + );
219 + assert_eq!(
220 + item.get("summary")
221 + .unwrap()
222 + .clone()
223 + .try_cast::<String>()
224 + .unwrap(),
225 + "Post body"
226 + );
164 227 }
165 228
166 229 #[test]
@@ -179,11 +242,20 @@ mod tests {
179 242
180 243 let result = parse_feed_xml(xml).unwrap();
181 244 let map = result.try_cast::<Map>().unwrap();
182 - let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
245 + let entries = map
246 + .get("entries")
247 + .unwrap()
248 + .clone()
249 + .try_cast::<rhai::Array>()
250 + .unwrap();
183 251 let item = entries[0].clone().try_cast::<Map>().unwrap();
184 252
185 253 assert_eq!(
186 - item.get("id").unwrap().clone().try_cast::<String>().unwrap(),
254 + item.get("id")
255 + .unwrap()
256 + .clone()
257 + .try_cast::<String>()
258 + .unwrap(),
187 259 "https://example.com/post"
188 260 );
189 261 }
@@ -206,11 +278,25 @@ mod tests {
206 278
207 279 let result = parse_feed_xml(xml).unwrap();
208 280 let map = result.try_cast::<Map>().unwrap();
209 - let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
281 + let entries = map
282 + .get("entries")
283 + .unwrap()
284 + .clone()
285 + .try_cast::<rhai::Array>()
286 + .unwrap();
210 287 let item = entries[0].clone().try_cast::<Map>().unwrap();
211 288
212 - let id = item.get("id").unwrap().clone().try_cast::<String>().unwrap();
213 - assert!(id.starts_with("synth-"), "synthesized ID should start with 'synth-', got: {}", id);
289 + let id = item
290 + .get("id")
291 + .unwrap()
292 + .clone()
293 + .try_cast::<String>()
294 + .unwrap();
295 + assert!(
296 + id.starts_with("synth-"),
297 + "synthesized ID should start with 'synth-', got: {}",
298 + id
299 + );
214 300 }
215 301
216 302 #[test]
@@ -233,14 +319,36 @@ mod tests {
233 319
234 320 let result = parse_feed_xml(xml).unwrap();
235 321 let map = result.try_cast::<Map>().unwrap();
236 - let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
237 -
238 - let id_a = entries[0].clone().try_cast::<Map>().unwrap()
239 - .get("id").unwrap().clone().try_cast::<String>().unwrap();
240 - let id_b = entries[1].clone().try_cast::<Map>().unwrap()
241 - .get("id").unwrap().clone().try_cast::<String>().unwrap();
242 -
243 - assert_ne!(id_a, id_b, "different items should get different synthesized IDs");
322 + let entries = map
323 + .get("entries")
324 + .unwrap()
325 + .clone()
326 + .try_cast::<rhai::Array>()
327 + .unwrap();
328 +
329 + let id_a = entries[0]
330 + .clone()
331 + .try_cast::<Map>()
332 + .unwrap()
333 + .get("id")
334 + .unwrap()
335 + .clone()
336 + .try_cast::<String>()
337 + .unwrap();
338 + let id_b = entries[1]
339 + .clone()
340 + .try_cast::<Map>()
341 + .unwrap()
342 + .get("id")
343 + .unwrap()
344 + .clone()
345 + .try_cast::<String>()
346 + .unwrap();
347 +
348 + assert_ne!(
349 + id_a, id_b,
350 + "different items should get different synthesized IDs"
351 + );
244 352 }
245 353
246 354 #[test]
@@ -259,11 +367,20 @@ mod tests {
259 367
260 368 let result = parse_feed_xml(xml).unwrap();
261 369 let map = result.try_cast::<Map>().unwrap();
262 - let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
370 + let entries = map
371 + .get("entries")
372 + .unwrap()
373 + .clone()
374 + .try_cast::<rhai::Array>()
375 + .unwrap();
263 376 let item = entries[0].clone().try_cast::<Map>().unwrap();
264 377
265 378 assert_eq!(
266 - item.get("id").unwrap().clone().try_cast::<String>().unwrap(),
379 + item.get("id")
380 + .unwrap()
381 + .clone()
382 + .try_cast::<String>()
383 + .unwrap(),
267 384 "my-guid-123"
268 385 );
269 386 }
@@ -287,12 +404,25 @@ mod tests {
287 404
288 405 let get_id = |result: Dynamic| -> String {
289 406 let map = result.try_cast::<Map>().unwrap();
290 - let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
407 + let entries = map
408 + .get("entries")
409 + .unwrap()
410 + .clone()
411 + .try_cast::<rhai::Array>()
412 + .unwrap();
291 413 let item = entries[0].clone().try_cast::<Map>().unwrap();
292 - item.get("id").unwrap().clone().try_cast::<String>().unwrap()
414 + item.get("id")
415 + .unwrap()
416 + .clone()
417 + .try_cast::<String>()
418 + .unwrap()
293 419 };
294 420
295 - assert_eq!(get_id(result1), get_id(result2), "same content should produce same ID");
421 + assert_eq!(
422 + get_id(result1),
423 + get_id(result2),
424 + "same content should produce same ID"
425 + );
296 426 }
297 427
298 428 // --- parse_xml_to_dynamic ---
@@ -302,8 +432,22 @@ mod tests {
302 432 let xml = "<root>hello</root>";
303 433 let result = parse_xml_to_dynamic(xml).unwrap();
304 434 let map = result.try_cast::<Map>().unwrap();
305 - assert_eq!(map.get("tag").unwrap().clone().try_cast::<String>().unwrap(), "root");
306 - assert_eq!(map.get("text").unwrap().clone().try_cast::<String>().unwrap(), "hello");
435 + assert_eq!(
436 + map.get("tag")
437 + .unwrap()
438 + .clone()
439 + .try_cast::<String>()
440 + .unwrap(),
441 + "root"
442 + );
443 + assert_eq!(
444 + map.get("text")
445 + .unwrap()
446 + .clone()
447 + .try_cast::<String>()
448 + .unwrap(),
449 + "hello"
450 + );
307 451 }
308 452
309 453 #[test]
@@ -312,8 +456,24 @@ mod tests {
312 456 let result = parse_xml_to_dynamic(xml).unwrap();
313 457 let map = result.try_cast::<Map>().unwrap();
314 458 let attrs = map.get("attrs").unwrap().clone().try_cast::<Map>().unwrap();
315 - assert_eq!(attrs.get("id").unwrap().clone().try_cast::<String>().unwrap(), "42");
316 - assert_eq!(attrs.get("type").unwrap().clone().try_cast::<String>().unwrap(), "post");
459 + assert_eq!(
460 + attrs
461 + .get("id")
462 + .unwrap()
463 + .clone()
464 + .try_cast::<String>()
465 + .unwrap(),
466 + "42"
467 + );
468 + assert_eq!(
469 + attrs
470 + .get("type")
471 + .unwrap()
472 + .clone()
473 + .try_cast::<String>()
474 + .unwrap(),
475 + "post"
476 + );
317 477 }
318 478
319 479 #[test]
@@ -321,11 +481,32 @@ mod tests {
321 481 let xml = "<root><child>text</child></root>";
322 482 let result = parse_xml_to_dynamic(xml).unwrap();
323 483 let map = result.try_cast::<Map>().unwrap();
324 - let children = map.get("children").unwrap().clone().try_cast::<rhai::Array>().unwrap();
484 + let children = map
485 + .get("children")
486 + .unwrap()
487 + .clone()
488 + .try_cast::<rhai::Array>()
489 + .unwrap();
325 490 assert_eq!(children.len(), 1);
326 491 let child = children[0].clone().try_cast::<Map>().unwrap();
327 - assert_eq!(child.get("tag").unwrap().clone().try_cast::<String>().unwrap(), "child");
328 - assert_eq!(child.get("text").unwrap().clone().try_cast::<String>().unwrap(), "text");
492 + assert_eq!(
493 + child
494 + .get("tag")
495 + .unwrap()
496 + .clone()
497 + .try_cast::<String>()
498 + .unwrap(),
499 + "child"
500 + );
501 + assert_eq!(
502 + child
503 + .get("text")
504 + .unwrap()
505 + .clone()
506 + .try_cast::<String>()
507 + .unwrap(),
508 + "text"
509 + );
329 510 }
330 511
331 512 #[test]
@@ -349,14 +530,37 @@ mod tests {
349 530 let d = busser_config_to_dynamic(&config);
350 531 let map = d.try_cast::<Map>().unwrap();
351 532
352 - assert_eq!(map.get("key1").unwrap().clone().try_cast::<String>().unwrap(), "val1");
353 - assert_eq!(map.get("key2").unwrap().clone().try_cast::<String>().unwrap(), "val2");
354 533 assert_eq!(
355 - map.get("feed_url").unwrap().clone().try_cast::<String>().unwrap(),
534 + map.get("key1")
535 + .unwrap()
536 + .clone()
537 + .try_cast::<String>()
538 + .unwrap(),
539 + "val1"
540 + );
541 + assert_eq!(
542 + map.get("key2")
543 + .unwrap()
544 + .clone()
545 + .try_cast::<String>()
546 + .unwrap(),
547 + "val2"
548 + );
549 + assert_eq!(
550 + map.get("feed_url")
551 + .unwrap()
552 + .clone()
553 + .try_cast::<String>()
554 + .unwrap(),
356 555 "https://feed.example.com"
357 556 );
358 557
359 - let feeds = map.get("feeds").unwrap().clone().try_cast::<rhai::Array>().unwrap();
558 + let feeds = map
559 + .get("feeds")
560 + .unwrap()
561 + .clone()
562 + .try_cast::<rhai::Array>()
563 + .unwrap();
360 564 assert_eq!(feeds.len(), 1);
361 565 }
362 566
@@ -371,7 +575,12 @@ mod tests {
371 575 let map = d.try_cast::<Map>().unwrap();
372 576
373 577 assert!(!map.contains_key("feed_url"));
374 - let feeds = map.get("feeds").unwrap().clone().try_cast::<rhai::Array>().unwrap();
578 + let feeds = map
579 + .get("feeds")
580 + .unwrap()
581 + .clone()
582 + .try_cast::<rhai::Array>()
583 + .unwrap();
375 584 assert!(feeds.is_empty());
376 585 }
377 586
@@ -435,7 +644,10 @@ mod tests {
435 644 let fields: rhai::Array = vec![Dynamic::from(field_map)];
436 645
437 646 let mut map = Map::new();
438 - map.insert("description".into(), Dynamic::from("Test plugin".to_string()));
647 + map.insert(
648 + "description".into(),
649 + Dynamic::from("Test plugin".to_string()),
650 + );
439 651 map.insert("fields".into(), Dynamic::from(fields));
440 652
441 653 let schema = dynamic_to_config_schema(Dynamic::from(map)).unwrap();
@@ -547,7 +759,10 @@ mod tests {
547 759 let mut content_map = Map::new();
548 760 content_map.insert("title".into(), Dynamic::from("Full Title".to_string()));
549 761 content_map.insert("body".into(), Dynamic::from("Full body".to_string()));
550 - content_map.insert("url".into(), Dynamic::from("https://example.com".to_string()));
762 + content_map.insert(
763 + "url".into(),
764 + Dynamic::from("https://example.com".to_string()),
765 + );
551 766
552 767 let mut map = Map::new();
553 768 map.insert("id".into(), Dynamic::from("1".to_string()));
@@ -586,25 +801,83 @@ mod tests {
586 801 let map = result.try_cast::<Map>().unwrap();
587 802
588 803 assert_eq!(
589 - map.get("title").unwrap().clone().try_cast::<String>().unwrap(),
804 + map.get("title")
805 + .unwrap()
806 + .clone()
807 + .try_cast::<String>()
808 + .unwrap(),
590 809 "My JSON Feed"
591 810 );
592 811 assert_eq!(
593 - map.get("link").unwrap().clone().try_cast::<String>().unwrap(),
812 + map.get("link")
813 + .unwrap()
814 + .clone()
815 + .try_cast::<String>()
816 + .unwrap(),
594 817 "https://example.com"
595 818 );
596 819
597 - let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
820 + let entries = map
821 + .get("entries")
822 + .unwrap()
823 + .clone()
824 + .try_cast::<rhai::Array>()
825 + .unwrap();
598 826 assert_eq!(entries.len(), 1);
599 827
600 828 let entry = entries[0].clone().try_cast::<Map>().unwrap();
601 - assert_eq!(entry.get("id").unwrap().clone().try_cast::<String>().unwrap(), "1");
602 - assert_eq!(entry.get("title").unwrap().clone().try_cast::<String>().unwrap(), "First Post");
603 - assert_eq!(entry.get("link").unwrap().clone().try_cast::<String>().unwrap(), "https://example.com/1");
604 - assert_eq!(entry.get("summary").unwrap().clone().try_cast::<String>().unwrap(), "<p>Hello</p>");
605 - assert_eq!(entry.get("author").unwrap().clone().try_cast::<String>().unwrap(), "Alice");
829 + assert_eq!(
830 + entry
831 + .get("id")
832 + .unwrap()
833 + .clone()
834 + .try_cast::<String>()
835 + .unwrap(),
836 + "1"
837 + );
838 + assert_eq!(
839 + entry
840 + .get("title")
841 + .unwrap()
842 + .clone()
843 + .try_cast::<String>()
844 + .unwrap(),
845 + "First Post"
846 + );
847 + assert_eq!(
848 + entry
849 + .get("link")
850 + .unwrap()
851 + .clone()
852 + .try_cast::<String>()
Lines truncated
@@ -1,8 +1,8 @@
1 1 //! Host functions registered into the Rhai engine for plugin scripts.
2 2
3 3 use std::io::Read as _;
4 - use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
5 4 use std::sync::Arc;
5 + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
6 6 use std::time::Duration;
7 7
8 8 use rhai::{Dynamic, Engine};
@@ -14,7 +14,11 @@ use crate::url_cleaner;
14 14 const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
15 15
16 16 /// User-Agent sent with all plugin HTTP requests.
17 - const USER_AGENT: &str = concat!("BalancedBreakfast/", env!("CARGO_PKG_VERSION"), " (feed reader)");
17 + const USER_AGENT: &str = concat!(
18 + "BalancedBreakfast/",
19 + env!("CARGO_PKG_VERSION"),
20 + " (feed reader)"
21 + );
18 22
19 23 /// Maximum response body size (2 MB). Prevents a plugin from consuming
20 24 /// unbounded memory on a large or malicious response.
@@ -47,7 +51,10 @@ fn is_cgnat(host: &str) -> bool {
47 51 fn validate_url(url: &str) -> Result<(), String> {
48 52 let lower = url.to_ascii_lowercase();
49 53 if !lower.starts_with("http://") && !lower.starts_with("https://") {
50 - return Err(format!("Blocked URL scheme (only http/https allowed): {}", url));
54 + return Err(format!(
55 + "Blocked URL scheme (only http/https allowed): {}",
56 + url
57 + ));
51 58 }
52 59 // Block localhost and common internal addresses
53 60 let host_part = lower
@@ -57,7 +64,11 @@ fn validate_url(url: &str) -> Result<(), String> {
57 64 let host_and_port = host_part.split('/').next().unwrap_or("");
58 65 // Handle IPv6 brackets: [::1]:8080 -> [::1]
59 66 let host = if host_and_port.starts_with('[') {
60 - host_and_port.split(']').next().map(|s| format!("{}]", s)).unwrap_or_default()
67 + host_and_port
68 + .split(']')
69 + .next()
70 + .map(|s| format!("{}]", s))
71 + .unwrap_or_default()
61 72 } else {
62 73 host_and_port.split(':').next().unwrap_or("").to_string() // strip port
63 74 };
@@ -180,7 +191,11 @@ fn format_ureq_error(err: ureq::Error) -> String {
180 191 .take(MAX_RESPONSE_BYTES)
181 192 .read_to_end(&mut bytes);
182 193 let body = String::from_utf8_lossy(&bytes);
183 - let body_preview = if body.len() > 200 { &body[..200] } else { &body };
194 + let body_preview = if body.len() > 200 {
195 + &body[..200]
196 + } else {
197 + &body
198 + };
184 199 match status {
185 200 401 | 403 => format!("{BB_ERR_PREFIX}auth:HTTP {status}: {body_preview}"),
186 201 429 => {
@@ -236,23 +251,24 @@ fn register_http_fns(
236 251 let deadline = fetch_deadline.clone();
237 252 let agent = make_agent();
238 253 let agent_clone = agent.clone();
239 - engine.register_fn("http_get", move |url: &str| -> Result<String, Box<rhai::EvalAltResult>> {
240 - validate_url(url)?;
241 - check_request_limit(&counter)?;
242 - check_fetch_deadline(&deadline)?;
243 -
244 - let response = agent_clone.get(url)
245 - .call()
246 - .map_err(format_ureq_error)?;
247 -
248 - let mut bytes = Vec::new();
249 - response
250 - .into_reader()
251 - .take(MAX_RESPONSE_BYTES)
252 - .read_to_end(&mut bytes)
253 - .map_err(|e| format!("Failed to read response: {}", e))?;
254 - Ok(String::from_utf8_lossy(&bytes).into_owned())
255 - });
254 + engine.register_fn(
255 + "http_get",
256 + move |url: &str| -> Result<String, Box<rhai::EvalAltResult>> {
257 + validate_url(url)?;
258 + check_request_limit(&counter)?;
259 + check_fetch_deadline(&deadline)?;
260 +
261 + let response = agent_clone.get(url).call().map_err(format_ureq_error)?;
262 +
263 + let mut bytes = Vec::new();
264 + response
265 + .into_reader()
266 + .take(MAX_RESPONSE_BYTES)
267 + .read_to_end(&mut bytes)
268 + .map_err(|e| format!("Failed to read response: {}", e))?;
269 + Ok(String::from_utf8_lossy(&bytes).into_owned())
270 + },
271 + );
256 272
257 273 // HTTP GET returning parsed JSON as Dynamic
258 274 let counter = request_counter;
@@ -264,9 +280,7 @@ fn register_http_fns(
264 280 check_request_limit(&counter)?;
265 281 check_fetch_deadline(&deadline)?;
266 282
267 - let response = agent.get(url)
268 - .call()
269 - .map_err(format_ureq_error)?;
283 + let response = agent.get(url).call().map_err(format_ureq_error)?;
270 284
271 285 let mut body = Vec::new();
272 286 response
@@ -290,8 +304,8 @@ fn register_parse_fns(engine: &mut Engine) {
290 304 engine.register_fn(
291 305 "parse_json",
292 306 |json_str: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> {
293 - let json: serde_json::Value =
294 - serde_json::from_str(json_str).map_err(|e| format!("{BB_ERR_PREFIX}parse:JSON parse error: {e}"))?;
307 + let json: serde_json::Value = serde_json::from_str(json_str)
308 + .map_err(|e| format!("{BB_ERR_PREFIX}parse:JSON parse error: {e}"))?;
295 309 json_to_dynamic(json)
296 310 },
297 311 );
@@ -366,23 +380,30 @@ fn register_string_fns(engine: &mut Engine) {
366 380
367 381 // String split — returns a Rhai Array of strings (not an iterator).
368 382 engine.register_fn("str_split", |text: &str, sep: &str| -> rhai::Array {
369 - text.split(sep).map(|s| Dynamic::from(s.to_string())).collect()
383 + text.split(sep)
384 + .map(|s| Dynamic::from(s.to_string()))
385 + .collect()
370 386 });
371 387
372 388 // String replace
373 - engine.register_fn("str_replace", |text: &str, from: &str, to: &str| -> String {
374 - text.replace(from, to)
375 - });
389 + engine.register_fn(
390 + "str_replace",
391 + |text: &str, from: &str, to: &str| -> String { text.replace(from, to) },
392 + );
376 393
377 394 // String trim
378 - engine.register_fn("str_trim", |text: &str| -> String { text.trim().to_string() });
395 + engine.register_fn("str_trim", |text: &str| -> String {
396 + text.trim().to_string()
397 + });
379 398 }
380 399
381 400 /// Register the remaining utility builtins (`timestamp_now`, `html_to_text`,
382 401 /// `debug_print`, `strip_tracking`, `extract_article`).
383 402 fn register_util_fns(engine: &mut Engine) {
384 403 // Current UTC timestamp as Unix epoch seconds (i64).
385 - engine.register_fn("timestamp_now", || -> i64 { chrono::Utc::now().timestamp() });
404 + engine.register_fn("timestamp_now", || -> i64 {
405 + chrono::Utc::now().timestamp()
406 + });
386 407
387 408 // Convert HTML to readable markdown.
388 409 engine.register_fn("html_to_text", |html: &str| -> String {
@@ -458,19 +479,18 @@ fn register_error_fns(engine: &mut Engine) {
458 479 );
459 480 }
460 481
461 -
462 482 #[cfg(test)]
463 483 mod tests {
464 - use std::sync::atomic::{AtomicUsize, Ordering};
465 484 use std::sync::Arc;
485 + use std::sync::atomic::{AtomicUsize, Ordering};
466 486
467 487 use rhai::Dynamic;
468 488
469 489 use std::sync::atomic::AtomicU64;
470 490
471 491 use super::{
472 - check_fetch_deadline, check_request_limit, validate_url, BB_ERR_PREFIX,
473 - MAX_REQUESTS_PER_FETCH,
492 + BB_ERR_PREFIX, MAX_REQUESTS_PER_FETCH, check_fetch_deadline, check_request_limit,
493 + validate_url,
474 494 };
475 495
476 496 /// Truncate text with ellipsis (mirrors the Rhai-registered closure for testing).
@@ -792,7 +812,10 @@ mod tests {
792 812 - 1_000;
793 813 let deadline = AtomicU64::new(past_ms);
794 814 let err = check_fetch_deadline(&deadline).unwrap_err();
795 - assert!(err.contains("aggregate limit"), "error should mention aggregate limit: {err}");
815 + assert!(
816 + err.contains("aggregate limit"),
817 + "error should mention aggregate limit: {err}"
818 + );
796 819 }
797 820
798 821 // ── extract_article tests ──────────────────────────────────────
@@ -12,19 +12,19 @@ mod host_functions;
12 12
13 13 use std::collections::HashMap;
14 14 use std::path::Path;
15 - use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
16 15 use std::sync::Arc;
16 + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
17 17
18 18 use bb_interface::{BusserCapabilities, ConfigSchema};
19 - use rhai::{Dynamic, Engine, ImmutableString, Scope, AST};
19 + use rhai::{AST, Dynamic, Engine, ImmutableString, Scope};
20 20 use thiserror::Error;
21 21 use tracing::debug;
22 22
23 + use bb_interface::{ErrorCategory, StructuredError};
23 24 use conversions::{
24 25 busser_config_to_dynamic, dynamic_to_capabilities, dynamic_to_config_schema,
25 26 dynamic_to_fetch_result,
26 27 };
27 - use bb_interface::{ErrorCategory, StructuredError};
28 28 use host_functions::register_host_functions;
29 29
30 30 #[derive(Error, Debug)]
@@ -112,15 +112,29 @@ pub fn classify_error(error_str: &str) -> StructuredError {
112 112 /// Best-effort classification of an error string by looking for common keywords.
113 113 fn heuristic_classify(error_str: &str) -> StructuredError {
114 114 let lower = error_str.to_ascii_lowercase();
115 - if lower.contains("401") || lower.contains("403") || lower.contains("unauthorized") || lower.contains("forbidden") {
115 + if lower.contains("401")
116 + || lower.contains("403")
117 + || lower.contains("unauthorized")
118 + || lower.contains("forbidden")
119 + {
116 120 StructuredError::new(ErrorCategory::Auth, error_str)
117 121 } else if lower.contains("429") || lower.contains("rate limit") {
118 122 StructuredError::rate_limited(error_str, 60)
119 123 } else if lower.contains("404") || lower.contains("not found") || lower.contains("410") {
120 124 StructuredError::new(ErrorCategory::Config, error_str)
121 - } else if lower.contains("timeout") || lower.contains("timed out") || lower.contains("500") || lower.contains("502") || lower.contains("503") || lower.contains("connection") {
125 + } else if lower.contains("timeout")
126 + || lower.contains("timed out")
127 + || lower.contains("500")
128 + || lower.contains("502")
129 + || lower.contains("503")
130 + || lower.contains("connection")
131 + {
122 132 StructuredError::new(ErrorCategory::Transient, error_str)
123 - } else if lower.contains("parse") || lower.contains("invalid xml") || lower.contains("invalid json") || lower.contains("malformed") {
133 + } else if lower.contains("parse")
134 + || lower.contains("invalid xml")
135 + || lower.contains("invalid json")
136 + || lower.contains("malformed")
137 + {
124 138 StructuredError::new(ErrorCategory::Parse, error_str)
125 139 } else {
126 140 StructuredError::new(ErrorCategory::Unknown, error_str)
@@ -232,8 +246,7 @@ impl RhaiPlugin {
232 246 .call_fn(&mut scope, &self.ast, "fetch", (config_map, cursor_val))
233 247 .map_err(|e| RhaiPluginError::RuntimeError(e.to_string()))?;
234 248
235 - validate_dynamic_sizes(&result, 0)
236 - .map_err(RhaiPluginError::RuntimeError)?;
249 + validate_dynamic_sizes(&result, 0).map_err(RhaiPluginError::RuntimeError)?;
237 250
238 251 dynamic_to_fetch_result(result, &self.id)
239 252 }
@@ -505,7 +518,8 @@ mod tests {
505 518
506 519 #[test]
507 520 fn classify_error_rate_limited_prefix() {
508 - let err = classify_error("Script execution failed: BB_ERR:rate_limited:120:HTTP 429: slow down");
521 + let err =
522 + classify_error("Script execution failed: BB_ERR:rate_limited:120:HTTP 429: slow down");
509 523 assert_eq!(err.category, bb_interface::ErrorCategory::RateLimited);
510 524 assert_eq!(err.retry_after_secs, Some(120));
511 525 assert!(err.message.contains("429"));
@@ -609,7 +623,10 @@ mod tests {
609 623 fn sandbox_enforces_operation_limit() {
610 624 let engine = create_engine();
611 625 let result = engine.eval::<()>("let x = 0; loop { x += 1; }");
612 - assert!(result.is_err(), "infinite loop should be stopped by operation limit");
626 + assert!(
627 + result.is_err(),
628 + "infinite loop should be stopped by operation limit"
629 + );
613 630 }
614 631
615 632 #[test]
@@ -621,7 +638,10 @@ mod tests {
621 638 recurse(0)
622 639 "#;
623 640 let result = engine.eval::<()>(script);
624 - assert!(result.is_err(), "deep recursion should be stopped by call level limit");
641 + assert!(
642 + result.is_err(),
643 + "deep recursion should be stopped by call level limit"
644 + );
625 645 }
626 646
627 647 #[test]
@@ -632,7 +652,10 @@ mod tests {
632 652 for i in 0..50 {
633 653 let mut inner = rhai::Map::new();
634 654 for j in 0..10 {
635 - inner.insert(format!("k{j}").into(), Dynamic::from(format!("val-{i}-{j}")));
655 + inner.insert(
656 + format!("k{j}").into(),
657 + Dynamic::from(format!("val-{i}-{j}")),
658 + );
636 659 }
637 660 outer.insert(format!("item{i}").into(), Dynamic::from(inner));
638 661 }
@@ -684,7 +707,10 @@ mod tests {
684 707 fn create_engine_enforces_operation_limit() {
685 708 let engine = create_engine();
686 709 let result = engine.eval::<()>("let x = 0; loop { x += 1; }");
687 - assert!(result.is_err(), "infinite loop should be stopped by operation limit");
710 + assert!(
711 + result.is_err(),
712 + "infinite loop should be stopped by operation limit"
713 + );
688 714 }
689 715
690 716 #[test]
@@ -695,10 +721,12 @@ mod tests {
695 721 recurse(0)
696 722 "#;
697 723 let result = engine.eval::<()>(script);
698 - assert!(result.is_err(), "deep recursion should be stopped by call level limit");
724 + assert!(
725 + result.is_err(),
726 + "deep recursion should be stopped by call level limit"
727 + );
699 728 }
700 729
701 -
702 730 #[test]
703 731 fn devto_plugin_live_fetch() {
704 732 let manifest_dir = env!("CARGO_MANIFEST_DIR");
@@ -710,7 +738,9 @@ mod tests {
710 738 }
711 739
712 740 let mut manager = RhaiPluginManager::new();
713 - let id = manager.load_plugin(&plugin_path).expect("failed to load devto plugin");
741 + let id = manager
742 + .load_plugin(&plugin_path)
743 + .expect("failed to load devto plugin");
714 744 let plugin = manager.get(&id).unwrap();
715 745
716 746 let config = bb_interface::BusserConfig {
@@ -23,11 +23,7 @@ pub fn is_single_feed_due(
23 23
24 24 #[tracing::instrument(skip_all)]
25 25 /// Check whether any feed in a set is overdue for fetching
26 - pub fn any_feed_due(
27 - last_fetches: &[Option<&str>],
28 - interval_secs: u64,
29 - now: DateTime<Utc>,
30 - ) -> bool {
26 + pub fn any_feed_due(last_fetches: &[Option<&str>], interval_secs: u64, now: DateTime<Utc>) -> bool {
31 27 last_fetches
32 28 .iter()
33 29 .any(|ts| is_single_feed_due(*ts, interval_secs, now))
@@ -86,10 +82,8 @@ mod tests {
86 82 let now = Utc::now();
87 83 let two_hours_ago = (now - Duration::hours(2)).to_rfc3339();
88 84 let five_min_ago = (now - Duration::minutes(5)).to_rfc3339();
89 - let feeds: Vec<Option<&str>> = vec![
90 - Some(five_min_ago.as_str()),
91 - Some(two_hours_ago.as_str()),
92 - ];
85 + let feeds: Vec<Option<&str>> =
86 + vec![Some(five_min_ago.as_str()), Some(two_hours_ago.as_str())];
93 87 assert!(any_feed_due(&feeds, 3600, now));
94 88 }
95 89
@@ -98,10 +92,8 @@ mod tests {
98 92 let now = Utc::now();
99 93 let five_min_ago = (now - Duration::minutes(5)).to_rfc3339();
100 94 let ten_min_ago = (now - Duration::minutes(10)).to_rfc3339();
101 - let feeds: Vec<Option<&str>> = vec![
102 - Some(five_min_ago.as_str()),
103 - Some(ten_min_ago.as_str()),
104 - ];
95 + let feeds: Vec<Option<&str>> =
96 + vec![Some(five_min_ago.as_str()), Some(ten_min_ago.as_str())];
105 97 assert!(!any_feed_due(&feeds, 3600, now));
106 98 }
107 99
@@ -110,10 +102,7 @@ mod tests {
110 102 // One feed never fetched (None) — should be due.
111 103 let now = Utc::now();
112 104 let five_min_ago = (now - Duration::minutes(5)).to_rfc3339();
113 - let feeds: Vec<Option<&str>> = vec![
114 - Some(five_min_ago.as_str()),
115 - None,
116 - ];
105 + let feeds: Vec<Option<&str>> = vec![Some(five_min_ago.as_str()), None];
117 106 assert!(any_feed_due(&feeds, 3600, now));
118 107 }
119 108
@@ -74,10 +74,7 @@ pub fn strip_tracking_params(url_str: &str) -> String {
74 74 if clean_pairs.is_empty() {
75 75 parsed.set_query(None);
76 76 } else {
77 - parsed
78 - .query_pairs_mut()
79 - .clear()
80 - .extend_pairs(clean_pairs);
77 + parsed.query_pairs_mut().clear().extend_pairs(clean_pairs);
81 78 }
82 79
83 80 parsed.to_string()
@@ -8,8 +8,8 @@ pub use id_types::*;
8 8 pub use models::*;
9 9 pub use repository::*;
10 10
11 - use sqlx::sqlite::SqlitePoolOptions;
12 11 use sqlx::SqlitePool;
12 + use sqlx::sqlite::SqlitePoolOptions;
13 13
14 14 /// SQLite timestamp format used for all datetime columns.
15 15 pub const TIMESTAMP_FMT: &str = "%Y-%m-%d %H:%M:%S";
@@ -43,7 +43,9 @@ impl Database {
43 43 /// Run migrations
44 44 #[tracing::instrument(skip_all)]
45 45 pub async fn migrate(&self) -> Result<(), sqlx::migrate::MigrateError> {
46 - sqlx::migrate!("../../migrations/sqlite").run(&self.pool).await
46 + sqlx::migrate!("../../migrations/sqlite")
47 + .run(&self.pool)
48 + .await
47 49 }
48 50
49 51 /// Get the connection pool
@@ -7,8 +7,8 @@ use chrono::{DateTime, Utc};
7 7 use serde::{Deserialize, Serialize};
8 8 use sqlx::FromRow;
9 9
10 - use crate::id_types::{BookmarkId, BusserId, BusserStateId, FeedId, ItemId, QueryFeedId};
11 10 use crate::TIMESTAMP_FMT;
11 + use crate::id_types::{BookmarkId, BusserId, BusserStateId, FeedId, ItemId, QueryFeedId};
12 12
13 13 /// Parse a value or log a warning and return the default.
14 14 /// Used for data that we wrote ourselves (JSON) — parse failures
@@ -494,10 +494,7 @@ mod tests {
494 494 updated_at: "2024-01-01 00:00:00".to_string(),
495 495 };
496 496 assert_eq!(feed.id, expected);
497 - assert_eq!(
498 - feed.id.to_string(),
499 - "550e8400-e29b-41d4-a716-446655440000"
500 - );
497 + assert_eq!(feed.id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
501 498 }
502 499
503 500 #[test]
@@ -76,7 +76,10 @@ impl BookmarksRepository {
76 76
77 77 /// Look up a bookmark by its linked feed item ID. Returns `None` if not found.
78 78 #[tracing::instrument(skip_all)]
79 - pub async fn get_by_feed_item(&self, feed_item_id: &str) -> Result<Option<DbBookmark>, sqlx::Error> {
79 + pub async fn get_by_feed_item(
80 + &self,
81 + feed_item_id: &str,
82 + ) -> Result<Option<DbBookmark>, sqlx::Error> {
80 83 sqlx::query_as("SELECT * FROM bookmarks WHERE feed_item_id = ?1")
81 84 .bind(feed_item_id)
82 85 .fetch_optional(&self.pool)
@@ -101,11 +104,9 @@ impl BookmarksRepository {
101 104 .await
102 105 }
103 106 None => {
104 - sqlx::query_as(
105 - "SELECT * FROM bookmarks ORDER BY is_pinned DESC, created_at DESC",
106 - )
107 - .fetch_all(&self.pool)
108 - .await
107 + sqlx::query_as("SELECT * FROM bookmarks ORDER BY is_pinned DESC, created_at DESC")
108 + .fetch_all(&self.pool)
109 + .await
109 110 }
110 111 }
111 112 }
@@ -203,4 +204,3 @@ impl BookmarksRepository {
203 204 Ok(row.0)
204 205 }
205 206 }
206 -
@@ -15,11 +15,10 @@ impl ConfigRepository {
15 15 /// Get a config value by key.
16 16 #[tracing::instrument(skip_all)]
17 17 pub async fn get(&self, key: &str) -> Result<Option<String>, sqlx::Error> {
18 - let row: Option<(String,)> =
19 - sqlx::query_as("SELECT value FROM user_config WHERE key = ?1")
20 - .bind(key)
21 - .fetch_optional(&self.pool)
22 - .await?;
18 + let row: Option<(String,)> = sqlx::query_as("SELECT value FROM user_config WHERE key = ?1")
19 + .bind(key)
20 + .fetch_optional(&self.pool)
21 + .await?;
23 22 Ok(row.map(|(v,)| v))
24 23 }
25 24
@@ -50,4 +49,3 @@ impl ConfigRepository {
50 49 Ok(())
51 50 }
52 51 }
53 -
@@ -60,11 +60,9 @@ impl FeedsRepository {
60 60 /// List only enabled feeds that are not circuit-broken, ordered by name.
61 61 #[tracing::instrument(skip_all)]
62 62 pub async fn list_enabled(&self) -> Result<Vec<DbFeed>, sqlx::Error> {
63 - sqlx::query_as(
64 - "SELECT * FROM feeds WHERE enabled = 1 AND circuit_broken = 0 ORDER BY name",
65 - )
66 - .fetch_all(&self.pool)
67 - .await
63 + sqlx::query_as("SELECT * FROM feeds WHERE enabled = 1 AND circuit_broken = 0 ORDER BY name")
64 + .fetch_all(&self.pool)
65 + .await
68 66 }
69 67
70 68 /// List every feed (enabled or disabled), ordered by name.
@@ -120,11 +118,7 @@ impl FeedsRepository {
120 118 /// Returns `true` if the circuit breaker tripped (i.e. the feed just crossed
121 119 /// the [`CIRCUIT_BREAKER_THRESHOLD`] and was marked `circuit_broken = 1`).
122 120 #[tracing::instrument(skip_all)]
123 - pub async fn record_fetch_failure(
124 - &self,
125 - id: FeedId,
126 - error: &str,
127 - ) -> Result<bool, sqlx::Error> {
121 + pub async fn record_fetch_failure(&self, id: FeedId, error: &str) -> Result<bool, sqlx::Error> {
128 122 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
129 123 sqlx::query(
130 124 "UPDATE feeds SET consecutive_failures = consecutive_failures + 1, \
@@ -218,20 +212,14 @@ impl FeedsRepository {
218 212
219 213 /// Mark a feed as circuit-broken (or clear the circuit breaker).
220 214 #[tracing::instrument(skip_all)]
221 - pub async fn set_circuit_broken(
222 - &self,
223 - id: FeedId,
224 - broken: bool,
225 - ) -> Result<(), sqlx::Error> {
215 + pub async fn set_circuit_broken(&self, id: FeedId, broken: bool) -> Result<(), sqlx::Error> {
226 216 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
227 - sqlx::query(
228 - "UPDATE feeds SET circuit_broken = ?1, updated_at = ?2 WHERE id = ?3",
229 - )
230 - .bind(broken)
231 - .bind(&now)
232 - .bind(id)
233 - .execute(&self.pool)
234 - .await?;
217 + sqlx::query("UPDATE feeds SET circuit_broken = ?1, updated_at = ?2 WHERE id = ?3")
218 + .bind(broken)
219 + .bind(&now)
220 + .bind(id)
221 + .execute(&self.pool)
222 + .await?;
235 223 Ok(())
236 224 }
237 225
@@ -26,8 +26,7 @@ impl ItemsRepository {
26 26 // Vec<String> is always serializable; unwrap is safe here.
27 27 let media = serde_json::to_string(&input.media).unwrap_or_else(|_| "[]".to_string());
28 28 let tags = serde_json::to_string(&input.tags).unwrap_or_else(|_| "[]".to_string());
29 - let actions =
30 - serde_json::to_string(&input.actions).unwrap_or_else(|_| "[]".to_string());
29 + let actions = serde_json::to_string(&input.actions).unwrap_or_else(|_| "[]".to_string());
31 30
32 31 sqlx::query_as(
33 32 r#"
@@ -104,13 +103,11 @@ impl ItemsRepository {
104 103 /// intended, even if fetched out of order during backfill.
105 104 #[tracing::instrument(skip_all)]
106 105 pub async fn list_all(&self, limit: i64, offset: i64) -> Result<Vec<DbFeedItem>, sqlx::Error> {
107 - sqlx::query_as(
108 - "SELECT * FROM feed_items ORDER BY published_at DESC LIMIT ?1 OFFSET ?2",
109 - )
110 - .bind(limit)
111 - .bind(offset)
112 - .fetch_all(&self.pool)
113 - .await
106 + sqlx::query_as("SELECT * FROM feed_items ORDER BY published_at DESC LIMIT ?1 OFFSET ?2")
107 + .bind(limit)
108 + .bind(offset)
109 + .fetch_all(&self.pool)
110 + .await
114 111 }
115 112
116 113 /// List items belonging to a specific feed, newest first.
@@ -151,7 +148,11 @@ impl ItemsRepository {
151 148
152 149 /// List unread items only, newest first.
153 150 #[tracing::instrument(skip_all)]
154 - pub async fn list_unread(&self, limit: i64, offset: i64) -> Result<Vec<DbFeedItem>, sqlx::Error> {
151 + pub async fn list_unread(
152 + &self,
153 + limit: i64,
154 + offset: i64,
155 + ) -> Result<Vec<DbFeedItem>, sqlx::Error> {
155 156 sqlx::query_as(
156 157 "SELECT * FROM feed_items WHERE is_read = 0 ORDER BY published_at DESC LIMIT ?1 OFFSET ?2",
157 158 )
@@ -269,7 +270,7 @@ impl ItemsRepository {
269 270 sql.push_str(" ORDER BY published_at DESC LIMIT ?1 OFFSET ?2");
270 271
271 272 let mut q = sqlx::query_as::<_, DbFeedItem>(sqlx::AssertSqlSafe(sql.as_str()))
272 - .bind(limit) // ?1
273 + .bind(limit) // ?1
273 274 .bind(offset); // ?2
274 275
275 276 if let Some(src) = source {
@@ -461,4 +462,3 @@ impl ItemsRepository {
461 462 Ok(result.rows_affected())
462 463 }
463 464 }
464 -
@@ -8,9 +8,9 @@ use sqlx::SqlitePool;
8 8
9 9 use bb_interface::{ErrorCategory, StructuredError};
10 10
11 + use crate::TIMESTAMP_FMT;
11 12 use crate::id_types::{BookmarkId, BusserStateId, FeedId, ItemId, QueryFeedId};
12 13 use crate::models::*;
13 - use crate::TIMESTAMP_FMT;
14 14
15 15 /// Sanitize a user-provided search string for FTS5 MATCH syntax.
16 16 ///
@@ -16,8 +16,7 @@ impl QueryFeedsRepository {
16 16 pub async fn create(&self, input: CreateQueryFeed) -> Result<DbQueryFeed, sqlx::Error> {
17 17 let id = QueryFeedId::new();
18 18 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
19 - let rules_json =
20 - serde_json::to_string(&input.rules).unwrap_or_else(|_| "[]".to_string());
19 + let rules_json = serde_json::to_string(&input.rules).unwrap_or_else(|_| "[]".to_string());
21 20
22 21 sqlx::query_as(
23 22 r#"
@@ -62,15 +61,13 @@ impl QueryFeedsRepository {
62 61 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
63 62 let rules_json = serde_json::to_string(rules).unwrap_or_else(|_| "[]".to_string());
64 63
65 - sqlx::query(
66 - "UPDATE query_feeds SET name = ?1, rules = ?2, updated_at = ?3 WHERE id = ?4",
67 - )
68 - .bind(name)
69 - .bind(&rules_json)
70 - .bind(&now)
71 - .bind(id)
72 - .execute(&self.pool)
73 - .await?;
64 + sqlx::query("UPDATE query_feeds SET name = ?1, rules = ?2, updated_at = ?3 WHERE id = ?4")
65 + .bind(name)
66 + .bind(&rules_json)
67 + .bind(&now)
68 + .bind(id)
69 + .execute(&self.pool)
70 + .await?;
74 71 Ok(())
75 72 }
76 73
@@ -86,4 +83,3 @@ impl QueryFeedsRepository {
86 83 }
87 84
88 85 // ── BookmarksRepository ──────────────────────────────────────────
89 -