max / balanced_breakfast
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 | - |