max / balanced_breakfast
85 files changed,
+658 insertions,
-597 deletions
| @@ -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 |
| @@ -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" |
| @@ -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)] |