Skip to main content

max / balanced_breakfast

Adopt lint block, pin stable toolchain, cargo fmt, fix clippy Wire the shared clippy::pedantic block across all members, pin channel=stable, normalize with cargo fmt, and reach green under -D warnings: #[must_use] on builders, explicit imports over wildcard, pub(crate) for private-module items, write!/writeln! over format!-push, clone_from, let-else, borrow spawn args. Scoped #[allow]s (with reasons) for Tauri command handlers and a map_err callback. No rhai/plugin, SQL, or serde-field changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-24 15:42 UTC
Commit: d7ef3a63878e11e0c3fa5ab19b6a9880358679ca
Parent: f474d91
85 files changed, +658 insertions, -597 deletions
M CONTRIBUTING.md +2 -2
@@ -108,7 +108,7 @@ pub async fn list_items(
108 108
109 109 **Rules:**
110 110 - Every command: `#[instrument(skip_all)]`, returns `Result<T, ApiError>`
111 - - No business logic in commands — that belongs in library crates
111 + - No business logic in commands, that belongs in library crates
112 112 - Response types use `#[serde(rename_all = "camelCase")]` for JS consumption
113 113
114 114 ## Error Types
@@ -250,7 +250,7 @@ Always verify: plugin fetch → DB upsert → command response → JS render. A
250 250
251 251 1. Create a `.rhai` file in `plugins/` implementing the 4 required functions
252 252 2. Test it manually: add a feed with the new busser, trigger a fetch
253 - 3. The orchestrator auto-discovers plugins on startup — no Rust changes needed
253 + 3. The orchestrator auto-discovers plugins on startup, no Rust changes needed
254 254
255 255 ## When Adding Features
256 256
M Cargo.lock +65 -106
@@ -492,7 +492,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
492 492
493 493 [[package]]
494 494 name = "bb-core"
495 - version = "0.3.1"
495 + version = "0.3.3"
496 496 dependencies = [
497 497 "aes-gcm",
498 498 "base64 0.22.1",
@@ -523,7 +523,7 @@ dependencies = [
523 523
524 524 [[package]]
525 525 name = "bb-db"
526 - version = "0.3.1"
526 + version = "0.3.3"
527 527 dependencies = [
528 528 "bb-interface",
529 529 "chrono",
@@ -538,7 +538,7 @@ dependencies = [
538 538
539 539 [[package]]
540 540 name = "bb-feed"
541 - version = "0.3.1"
541 + version = "0.3.3"
542 542 dependencies = [
543 543 "bb-db",
544 544 "bb-interface",
@@ -553,7 +553,7 @@ dependencies = [
553 553
554 554 [[package]]
555 555 name = "bb-interface"
556 - version = "0.3.1"
556 + version = "0.3.3"
557 557 dependencies = [
558 558 "chrono",
559 559 "serde",
@@ -562,7 +562,7 @@ dependencies = [
562 562
563 563 [[package]]
564 564 name = "bb-pdf"
565 - version = "0.3.1"
565 + version = "0.3.3"
566 566 dependencies = [
567 567 "printpdf",
568 568 "pter 0.1.1",
@@ -998,7 +998,7 @@ dependencies = [
998 998 "bitflags 2.13.1",
999 999 "core-foundation 0.10.1",
1000 1000 "core-graphics-types",
1001 - "foreign-types 0.5.0",
1001 + "foreign-types",
1002 1002 "libc",
1003 1003 ]
1004 1004
@@ -1234,6 +1234,32 @@ dependencies = [
1234 1234 ]
1235 1235
1236 1236 [[package]]
1237 + name = "curve25519-dalek"
1238 + version = "4.1.3"
1239 + source = "registry+https://github.com/rust-lang/crates.io-index"
1240 + checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
1241 + dependencies = [
1242 + "cfg-if",
1243 + "cpufeatures 0.2.17",
1244 + "curve25519-dalek-derive",
1245 + "fiat-crypto",
1246 + "rustc_version",
1247 + "subtle",
1248 + "zeroize",
1249 + ]
1250 +
1251 + [[package]]
1252 + name = "curve25519-dalek-derive"
1253 + version = "0.1.1"
1254 + source = "registry+https://github.com/rust-lang/crates.io-index"
1255 + checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
1256 + dependencies = [
1257 + "proc-macro2",
1258 + "quote",
1259 + "syn 2.0.114",
1260 + ]
1261 +
1262 + [[package]]
1237 1263 name = "darling"
1238 1264 version = "0.21.3"
1239 1265 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1654,6 +1680,12 @@ dependencies = [
1654 1680 ]
1655 1681
1656 1682 [[package]]
1683 + name = "fiat-crypto"
1684 + version = "0.2.9"
1685 + source = "registry+https://github.com/rust-lang/crates.io-index"
1686 + checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
1687 +
1688 + [[package]]
1657 1689 name = "field-offset"
1658 1690 version = "0.3.6"
1659 1691 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1715,21 +1747,12 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
1715 1747
1716 1748 [[package]]
1717 1749 name = "foreign-types"
1718 - version = "0.3.2"
1719 - source = "registry+https://github.com/rust-lang/crates.io-index"
1720 - checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
1721 - dependencies = [
1722 - "foreign-types-shared 0.1.1",
1723 - ]
1724 -
1725 - [[package]]
1726 - name = "foreign-types"
1727 1750 version = "0.5.0"
1728 1751 source = "registry+https://github.com/rust-lang/crates.io-index"
1729 1752 checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
1730 1753 dependencies = [
1731 1754 "foreign-types-macros",
1732 - "foreign-types-shared 0.3.1",
1755 + "foreign-types-shared",
1733 1756 ]
1734 1757
1735 1758 [[package]]
@@ -1745,12 +1768,6 @@ dependencies = [
1745 1768
1746 1769 [[package]]
1747 1770 name = "foreign-types-shared"
1748 - version = "0.1.1"
1749 - source = "registry+https://github.com/rust-lang/crates.io-index"
1750 - checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
1751 -
1752 - [[package]]
1753 - name = "foreign-types-shared"
1754 1771 version = "0.3.1"
1755 1772 source = "registry+https://github.com/rust-lang/crates.io-index"
1756 1773 checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
@@ -2494,22 +2511,6 @@ dependencies = [
2494 2511 ]
2495 2512
2496 2513 [[package]]
2497 - name = "hyper-tls"
2498 - version = "0.6.0"
2499 - source = "registry+https://github.com/rust-lang/crates.io-index"
2500 - checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
2501 - dependencies = [
2502 - "bytes",
2503 - "http-body-util",
2504 - "hyper",
2505 - "hyper-util",
2506 - "native-tls",
2507 - "tokio",
2508 - "tokio-native-tls",
2509 - "tower-service",
2510 - ]
2511 -
2512 - [[package]]
2513 2514 name = "hyper-util"
2514 2515 version = "0.1.20"
2515 2516 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3309,23 +3310,6 @@ dependencies = [
3309 3310 ]
3310 3311
3311 3312 [[package]]
3312 - name = "native-tls"
3313 - version = "0.2.18"
3314 - source = "registry+https://github.com/rust-lang/crates.io-index"
3315 - checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
3316 - dependencies = [
3317 - "libc",
3318 - "log",
3319 - "openssl",
3320 - "openssl-probe",
3321 - "openssl-sys",
3322 - "schannel",
3323 - "security-framework",
3324 - "security-framework-sys",
3325 - "tempfile",
3326 - ]
3327 -
3328 - [[package]]
3329 3313 name = "ndk"
3330 3314 version = "0.9.0"
3331 3315 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3751,50 +3735,12 @@ dependencies = [
3751 3735 ]
3752 3736
3753 3737 [[package]]
3754 - name = "openssl"
3755 - version = "0.10.75"
3756 - source = "registry+https://github.com/rust-lang/crates.io-index"
3757 - checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
3758 - dependencies = [
3759 - "bitflags 2.13.1",
3760 - "cfg-if",
3761 - "foreign-types 0.3.2",
3762 - "libc",
3763 - "once_cell",
3764 - "openssl-macros",
3765 - "openssl-sys",
3766 - ]
3767 -
3768 - [[package]]
3769 - name = "openssl-macros"
3770 - version = "0.1.1"
3771 - source = "registry+https://github.com/rust-lang/crates.io-index"
3772 - checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
3773 - dependencies = [
3774 - "proc-macro2",
3775 - "quote",
3776 - "syn 2.0.114",
3777 - ]
3778 -
3779 - [[package]]
3780 3738 name = "openssl-probe"
3781 3739 version = "0.2.1"
3782 3740 source = "registry+https://github.com/rust-lang/crates.io-index"
3783 3741 checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
3784 3742
3785 3743 [[package]]
3786 - name = "openssl-sys"
3787 - version = "0.9.111"
3788 - source = "registry+https://github.com/rust-lang/crates.io-index"
3789 - checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
3790 - dependencies = [
3791 - "cc",
3792 - "libc",
3793 - "pkg-config",
3794 - "vcpkg",
3795 - ]
3796 -
3797 - [[package]]
3798 3744 name = "option-ext"
3799 3745 version = "0.2.0"
3800 3746 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4680,12 +4626,10 @@ dependencies = [
4680 4626 "http-body-util",
4681 4627 "hyper",
4682 4628 "hyper-rustls",
4683 - "hyper-tls",
4684 4629 "hyper-util",
4685 4630 "js-sys",
4686 4631 "log",
4687 4632 "mime",
4688 - "native-tls",
4689 4633 "percent-encoding",
4690 4634 "pin-project-lite",
4691 4635 "rustls",
@@ -4696,7 +4640,6 @@ dependencies = [
4696 4640 "serde_urlencoded",
4697 4641 "sync_wrapper",
4698 4642 "tokio",
4699 - "tokio-native-tls",
4700 4643 "tokio-rustls",
4701 4644 "tokio-util",
4702 4645 "tower",
@@ -5951,6 +5894,7 @@ dependencies = [
5951 5894 "unicode-normalization",
5952 5895 "urlencoding",
5953 5896 "uuid",
5897 + "x25519-dalek",
5954 5898 "zeroize",
5955 5899 ]
5956 5900
@@ -6614,16 +6558,6 @@ dependencies = [
6614 6558 ]
6615 6559
6616 6560 [[package]]
6617 - name = "tokio-native-tls"
6618 - version = "0.3.1"
6619 - source = "registry+https://github.com/rust-lang/crates.io-index"
6620 - checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
6621 - dependencies = [
6622 - "native-tls",
6623 - "tokio",
6624 - ]
6625 -
6626 - [[package]]
6627 6561 name = "tokio-rustls"
6628 6562 version = "0.26.4"
6629 6563 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8058,6 +7992,17 @@ dependencies = [
8058 7992 ]
8059 7993
8060 7994 [[package]]
7995 + name = "x25519-dalek"
7996 + version = "2.0.1"
7997 + source = "registry+https://github.com/rust-lang/crates.io-index"
7998 + checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
7999 + dependencies = [
8000 + "curve25519-dalek",
8001 + "rand_core 0.6.4",
8002 + "zeroize",
8003 + ]
8004 +
8005 + [[package]]
8061 8006 name = "xattr"
8062 8007 version = "1.6.1"
8063 8008 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8226,6 +8171,20 @@ name = "zeroize"
8226 8171 version = "1.8.2"
8227 8172 source = "registry+https://github.com/rust-lang/crates.io-index"
8228 8173 checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
8174 + dependencies = [
8175 + "zeroize_derive",
8176 + ]
8177 +
8178 + [[package]]
8179 + name = "zeroize_derive"
8180 + version = "1.5.0"
8181 + source = "registry+https://github.com/rust-lang/crates.io-index"
8182 + checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
8183 + dependencies = [
8184 + "proc-macro2",
8185 + "quote",
8186 + "syn 2.0.114",
8187 + ]
8229 8188
8230 8189 [[package]]
8231 8190 name = "zerotrie"
M Cargo.toml +32
@@ -73,3 +73,35 @@ tagtree = { path = "../../MNW/shared/tagtree" }
73 73 regex = "1"
74 74 zeroize = "1"
75 75 wiremock = "0.6"
76 +
77 + [workspace.lints.rust]
78 + unused = "warn"
79 + unreachable_pub = "warn"
80 +
81 + [workspace.lints.clippy]
82 + pedantic = { level = "warn", priority = -1 }
83 + # Allow-list tuned from a measured breakdown across server/multithreaded/pter
84 + # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
85 + # else in `pedantic` stays a warning. Keep this block identical across repos.
86 + module_name_repetitions = "allow"
87 + # Doc lints. No docs-completeness push is underway.
88 + missing_errors_doc = "allow"
89 + missing_panics_doc = "allow"
90 + doc_markdown = "allow"
91 + # Numeric casts. Endemic and mostly intentional in size and byte math.
92 + cast_possible_truncation = "allow"
93 + cast_sign_loss = "allow"
94 + cast_precision_loss = "allow"
95 + cast_possible_wrap = "allow"
96 + cast_lossless = "allow"
97 + # Subjective structure and style nags. High churn, low signal.
98 + must_use_candidate = "allow"
99 + too_many_lines = "allow"
100 + struct_excessive_bools = "allow"
101 + similar_names = "allow"
102 + items_after_statements = "allow"
103 + single_match_else = "allow"
104 + # Frequent false-positives in TUI and router-heavy code.
105 + match_same_arms = "allow"
106 + unnecessary_wraps = "allow"
107 + type_complexity = "allow"
@@ -56,3 +56,6 @@ futures = { workspace = true }
56 56
57 57 # Article extraction (reader view)
58 58 readable-readability = "0.4"
59 +
60 + [lints]
61 + workspace = true
@@ -59,7 +59,7 @@ fn load_or_create_key(path: &Path) -> Result<EncryptionKey, String> {
59 59 Ok(key)
60 60 }
61 61 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
62 - // File already exists — read it.
62 + // File already exists, read it.
63 63 let data =
64 64 std::fs::read(path).map_err(|e| format!("Failed to read encryption key: {e}"))?;
65 65 if data.len() != 32 {
@@ -100,7 +100,7 @@ pub fn load_or_create_key_from_keychain(file_path: &Path) -> Result<EncryptionKe
100 100 return Ok(key);
101 101 }
102 102 Err(keyring::Error::NoEntry) => {
103 - // No key in keychain — check if file exists to migrate
103 + // No key in keychain, check if file exists to migrate
104 104 if file_path.exists() {
105 105 let key = load_or_create_key(file_path)?;
106 106 // Migrate to keychain
@@ -125,7 +125,7 @@ pub fn load_or_create_key_from_keychain(file_path: &Path) -> Result<EncryptionKe
125 125 return Ok(key);
126 126 }
127 127 // No file either. Either this is a first run, or the credential
128 - // store lost the key it was holding — keyring's `linux-native`
128 + // store lost the key it was holding, keyring's `linux-native`
129 129 // (keyutils) backing store was in-memory only and dropped every
130 130 // entry on reboot, so installs that migrated to it and had the
131 131 // file fallback deleted below land here with existing secrets
@@ -142,11 +142,11 @@ pub fn load_or_create_key_from_keychain(file_path: &Path) -> Result<EncryptionKe
142 142 if entry.set_password(&b64).is_ok() {
143 143 return Ok(key);
144 144 }
145 - // Keychain write failed — fall back to file
145 + // Keychain write failed, fall back to file
146 146 tracing::warn!("Keychain write failed, falling back to file-based key");
147 147 }
148 148 Err(_) => {
149 - // Keychain read error — fall back to file
149 + // Keychain read error, fall back to file
150 150 tracing::warn!("Keychain unavailable, falling back to file-based key");
151 151 }
152 152 }
@@ -70,11 +70,9 @@ impl Orchestrator {
70 70 pub async fn new(config: OrchestratorConfig) -> Result<Self, OrchestratorError> {
71 71 info!("Initializing orchestrator");
72 72
73 - // Connect to database
74 73 let db = Database::connect(&config.database_url).await?;
75 74 info!("Connected to database");
76 75
77 - // Create plugin manager
78 76 let plugins = PluginManager::new(&config.plugins_dir);
79 77
80 78 Ok(Self {
@@ -90,7 +88,7 @@ impl Orchestrator {
90 88 self.db
91 89 .migrate()
92 90 .await
93 - .map_err(|e| OrchestratorError::Config(format!("Migration failed: {}", e)))?;
91 + .map_err(|e| OrchestratorError::Config(format!("Migration failed: {e}")))?;
94 92 info!("Database migrations complete");
95 93 Ok(())
96 94 }
@@ -248,7 +246,7 @@ impl Orchestrator {
248 246
249 247 // Store items in database
250 248 let mut count = 0;
251 - for item in result.items.iter() {
249 + for item in &result.items {
252 250 let mut create_item = bb_db::CreateFeedItem::from_feed_item(item, feed_id);
253 251
254 252 // Strip tracking parameters from item URL and body
@@ -351,8 +349,9 @@ impl Orchestrator {
351 349 let plugins = self.plugins.read().await;
352 350 plugins
353 351 .get_capabilities(plugin_id)
354 - .map(|c| c.fetch_interval_secs)
355 - .unwrap_or(bb_interface::DEFAULT_FETCH_INTERVAL_SECS)
352 + .map_or(bb_interface::DEFAULT_FETCH_INTERVAL_SECS, |c| {
353 + c.fetch_interval_secs
354 + })
356 355 }
357 356
358 357 /// Set the encryption key for Secret-field encryption at rest.
@@ -379,9 +378,8 @@ impl Orchestrator {
379 378 let plugins = self.plugins.read().await;
380 379
381 380 for feed in &all_feeds {
382 - let schema = match plugins.get_config_schema(feed.busser_id.as_str()) {
383 - Some(s) => s,
384 - None => continue,
381 + let Some(schema) = plugins.get_config_schema(feed.busser_id.as_str()) else {
382 + continue;
385 383 };
386 384
387 385 // Check if any Secret field needs encryption
@@ -435,7 +433,7 @@ mod tests {
435 433 /// Build an `OrchestratorConfig` that uses in-memory SQLite and an empty
436 434 /// temp directory for plugins.
437 435 fn test_config(suffix: &str) -> OrchestratorConfig {
438 - let dir = temp_dir().join(format!("bb_orch_test_{}", suffix));
436 + let dir = temp_dir().join(format!("bb_orch_test_{suffix}"));
439 437 let _ = std::fs::create_dir_all(&dir);
440 438 OrchestratorConfig {
441 439 database_url: "sqlite::memory:".to_string(),
@@ -218,7 +218,9 @@ impl PluginManager {
218 218 /// Get plugin capabilities
219 219 #[tracing::instrument(skip_all)]
220 220 pub fn get_capabilities(&self, plugin_id: &str) -> Option<BusserCapabilities> {
221 - self.rhai_manager.get(plugin_id).map(|p| p.capabilities())
221 + self.rhai_manager
222 + .get(plugin_id)
223 + .map(super::rhai_plugin::RhaiPlugin::capabilities)
222 224 }
223 225
224 226 /// Get plugin configuration schema
@@ -1,4 +1,8 @@
1 - use super::*;
1 + use super::{
2 + BiteDisplay, BusserCapabilities, BusserConfig, ConfigField, ConfigFieldType, ConfigSchema,
3 + Dynamic, FeedItem, FeedItemContent, FeedItemId, FeedItemMeta, FetchResult, ItemAction, Map,
4 + RhaiPluginError,
5 + };
2 6 use tracing::warn;
3 7
4 8 /// Convert BusserConfig to Rhai Dynamic map.
@@ -93,7 +97,7 @@ pub(crate) fn dynamic_to_config_schema(val: Dynamic) -> Result<ConfigSchema, Rha
93 97 {
94 98 field.options = opts
95 99 .into_iter()
96 - .filter_map(|v| v.try_cast::<String>())
100 + .filter_map(rhai::Dynamic::try_cast::<String>)
97 101 .collect();
98 102 }
99 103
@@ -107,9 +111,8 @@ pub(crate) fn dynamic_to_config_schema(val: Dynamic) -> Result<ConfigSchema, Rha
107 111
108 112 /// Convert Dynamic to BusserCapabilities.
109 113 pub(crate) fn dynamic_to_capabilities(val: Dynamic) -> BusserCapabilities {
110 - let map = match val.try_cast::<Map>() {
111 - Some(m) => m,
112 - None => return BusserCapabilities::default(),
114 + let Some(map) = val.try_cast::<Map>() else {
115 + return BusserCapabilities::default();
113 116 };
114 117
115 118 let defaults = BusserCapabilities::default();
@@ -142,8 +145,7 @@ pub(crate) fn dynamic_to_capabilities(val: Dynamic) -> BusserCapabilities {
142 145 fetch_interval_secs: map
143 146 .get("fetch_interval_secs")
144 147 .and_then(|v| v.clone().try_cast::<i64>())
145 - .map(|v| v.max(0) as u64)
146 - .unwrap_or(defaults.fetch_interval_secs),
148 + .map_or(defaults.fetch_interval_secs, |v| v.max(0) as u64),
147 149 }
148 150 }
149 151
@@ -272,7 +274,7 @@ pub(crate) fn dynamic_to_feed_item(
272 274 {
273 275 content.media = media_arr
274 276 .into_iter()
275 - .filter_map(|v| v.try_cast::<String>())
277 + .filter_map(rhai::Dynamic::try_cast::<String>)
276 278 .collect();
277 279 }
278 280
@@ -322,7 +324,7 @@ pub(crate) fn dynamic_to_feed_item(
322 324 {
323 325 meta.tags = tags_arr
324 326 .into_iter()
325 - .filter_map(|v| v.try_cast::<String>())
327 + .filter_map(rhai::Dynamic::try_cast::<String>)
326 328 .collect();
327 329 }
328 330 meta
@@ -1,8 +1,8 @@
1 - use super::*;
1 + use super::{Dynamic, Map};
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 = 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}"))?;
6 6
7 7 fn element_to_dynamic(node: roxmltree::Node) -> Dynamic {
8 8 let mut map = Map::new();
@@ -15,7 +15,7 @@ pub(crate) fn parse_xml_to_dynamic(xml_str: &str) -> Result<Dynamic, Box<rhai::E
15 15 // Text content
16 16 let text: String = node
17 17 .children()
18 - .filter(|n| n.is_text())
18 + .filter(roxmltree::Node::is_text)
19 19 .map(|n| n.text().unwrap_or(""))
20 20 .collect();
21 21 if !text.trim().is_empty() {
@@ -34,7 +34,7 @@ pub(crate) fn parse_xml_to_dynamic(xml_str: &str) -> Result<Dynamic, Box<rhai::E
34 34 // Children
35 35 let children: rhai::Array = node
36 36 .children()
37 - .filter(|n| n.is_element())
37 + .filter(roxmltree::Node::is_element)
38 38 .map(element_to_dynamic)
39 39 .collect();
40 40 if !children.is_empty() {
@@ -49,7 +49,7 @@ pub(crate) fn parse_xml_to_dynamic(xml_str: &str) -> Result<Dynamic, Box<rhai::E
49 49
50 50 /// Parse RSS/Atom feed XML into a feed-friendly structure.
51 51 pub(crate) fn parse_feed_xml(xml_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
52 - let doc = 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}"))?;
53 53 let root = doc.root_element();
54 54
55 55 let mut feed = Map::new();
@@ -60,7 +60,7 @@ pub(crate) fn parse_feed_xml(xml_str: &str) -> Result<Dynamic, Box<rhai::EvalAlt
60 60 let is_rss = root.tag_name().name() == "rss" || root.tag_name().name() == "RDF";
61 61
62 62 if is_atom {
63 - for child in root.children().filter(|n| n.is_element()) {
63 + for child in root.children().filter(roxmltree::Node::is_element) {
64 64 match child.tag_name().name() {
65 65 "title" => {
66 66 feed.insert("title".into(), get_text_content(&child).into());
@@ -82,7 +82,7 @@ pub(crate) fn parse_feed_xml(xml_str: &str) -> Result<Dynamic, Box<rhai::EvalAlt
82 82 .find(|n| n.is_element() && n.tag_name().name() == "channel");
83 83
84 84 if let Some(channel) = channel {
85 - for child in channel.children().filter(|n| n.is_element()) {
85 + for child in channel.children().filter(roxmltree::Node::is_element) {
86 86 match child.tag_name().name() {
87 87 "title" => {
88 88 feed.insert("title".into(), get_text_content(&child).into());
@@ -110,7 +110,7 @@ fn get_text_content(node: &roxmltree::Node) -> String {
110 110 fn parse_atom_entry(node: &roxmltree::Node) -> Dynamic {
111 111 let mut entry = Map::new();
112 112
113 - for child in node.children().filter(|n| n.is_element()) {
113 + for child in node.children().filter(roxmltree::Node::is_element) {
114 114 match child.tag_name().name() {
115 115 "id" => {
116 116 entry.insert("id".into(), get_text_content(&child).into());
@@ -158,7 +158,7 @@ fn parse_atom_entry(node: &roxmltree::Node) -> Dynamic {
158 158 fn parse_rss_item(node: &roxmltree::Node) -> Dynamic {
159 159 let mut item = Map::new();
160 160
161 - for child in node.children().filter(|n| n.is_element()) {
161 + for child in node.children().filter(roxmltree::Node::is_element) {
162 162 match child.tag_name().name() {
163 163 "guid" => {
164 164 item.insert("id".into(), get_text_content(&child).into());
@@ -211,7 +211,7 @@ fn parse_rss_item(node: &roxmltree::Node) -> Dynamic {
211 211 .unwrap_or_default();
212 212 let published = item
213 213 .get("published")
214 - .map(|v| v.to_string())
214 + .map(std::string::ToString::to_string)
215 215 .unwrap_or_default();
216 216
217 217 use std::hash::{Hash, Hasher};
@@ -220,7 +220,7 @@ fn parse_rss_item(node: &roxmltree::Node) -> Dynamic {
220 220 summary.hash(&mut hasher);
221 221 published.hash(&mut hasher);
222 222 let hash = hasher.finish();
223 - item.insert("id".into(), Dynamic::from(format!("synth-{:016x}", hash)));
223 + item.insert("id".into(), Dynamic::from(format!("synth-{hash:016x}")));
224 224 }
225 225 }
226 226
@@ -1,4 +1,4 @@
1 - use super::*;
1 + use super::{Dynamic, Map};
2 2
3 3 /// Convert serde_json::Value to Rhai Dynamic.
4 4 pub(crate) fn json_to_dynamic(val: serde_json::Value) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
@@ -35,14 +35,14 @@ pub(crate) fn json_to_dynamic(val: serde_json::Value) -> Result<Dynamic, Box<rha
35 35 /// JSON Feed spec: <https://www.jsonfeed.org/version/1.1/>
36 36 pub(crate) fn parse_json_feed(json_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
37 37 let json: serde_json::Value =
38 - serde_json::from_str(json_str).map_err(|e| format!("JSON parse error: {}", e))?;
38 + serde_json::from_str(json_str).map_err(|e| format!("JSON parse error: {e}"))?;
39 39
40 40 let obj = json.as_object().ok_or("JSON Feed root must be an object")?;
41 41
42 42 // Validate it's a JSON Feed by checking the version field
43 43 let version = obj.get("version").and_then(|v| v.as_str()).unwrap_or("");
44 44 if !version.starts_with("https://jsonfeed.org/version/") {
45 - return Err(format!("Not a valid JSON Feed: version = {:?}", version).into());
45 + return Err(format!("Not a valid JSON Feed: version = {version:?}").into());
46 46 }
47 47
48 48 let mut feed = Map::new();
@@ -4,13 +4,13 @@
4 4 //!
5 5 //! A Rhai plugin's `fetch(config, cursor)` function must return a map with:
6 6 //!
7 - //! - **`items`** (array, required) -- array of item maps, each containing:
7 + //! - **`items`** (array, required): array of item maps, each containing:
8 8 //! - `id` (string or `{source, item_id}` map, required)
9 - //! - `bite` (map, optional) -- `{author, text, secondary?, indicator?}`
10 - //! - `content` (map, optional) -- `{title?, body?, url?, media?}`
11 - //! - `meta` (map, optional) -- `{source_name?, published_at?, score?, tags?}`
12 - //! - **`has_more`** (bool, optional, default `false`) -- signals additional pages.
13 - //! - **`next_cursor`** (string, optional) -- opaque cursor passed to the next
9 + //! - `bite` (map, optional): `{author, text, secondary?, indicator?}`
10 + //! - `content` (map, optional): `{title?, body?, url?, media?}`
11 + //! - `meta` (map, optional): `{source_name?, published_at?, score?, tags?}`
12 + //! - **`has_more`** (bool, optional, default `false`): signals additional pages.
13 + //! - **`next_cursor`** (string, optional): opaque cursor passed to the next
14 14 //! `fetch()` call for pagination.
15 15 //!
16 16 //! Missing optional fields fall back to sensible defaults (empty strings,
@@ -39,7 +39,7 @@ mod tests {
39 39 use rhai::{Dynamic, Map};
40 40 use std::collections::HashMap;
41 41
42 - // --- json_to_dynamic ---
42 + // json_to_dynamic
43 43
44 44 #[test]
45 45 fn json_null_to_unit() {
@@ -95,7 +95,7 @@ mod tests {
95 95 );
96 96 }
97 97
98 - // --- parse_feed_xml (Atom) ---
98 + // parse_feed_xml (Atom)
99 99
100 100 #[test]
101 101 fn parse_atom_feed_title_and_link() {
@@ -160,7 +160,7 @@ mod tests {
160 160 );
161 161 }
162 162
163 - // --- parse_feed_xml (RSS) ---
163 + // parse_feed_xml (RSS)
164 164
165 165 #[test]
166 166 fn parse_rss_feed_title_and_items() {
@@ -260,7 +260,7 @@ mod tests {
260 260 );
261 261 }
262 262
263 - // --- RSS items without guid or link get synthesized ID ---
263 + // RSS items without guid or link get synthesized ID
264 264
265 265 #[test]
266 266 fn rss_item_no_guid_no_link_gets_synthesized_id() {
@@ -294,8 +294,7 @@ mod tests {
294 294 .unwrap();
295 295 assert!(
296 296 id.starts_with("synth-"),
297 - "synthesized ID should start with 'synth-', got: {}",
298 - id
297 + "synthesized ID should start with 'synth-', got: {id}"
299 298 );
300 299 }
301 300
@@ -425,7 +424,7 @@ mod tests {
425 424 );
426 425 }
427 426
428 - // --- parse_xml_to_dynamic ---
427 + // parse_xml_to_dynamic
429 428
430 429 #[test]
431 430 fn parse_xml_simple_element() {
@@ -515,7 +514,7 @@ mod tests {
515 514 assert!(result.is_err());
516 515 }
517 516
518 - // --- busser_config_to_dynamic ---
517 + // busser_config_to_dynamic
519 518
520 519 #[test]
521 520 fn config_to_dynamic_options_and_feeds() {
@@ -584,7 +583,7 @@ mod tests {
584 583 assert!(feeds.is_empty());
585 584 }
586 585
587 - // --- dynamic_to_capabilities ---
586 + // dynamic_to_capabilities
588 587
589 588 #[test]
590 589 fn capabilities_defaults_from_empty_map() {
@@ -631,7 +630,7 @@ mod tests {
631 630 assert_eq!(caps.fetch_interval_secs, 0);
632 631 }
633 632
634 - // --- dynamic_to_config_schema ---
633 + // dynamic_to_config_schema
635 634
636 635 #[test]
637 636 fn config_schema_with_fields() {
@@ -679,7 +678,7 @@ mod tests {
679 678 assert!(matches!(schema.fields[0].field_type, ConfigFieldType::Text));
680 679 }
681 680
682 - // --- dynamic_to_fetch_result ---
681 + // dynamic_to_fetch_result
683 682
684 683 #[test]
685 684 fn fetch_result_with_items() {
@@ -716,7 +715,7 @@ mod tests {
716 715 assert!(result.is_err());
717 716 }
718 717
719 - // --- dynamic_to_feed_item ---
718 + // dynamic_to_feed_item
720 719
721 720 #[test]
722 721 fn feed_item_string_id() {
@@ -778,7 +777,7 @@ mod tests {
778 777 assert_eq!(item.content.url.as_deref(), Some("https://example.com"));
779 778 }
780 779
781 - // --- parse_json_feed ---
780 + // parse_json_feed
782 781
783 782 #[test]
784 783 fn parse_json_feed_1_1_with_all_fields() {
@@ -32,7 +32,7 @@ const MAX_REQUESTS_PER_FETCH: usize = 100;
32 32 /// Checked on each HTTP request. Prevents plugins from hanging the app
33 33 /// even if individual requests are fast (100 requests × 15s = 25 min worst case
34 34 /// without this limit).
35 - pub(super) const MAX_FETCH_DURATION: Duration = Duration::from_secs(60);
35 + pub(super) const MAX_FETCH_DURATION: Duration = Duration::from_mins(1);
36 36
37 37 /// Check if a host is in the CGNAT/shared address space (100.64.0.0/10).
38 38 fn is_cgnat(host: &str) -> bool {
@@ -51,8 +51,7 @@ fn validate_url(url: &str) -> Result<(), String> {
51 51 let lower = url.to_ascii_lowercase();
52 52 if !lower.starts_with("http://") && !lower.starts_with("https://") {
53 53 return Err(format!(
54 - "Blocked URL scheme (only http/https allowed): {}",
55 - url
54 + "Blocked URL scheme (only http/https allowed): {url}"
56 55 ));
57 56 }
58 57 // Block localhost and common internal addresses
@@ -66,7 +65,7 @@ fn validate_url(url: &str) -> Result<(), String> {
66 65 host_and_port
67 66 .split(']')
68 67 .next()
69 - .map(|s| format!("{}]", s))
68 + .map(|s| format!("{s}]"))
70 69 .unwrap_or_default()
71 70 } else {
72 71 host_and_port.split(':').next().unwrap_or("").to_string() // strip port
@@ -83,7 +82,7 @@ fn validate_url(url: &str) -> Result<(), String> {
83 82 || host.starts_with("169.254.")
84 83 || is_cgnat(host)
85 84 {
86 - return Err(format!("Blocked request to internal address: {}", url));
85 + return Err(format!("Blocked request to internal address: {url}"));
87 86 }
88 87 // Block IPv6 private/link-local ranges (fc00::/7, fe80::/10)
89 88 // and IPv4-mapped IPv6 addresses (::ffff:x.x.x.x)
@@ -94,7 +93,7 @@ fn validate_url(url: &str) -> Result<(), String> {
94 93 || inner.starts_with("fe80")
95 94 || inner.starts_with("::ffff:")
96 95 {
97 - return Err(format!("Blocked request to internal address: {}", url));
96 + return Err(format!("Blocked request to internal address: {url}"));
98 97 }
99 98 }
100 99 // Block 172.16.0.0/12
@@ -103,17 +102,17 @@ fn validate_url(url: &str) -> Result<(), String> {
103 102 && let Ok(n) = second.parse::<u8>()
104 103 && (16..=31).contains(&n)
105 104 {
106 - return Err(format!("Blocked request to internal address: {}", url));
105 + return Err(format!("Blocked request to internal address: {url}"));
107 106 }
108 107 // Block encoded IP forms: decimal (2130706433), hex (0x7f000001),
109 108 // and octal-prefixed octets (0177.0.0.1). These bypass string-based
110 109 // checks but are resolved by ureq to actual IPs.
111 110 if host.starts_with("0x") || host.starts_with("0X") {
112 - return Err(format!("Blocked hex-encoded IP address: {}", url));
111 + return Err(format!("Blocked hex-encoded IP address: {url}"));
113 112 }
114 113 // Pure-numeric hosts are decimal-encoded IPs (e.g. 2130706433 = 127.0.0.1)
115 114 if host.chars().all(|c| c.is_ascii_digit()) && !host.is_empty() {
116 - return Err(format!("Blocked numeric IP address: {}", url));
115 + return Err(format!("Blocked numeric IP address: {url}"));
117 116 }
118 117 // Octal-prefixed octets (e.g. 0177.0.0.1)
119 118 if host.contains('.') {
@@ -121,7 +120,7 @@ fn validate_url(url: &str) -> Result<(), String> {
121 120 octet.len() > 1 && octet.starts_with('0') && octet.chars().all(|c| c.is_ascii_digit())
122 121 });
123 122 if has_octal {
124 - return Err(format!("Blocked octal-encoded IP address: {}", url));
123 + return Err(format!("Blocked octal-encoded IP address: {url}"));
125 124 }
126 125 }
127 126 Ok(())
@@ -132,8 +131,7 @@ fn check_request_limit(counter: &AtomicUsize) -> Result<(), String> {
132 131 let prev = counter.fetch_add(1, Ordering::Relaxed);
133 132 if prev >= MAX_REQUESTS_PER_FETCH {
134 133 Err(format!(
135 - "HTTP request limit exceeded ({} per fetch)",
136 - MAX_REQUESTS_PER_FETCH
134 + "HTTP request limit exceeded ({MAX_REQUESTS_PER_FETCH} per fetch)"
137 135 ))
138 136 } else {
139 137 Ok(())
@@ -195,7 +193,7 @@ const DEFAULT_RETRY_AFTER_SECS: u64 = 60;
195 193 /// that carries error category information through the Rhai string-error
196 194 /// channel. Status codes never reach here: the agent turns off
197 195 /// `http_status_as_error`, so they are handled by `format_status_error`.
198 - fn format_ureq_error(err: ureq::Error) -> String {
196 + fn format_ureq_error(err: &ureq::Error) -> String {
199 197 format!("{BB_ERR_PREFIX}transient:Transport error: {err}")
200 198 }
201 199
@@ -211,7 +209,7 @@ fn format_status_error(resp: &mut ureq::http::Response<ureq::Body>) -> Option<St
211 209 return None;
212 210 }
213 211
214 - let retry_after = parse_retry_after(&resp.headers().get("retry-after").cloned());
212 + let retry_after = parse_retry_after(resp.headers().get("retry-after"));
215 213
216 214 // Cap error body read to prevent OOM on malicious large error responses
217 215 let mut bytes = Vec::new();
@@ -243,8 +241,8 @@ fn format_status_error(resp: &mut ureq::http::Response<ureq::Body>) -> Option<St
243 241 /// Only the delta-seconds form is honoured. The HTTP-date form is rare in
244 242 /// practice and a bad clock would turn it into a wildly wrong backoff, so an
245 243 /// unparseable value falls back to the default rather than being guessed at.
246 - fn parse_retry_after(header: &Option<ureq::http::HeaderValue>) -> Option<u64> {
247 - let secs: u64 = header.as_ref()?.to_str().ok()?.trim().parse().ok()?;
244 + fn parse_retry_after(header: Option<&ureq::http::HeaderValue>) -> Option<u64> {
245 + let secs: u64 = header?.to_str().ok()?.trim().parse().ok()?;
248 246 // Clamp to a day so a hostile server cannot park a plugin indefinitely.
249 247 Some(secs.min(24 * 60 * 60))
250 248 }
@@ -255,7 +253,7 @@ fn parse_retry_after(header: &Option<ureq::http::HeaderValue>) -> Option<u64> {
255 253 ///
256 254 /// `http_get` and `http_get_json` allow plugins to fetch any URL without
257 255 /// restriction. This is acceptable because plugins are local `.rhai` files
258 - /// that the user explicitly installs into their plugins directory — they are
256 + /// that the user explicitly installs into their plugins directory, they are
259 257 /// not downloaded or executed automatically. Users should only install plugins
260 258 /// they trust, similar to shell scripts. If BB ever supports remote/untrusted
261 259 /// plugin sources, HTTP sandboxing (domain allowlist or per-plugin permissions)
@@ -291,7 +289,10 @@ fn register_http_fns(
291 289 check_request_limit(&counter)?;
292 290 check_fetch_deadline(&deadline)?;
293 291
294 - let mut response = agent_clone.get(url).call().map_err(format_ureq_error)?;
292 + let mut response = agent_clone
293 + .get(url)
294 + .call()
295 + .map_err(|e| format_ureq_error(&e))?;
295 296 if let Some(e) = format_status_error(&mut response) {
296 297 return Err(e.into());
297 298 }
@@ -302,7 +303,7 @@ fn register_http_fns(
302 303 .into_reader()
303 304 .take(MAX_RESPONSE_BYTES)
304 305 .read_to_end(&mut bytes)
305 - .map_err(|e| format!("Failed to read response: {}", e))?;
306 + .map_err(|e| format!("Failed to read response: {e}"))?;
306 307 Ok(String::from_utf8_lossy(&bytes).into_owned())
307 308 },
308 309 );
@@ -317,7 +318,7 @@ fn register_http_fns(
317 318 check_request_limit(&counter)?;
318 319 check_fetch_deadline(&deadline)?;
319 320
320 - let mut response = agent.get(url).call().map_err(format_ureq_error)?;
321 + let mut response = agent.get(url).call().map_err(|e| format_ureq_error(&e))?;
321 322 if let Some(e) = format_status_error(&mut response) {
322 323 return Err(e.into());
323 324 }
@@ -328,7 +329,7 @@ fn register_http_fns(
328 329 .into_reader()
329 330 .take(MAX_RESPONSE_BYTES)
330 331 .read_to_end(&mut body)
331 - .map_err(|e| format!("Failed to read response: {}", e))?;
332 + .map_err(|e| format!("Failed to read response: {e}"))?;
332 333
333 334 let json: serde_json::Value = serde_json::from_slice(&body)
334 335 .map_err(|e| format!("{BB_ERR_PREFIX}parse:JSON parse failed: {e}"))?;
@@ -353,7 +354,7 @@ fn register_parse_fns(engine: &mut Engine) {
353 354
354 355 // Parse XML string to a nested Dynamic map. "Simplified structure" means
355 356 // each XML element becomes a map with "name", "attrs", "text", and "children"
356 - // keys — not a full DOM, but enough for plugins to walk RSS/Atom structures.
357 + // keys, not a full DOM, but enough for plugins to walk RSS/Atom structures.
357 358 engine.register_fn(
358 359 "parse_xml",
359 360 |xml_str: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> {
@@ -383,7 +384,7 @@ fn register_parse_fns(engine: &mut Engine) {
383 384 chrono::DateTime::parse_from_rfc3339(date_str)
384 385 .or_else(|_| chrono::DateTime::parse_from_rfc2822(date_str))
385 386 .map(|dt| dt.timestamp())
386 - .map_err(|e| format!("Date parse error: {}", e).into())
387 + .map_err(|e| format!("Date parse error: {e}").into())
387 388 },
388 389 );
389 390
@@ -410,7 +411,7 @@ fn register_string_fns(engine: &mut Engine) {
410 411 text.chars().take(max).collect()
411 412 } else {
412 413 let truncated: String = text.chars().take(max - 3).collect();
413 - format!("{}...", truncated)
414 + format!("{truncated}...")
414 415 }
415 416 });
416 417
@@ -419,7 +420,7 @@ fn register_string_fns(engine: &mut Engine) {
419 420 text.contains(pattern)
420 421 });
421 422
422 - // String split — returns a Rhai Array of strings (not an iterator).
423 + // String split, returns a Rhai Array of strings (not an iterator).
423 424 engine.register_fn("str_split", |text: &str, sep: &str| -> rhai::Array {
424 425 text.split(sep)
425 426 .map(|s| Dynamic::from(s.to_string()))
@@ -451,7 +452,7 @@ fn register_util_fns(engine: &mut Engine) {
451 452 pter::convert(html)
452 453 });
453 454
454 - // Debug print — outputs to tracing at debug level, visible in dev console.
455 + // Debug print, outputs to tracing at debug level, visible in dev console.
455 456 engine.register_fn("debug_print", |val: Dynamic| {
456 457 tracing::debug!(?val, "Rhai debug");
457 458 });
@@ -543,7 +544,7 @@ mod tests {
543 544 text.chars().take(max).collect()
544 545 } else {
545 546 let truncated: String = text.chars().take(max - 3).collect();
546 - format!("{}...", truncated)
547 + format!("{truncated}...")
547 548 }
548 549 }
549 550
@@ -560,7 +561,7 @@ mod tests {
560 561 chrono::DateTime::parse_from_rfc3339(date_str)
561 562 .or_else(|_| chrono::DateTime::parse_from_rfc2822(date_str))
562 563 .map(|dt| dt.timestamp())
563 - .map_err(|e| format!("Date parse error: {}", e))
564 + .map_err(|e| format!("Date parse error: {e}"))
564 565 }
565 566
566 567 /// Convert HTML to readable markdown (mirrors the Rhai closure).
@@ -575,7 +576,9 @@ mod tests {
575 576
576 577 /// String split (mirrors the Rhai closure, returns Vec<String> for easier testing).
577 578 fn str_split(text: &str, sep: &str) -> Vec<String> {
578 - text.split(sep).map(|s| s.to_string()).collect()
579 + text.split(sep)
580 + .map(std::string::ToString::to_string)
581 + .collect()
579 582 }
580 583
581 584 /// Current UTC timestamp as Unix epoch seconds (mirrors the Rhai closure).
@@ -278,7 +278,7 @@ impl RhaiPluginManager {
278 278 #[tracing::instrument(skip_all)]
279 279 pub fn load_plugin(&mut self, path: &Path) -> Result<String, RhaiPluginError> {
280 280 let script = std::fs::read_to_string(path)
281 - .map_err(|e| RhaiPluginError::CompileError(format!("Failed to read file: {}", e)))?;
281 + .map_err(|e| RhaiPluginError::CompileError(format!("Failed to read file: {e}")))?;
282 282
283 283 // Create a per-plugin engine with its own atomics
284 284 let mut engine = Engine::new();
@@ -306,19 +306,19 @@ impl RhaiPluginManager {
306 306 let mut scope = Scope::new();
307 307 engine
308 308 .run_ast_with_scope(&mut scope, &ast)
309 - .map_err(|e| RhaiPluginError::CompileError(format!("Top-level eval failed: {}", e)))?;
309 + .map_err(|e| RhaiPluginError::CompileError(format!("Top-level eval failed: {e}")))?;
310 310
311 311 let id: String = engine
312 312 .call_fn(&mut scope, &ast, "id", ())
313 - .map_err(|e| RhaiPluginError::MissingFunction(format!("id(): {}", e)))?;
313 + .map_err(|e| RhaiPluginError::MissingFunction(format!("id(): {e}")))?;
314 314
315 315 let name: String = engine
316 316 .call_fn(&mut scope, &ast, "name", ())
317 - .map_err(|e| RhaiPluginError::MissingFunction(format!("name(): {}", e)))?;
317 + .map_err(|e| RhaiPluginError::MissingFunction(format!("name(): {e}")))?;
318 318
319 319 let _: Dynamic = engine
320 320 .call_fn(&mut scope, &ast, "config_schema", ())
321 - .map_err(|e| RhaiPluginError::MissingFunction(format!("config_schema(): {}", e)))?;
321 + .map_err(|e| RhaiPluginError::MissingFunction(format!("config_schema(): {e}")))?;
322 322
323 323 debug!(%name, %id, "Loaded Rhai plugin");
324 324
@@ -472,9 +472,8 @@ pub fn run_reader_script(url: &str, plugins_dir: &Path) -> Result<ReaderResult,
472 472 let engine = create_engine_inner(true);
473 473
474 474 let plugin_path = plugins_dir.join("reader.rhai");
475 - let script = std::fs::read_to_string(plugin_path).map_err(|e| {
476 - RhaiPluginError::CompileError(format!("Failed to read reader plugin: {}", e))
477 - })?;
475 + let script = std::fs::read_to_string(plugin_path)
476 + .map_err(|e| RhaiPluginError::CompileError(format!("Failed to read reader plugin: {e}")))?;
478 477
479 478 let ast = engine
480 479 .compile(&script)
@@ -633,10 +632,10 @@ mod tests {
633 632 fn sandbox_enforces_call_level_limit() {
634 633 let engine = create_engine();
635 634 // Recursive function that exceeds 32 call levels
636 - let script = r#"
635 + let script = r"
637 636 fn recurse(n) { recurse(n + 1) }
638 637 recurse(0)
639 - "#;
638 + ";
640 639 let result = engine.eval::<()>(script);
641 640 assert!(
642 641 result.is_err(),
@@ -694,7 +693,7 @@ mod tests {
694 693 fn validate_dynamic_sizes_rejects_deep_nesting() {
695 694 // Build a value nested MAX_DEPTH+1 levels deep
696 695 let mut val = Dynamic::from(1_i64);
697 - for _ in 0..MAX_DEPTH + 1 {
696 + for _ in 0..=MAX_DEPTH {
698 697 let mut m = rhai::Map::new();
699 698 m.insert("nested".into(), val);
700 699 val = Dynamic::from(m);
@@ -716,10 +715,10 @@ mod tests {
716 715 #[test]
717 716 fn create_engine_enforces_call_level_limit() {
718 717 let engine = create_engine();
719 - let script = r#"
718 + let script = r"
720 719 fn recurse(n) { recurse(n + 1) }
721 720 recurse(0)
722 - "#;
721 + ";
723 722 let result = engine.eval::<()>(script);
724 723 assert!(
725 724 result.is_err(),
@@ -739,7 +738,7 @@ mod tests {
739 738 let plugins_dir = std::path::Path::new(manifest_dir).join("../../plugins");
740 739 let plugin_path = plugins_dir.join("devto.rhai");
741 740 if !plugin_path.exists() {
742 - eprintln!("Skipping: devto.rhai not found at {:?}", plugin_path);
741 + eprintln!("Skipping: devto.rhai not found at {plugin_path:?}");
743 742 return;
744 743 }
745 744
@@ -14,9 +14,7 @@ pub fn is_single_feed_due(
14 14 interval_secs: u64,
15 15 now: DateTime<Utc>,
16 16 ) -> bool {
17 - let last = last_fetch
18 - .map(parse_timestamp)
19 - .unwrap_or(DateTime::UNIX_EPOCH);
17 + let last = last_fetch.map_or(DateTime::UNIX_EPOCH, parse_timestamp);
20 18 let elapsed = now.signed_duration_since(last);
21 19 elapsed.num_seconds() >= interval_secs as i64
22 20 }
@@ -41,7 +39,7 @@ mod tests {
41 39 use super::*;
42 40 use chrono::{Duration, Utc};
43 41
44 - // --- is_single_feed_due ---
42 + // is_single_feed_due
45 43
46 44 #[test]
47 45 fn is_fetch_due_first_time() {
@@ -52,7 +50,7 @@ mod tests {
52 50
53 51 #[test]
54 52 fn is_fetch_due_overdue() {
55 - // Last fetch was 2 hours ago, interval is 1 hour -- due.
53 + // Last fetch was 2 hours ago, interval is 1 hour, due.
56 54 let now = Utc::now();
57 55 let two_hours_ago = now - Duration::hours(2);
58 56 let ts = two_hours_ago.to_rfc3339();
@@ -61,14 +59,14 @@ mod tests {
61 59
62 60 #[test]
63 61 fn is_fetch_due_not_yet() {
64 - // Last fetch was 5 minutes ago, interval is 1 hour -- not due.
62 + // Last fetch was 5 minutes ago, interval is 1 hour, not due.
65 63 let now = Utc::now();
66 64 let five_min_ago = now - Duration::minutes(5);
67 65 let ts = five_min_ago.to_rfc3339();
68 66 assert!(!is_single_feed_due(Some(&ts), 3600, now));
69 67 }
70 68
71 - // --- any_feed_due ---
69 + // any_feed_due
72 70
73 71 #[test]
74 72 fn any_feed_due_empty() {
@@ -99,7 +97,7 @@ mod tests {
99 97
100 98 #[test]
101 99 fn any_feed_due_never_fetched() {
102 - // One feed never fetched (None) — should be due.
100 + // One feed never fetched (None), should be due.
103 101 let now = Utc::now();
104 102 let five_min_ago = (now - Duration::minutes(5)).to_rfc3339();
105 103 let feeds: Vec<Option<&str>> = vec![Some(five_min_ago.as_str()), None];
@@ -113,7 +111,7 @@ mod tests {
113 111 assert!(any_feed_due(&feeds, 3600, now));
114 112 }
115 113
116 - // --- exponential_backoff_secs ---
114 + // exponential_backoff_secs
117 115
118 116 #[test]
119 117 fn backoff_zero_failures() {
@@ -135,13 +133,13 @@ mod tests {
135 133
136 134 #[test]
137 135 fn backoff_capped_at_max() {
138 - // 2^20 = 1_048_576, but max is 900 → capped at 900
136 + // 2^20 = 1_048_576, but max is 900, so capped at 900
139 137 assert_eq!(exponential_backoff_secs(20, 900), 900);
140 138 }
141 139
142 140 #[test]
143 141 fn backoff_exact_cap() {
144 - // 2^4 = 16, max is 16 → exactly 16
142 + // 2^4 = 16, max is 16, so exactly 16
145 143 assert_eq!(exponential_backoff_secs(4, 16), 16);
146 144 }
147 145
@@ -9,7 +9,7 @@ use std::sync::LazyLock;
9 9 use regex::Regex;
10 10 use url::Url;
11 11
12 - /// Tracking parameter prefixes — any query parameter whose name starts with one
12 + /// Tracking parameter prefixes, any query parameter whose name starts with one
13 13 /// of these (case-insensitive) is stripped.
14 14 const TRACKING_PREFIXES: &[&str] = &["utm_"];
15 15
@@ -59,9 +59,8 @@ fn is_tracking_param(name: &str) -> bool {
59 59 #[tracing::instrument(skip_all)]
60 60 /// Strip known tracking query parameters from a URL string
61 61 pub fn strip_tracking_params(url_str: &str) -> String {
62 - let mut parsed = match Url::parse(url_str) {
63 - Ok(u) => u,
64 - Err(_) => return url_str.to_string(),
62 + let Ok(mut parsed) = Url::parse(url_str) else {
63 + return url_str.to_string();
65 64 };
66 65
67 66 let clean_pairs: Vec<(String, String)> = parsed
@@ -99,7 +98,7 @@ pub fn strip_tracking_from_html(html: &str) -> String {
99 98 (caps.get(3).unwrap().as_str(), '\'')
100 99 };
101 100 let cleaned = strip_tracking_params(url);
102 - format!("{}={}{}{}", attr, quote, cleaned, quote)
101 + format!("{attr}={quote}{cleaned}{quote}")
103 102 })
104 103 .into_owned()
105 104 }
@@ -14,3 +14,6 @@ chrono.workspace = true
14 14 uuid.workspace = true
15 15 serde.workspace = true
16 16 serde_json.workspace = true
17 +
18 + [lints]
19 + workspace = true
@@ -11,7 +11,7 @@ use crate::TIMESTAMP_FMT;
11 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 - /// Used for data that we wrote ourselves (JSON) — parse failures
14 + /// Used for data that we wrote ourselves (JSON), parse failures
15 15 /// indicate DB corruption or a write bug, both extremely rare.
16 16 fn parse_or_default<T: Default>(result: Result<T, impl std::fmt::Display>, context: &str) -> T {
17 17 match result {
@@ -170,9 +170,9 @@ impl DbFeedItem {
170 170 /// Reconstruct a full [`FeedItem`](bb_interface::FeedItem) from the flat DB row.
171 171 ///
172 172 /// The interface type splits an item into three parts:
173 - /// 1. **Bite** — compact list-view display (author, text, secondary, indicator)
174 - /// 2. **Content** — full article (title, body, url, media)
175 - /// 3. **Meta** — metadata (source name, timestamps, score, tags)
173 + /// 1. **Bite**: compact list-view display (author, text, secondary, indicator)
174 + /// 2. **Content**: full article (title, body, url, media)
175 + /// 3. **Meta**: metadata (source name, timestamps, score, tags)
176 176 ///
177 177 /// This method maps the flat `DbFeedItem` columns back into those three
178 178 /// structs, deserializing JSON columns (media, tags) along the way.
@@ -182,20 +182,20 @@ impl DbFeedItem {
182 182
183 183 let id = FeedItemId::new(&*self.busser_id, &self.external_id);
184 184
185 - // 1. Bite display — compact list row
185 + // 1. Bite display: compact list row
186 186 let mut bite = BiteDisplay::new(&self.bite_author, &self.bite_text);
187 - bite.secondary = self.bite_secondary.clone();
188 - bite.indicator = self.bite_indicator.clone();
187 + bite.secondary.clone_from(&self.bite_secondary);
188 + bite.indicator.clone_from(&self.bite_indicator);
189 189
190 - // 2. Content — full article view
190 + // 2. Content: full article view
191 191 let mut content = FeedItemContent::new();
192 - content.title = self.title.clone();
193 - content.body = self.body.clone();
194 - content.url = self.url.clone();
192 + content.title.clone_from(&self.title);
193 + content.body.clone_from(&self.body);
194 + content.url.clone_from(&self.url);
195 195 content.media = self.media_vec();
196 196 content.actions = self.actions_vec();
197 197
198 - // 3. Meta — timestamps, score, tags
198 + // 3. Meta: timestamps, score, tags
199 199 let published_at = self.published_at_dt();
200 200 let mut meta = FeedItemMeta::new(&self.source_name, published_at.timestamp());
201 201 meta.fetched_at = self.fetched_at_dt().timestamp();
@@ -307,25 +307,25 @@ pub struct QueryCondition {
307 307 /// The item field to match against.
308 308 ///
309 309 /// Valid values:
310 - /// - `"title"` — full article/post title (in-memory filter)
311 - /// - `"author"` — bite display author (in-memory filter)
312 - /// - `"body"` — full body/content text (in-memory filter)
313 - /// - `"source"` — busser source ID (fast-path SQL)
314 - /// - `"tag"` — item-level tag (fast-path SQL)
315 - /// - `"starred"` — starred boolean state (fast-path SQL)
316 - /// - `"unread"` — unread boolean state (fast-path SQL)
310 + /// - `"title"`: full article/post title (in-memory filter)
311 + /// - `"author"`: bite display author (in-memory filter)
312 + /// - `"body"`: full body/content text (in-memory filter)
313 + /// - `"source"`: busser source ID (fast-path SQL)
314 + /// - `"tag"`: item-level tag (fast-path SQL)
315 + /// - `"starred"`: starred boolean state (fast-path SQL)
316 + /// - `"unread"`: unread boolean state (fast-path SQL)
317 317 pub field: String,
318 318 /// The comparison operator to apply.
319 319 ///
320 320 /// For text fields (title, author, body):
321 - /// - `"contains"` — case-insensitive substring match
322 - /// - `"not_contains"` — negated case-insensitive substring match
323 - /// - `"equals"` — case-insensitive exact match
324 - /// - `"matches_regex"` — Rust `regex` crate pattern match
321 + /// - `"contains"`: case-insensitive substring match
322 + /// - `"not_contains"`: negated case-insensitive substring match
323 + /// - `"equals"`: case-insensitive exact match
324 + /// - `"matches_regex"`: Rust `regex` crate pattern match
325 325 ///
326 326 /// For fast-path fields:
327 - /// - `"is"` — boolean check, used with starred/unread
328 - /// - `"equals"` — exact match, used with source/tag
327 + /// - `"is"`: boolean check, used with starred/unread
328 + /// - `"equals"`: exact match, used with source/tag
329 329 pub operator: String,
330 330 /// The comparison value.
331 331 ///
@@ -1,4 +1,6 @@
1 - use super::*;
1 + use super::{
2 + BookmarkId, CreateBookmark, DbBookmark, SqlitePool, TIMESTAMP_FMT, UpdateBookmark, Utc,
3 + };
2 4
3 5 #[derive(Clone)]
4 6 /// Repository for bookmark (reading list) CRUD
@@ -20,12 +22,12 @@ impl BookmarksRepository {
20 22 let mut tx = self.pool.begin().await?;
21 23
22 24 let bookmark: DbBookmark = sqlx::query_as(
23 - r#"
25 + r"
24 26 INSERT INTO bookmarks (id, url, title, description, author, source_name,
25 27 feed_item_id, notes, is_pinned, created_at, updated_at)
26 28 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0, ?9, ?9)
27 29 RETURNING *
28 - "#,
30 + ",
29 31 )
30 32 .bind(id)
31 33 .bind(&input.url)
@@ -92,12 +94,12 @@ impl BookmarksRepository {
92 94 match tag {
93 95 Some(tag) => {
94 96 sqlx::query_as(
95 - r#"
97 + r"
96 98 SELECT b.* FROM bookmarks b
97 99 INNER JOIN bookmark_tags bt ON bt.bookmark_id = b.id
98 100 WHERE bt.tag = ?1
99 101 ORDER BY b.is_pinned DESC, b.created_at DESC
100 - "#,
102 + ",
101 103 )
102 104 .bind(tag)
103 105 .fetch_all(&self.pool)
@@ -117,7 +119,7 @@ impl BookmarksRepository {
117 119 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
118 120
119 121 sqlx::query(
120 - r#"
122 + r"
121 123 UPDATE bookmarks SET
122 124 title = COALESCE(?2, title),
123 125 description = COALESCE(?3, description),
@@ -125,7 +127,7 @@ impl BookmarksRepository {
125 127 is_pinned = COALESCE(?5, is_pinned),
126 128 updated_at = ?1
127 129 WHERE id = ?6
128 - "#,
130 + ",
129 131 )
130 132 .bind(&now)
131 133 .bind(&input.title)
@@ -1,4 +1,4 @@
1 - use super::*;
1 + use super::SqlitePool;
2 2
3 3 #[derive(Clone)]
4 4 /// Repository for user_config key-value pairs (theme, welcome flag, etc.)
@@ -26,11 +26,11 @@ impl ConfigRepository {
26 26 #[tracing::instrument(skip_all)]
27 27 pub async fn set(&self, key: &str, value: &str) -> Result<(), sqlx::Error> {
28 28 sqlx::query(
29 - r#"
29 + r"
30 30 INSERT INTO user_config (key, value)
31 31 VALUES (?1, ?2)
32 32 ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
33 - "#,
33 + ",
34 34 )
35 35 .bind(key)
36 36 .bind(value)
@@ -1,4 +1,7 @@
1 - use super::*;
1 + use super::{
2 + CIRCUIT_BREAKER_THRESHOLD, CreateFeed, DbFeed, ErrorCategory, FeedId, SqlitePool,
3 + StructuredError, TIMESTAMP_FMT, Utc,
4 + };
2 5
3 6 #[derive(Clone)]
4 7 /// Repository for feed subscription CRUD and fetch tracking
@@ -24,11 +27,11 @@ impl FeedsRepository {
24 27 let config = serde_json::to_string(&input.config).unwrap_or_else(|_| "{}".to_string());
25 28
26 29 sqlx::query_as(
27 - r#"
30 + r"
28 31 INSERT INTO feeds (id, busser_id, name, config, enabled, created_at, updated_at)
29 32 VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5)
30 33 RETURNING *
31 - "#,
34 + ",
32 35 )
33 36 .bind(id)
34 37 .bind(input.busser_id.as_str())
@@ -144,9 +147,9 @@ impl FeedsRepository {
144 147
145 148 /// Record a structured fetch failure with category-aware behavior:
146 149 ///
147 - /// - `RateLimited` — store error JSON, do NOT increment `consecutive_failures`.
148 - /// - `Auth` / `Config` — increment + immediately set `circuit_broken = 1`.
149 - /// - `Transient` / `Parse` / `Unknown` — increment normally (existing behavior).
150 + /// - `RateLimited`: store error JSON, do NOT increment `consecutive_failures`.
151 + /// - `Auth` / `Config`: increment + immediately set `circuit_broken = 1`.
152 + /// - `Transient` / `Parse` / `Unknown`: increment normally (existing behavior).
150 153 ///
151 154 /// Returns `true` if the circuit breaker tripped.
152 155 #[tracing::instrument(skip_all)]