max / balanced_breakfast
- Co-Authored-By
- Claude Opus 4.6 (1M context) <noreply@anthropic.com>
23 files changed,
+429 insertions,
-283 deletions
| @@ -79,20 +79,7 @@ | |||
| 79 | 79 | ||
| 80 | 80 | ### Sandbox Limits | |
| 81 | 81 | ||
| 82 | - | Each plugin runs in an isolated Rhai engine with strict limits: | |
| 83 | - | ||
| 84 | - | | Limit | Value | Purpose | | |
| 85 | - | |-------|-------|---------| | |
| 86 | - | | Max operations | 100,000 | Catches infinite loops | | |
| 87 | - | | Max expression depth | 128 | Prevents stack overflow | | |
| 88 | - | | Max recursion | 32 | Limits call depth | | |
| 89 | - | | HTTP timeout | 15s per request | Prevents hanging | | |
| 90 | - | | Response size | 2 MB per response | Prevents memory exhaustion | | |
| 91 | - | | Max requests | 100 per fetch | Catches runaway fetchers | | |
| 92 | - | | Aggregate timeout | 60s per fetch | Hard ceiling on total fetch time | | |
| 93 | - | | URL restrictions | Block localhost, private IPs | Prevents SSRF | | |
| 94 | - | ||
| 95 | - | Each plugin gets its own engine instance with isolated counters (request count via `Arc<AtomicUsize>`, deadline via `Arc<AtomicU64>`). | |
| 82 | + | Each plugin runs in an isolated Rhai engine with strict limits (operations, recursion, HTTP timeout/count, response size, SSRF blocking). Full limit table and isolation details in `docs/architecture.md` § Sandboxing. | |
| 96 | 83 | ||
| 97 | 84 | ### Plugin Style | |
| 98 | 85 |
| @@ -3692,6 +3692,16 @@ | |||
| 3692 | 3692 | "rand_core 0.6.4", | |
| 3693 | 3693 | ] | |
| 3694 | 3694 | ||
| 3695 | + | [[package]] | |
| 3696 | + | name = "rand" | |
| 3697 | + | version = "0.9.4" | |
| 3698 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3699 | + | checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" | |
| 3700 | + | dependencies = [ | |
| 3701 | + | "rand_chacha 0.9.0", | |
| 3702 | + | "rand_core 0.9.5", | |
| 3703 | + | ] | |
| 3704 | + | ||
| 3695 | 3705 | [[package]] | |
| 3696 | 3706 | name = "rand_chacha" | |
| 3697 | 3707 | version = "0.2.2" | |
| @@ -3712,6 +3722,16 @@ | |||
| 3712 | 3722 | "rand_core 0.6.4", | |
| 3713 | 3723 | ] | |
| 3714 | 3724 | ||
| 3725 | + | [[package]] | |
| 3726 | + | name = "rand_chacha" | |
| 3727 | + | version = "0.9.0" | |
| 3728 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3729 | + | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" | |
| 3730 | + | dependencies = [ | |
| 3731 | + | "ppv-lite86", | |
| 3732 | + | "rand_core 0.9.5", | |
| 3733 | + | ] | |
| 3734 | + | ||
| 3715 | 3735 | [[package]] | |
| 3716 | 3736 | name = "rand_core" | |
| 3717 | 3737 | version = "0.5.1" | |
| @@ -3730,6 +3750,15 @@ | |||
| 3730 | 3750 | "getrandom 0.2.17", | |
| 3731 | 3751 | ] | |
| 3732 | 3752 | ||
| 3753 | + | [[package]] | |
| 3754 | + | name = "rand_core" | |
| 3755 | + | version = "0.9.5" | |
| 3756 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3757 | + | checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" | |
| 3758 | + | dependencies = [ | |
| 3759 | + | "getrandom 0.3.4", | |
| 3760 | + | ] | |
| 3761 | + | ||
| 3733 | 3762 | [[package]] | |
| 3734 | 3763 | name = "rand_hc" | |
| 3735 | 3764 | version = "0.2.0" | |
| @@ -5052,7 +5081,7 @@ | |||
| 5052 | 5081 | "chrono", | |
| 5053 | 5082 | "keyring", | |
| 5054 | 5083 | "parking_lot", | |
| 5055 | - | "rand 0.8.5", | |
| 5084 | + | "rand 0.9.4", | |
| 5056 | 5085 | "reqwest 0.12.28", | |
| 5057 | 5086 | "serde", | |
| 5058 | 5087 | "serde_json", |
| @@ -11,7 +11,7 @@ | |||
| 11 | 11 | ||
| 12 | 12 | [workspace.package] | |
| 13 | 13 | version = "0.3.1" | |
| 14 | - | edition = "2021" | |
| 14 | + | edition = "2024" | |
| 15 | 15 | authors = ["BalancedBreakfast Contributors"] | |
| 16 | 16 | license-file = "LICENSE" | |
| 17 | 17 |
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "balanced-breakfast-desktop" | |
| 3 | 3 | version = "0.3.0" | |
| 4 | - | edition = "2021" | |
| 4 | + | edition = "2024" | |
| 5 | 5 | ||
| 6 | 6 | [[bin]] | |
| 7 | 7 | name = "balanced-breakfast-desktop" |
| @@ -16,14 +16,17 @@ | |||
| 16 | 16 | <input type="text" id="search-input" placeholder="Search... (/) | ? for help" class="search-input"> | |
| 17 | 17 | <span id="search-spinner" class="search-spinner" aria-hidden="true"></span> | |
| 18 | 18 | <label for="sort-select" class="sr-only">Sort order</label> | |
| 19 | - | <select id="sort-select" class="sort-select" title="Sort order"> | |
| 19 | + | <button id="unread-toggle" class="btn btn-small" title="Show unread only (U)" aria-pressed="false">Unread</button> | |
| 20 | + | <select id="sort-select" class="sort-select" title="Sort order (S)"> | |
| 20 | 21 | <option value="chronological">Newest First</option> | |
| 21 | 22 | <option value="score">By Score</option> | |
| 22 | 23 | <option value="unread">Unread First</option> | |
| 23 | 24 | <option value="starred">Starred First</option> | |
| 24 | 25 | </select> | |
| 25 | - | <button id="refresh-btn" class="btn btn-primary" title="Refresh all feeds">Refresh</button> | |
| 26 | + | <button id="mark-all-read-btn" class="btn btn-small" title="Mark all as read (Shift+A)">Mark All Read</button> | |
| 27 | + | <button id="refresh-btn" class="btn btn-primary" title="Refresh all feeds (R)">Refresh</button> | |
| 26 | 28 | <button id="add-feed-btn" class="btn btn-success" title="Add a new feed source">+ Add Feed</button> | |
| 29 | + | <button id="help-btn" class="btn btn-small" title="Keyboard shortcuts (?)" aria-label="Help">?</button> | |
| 27 | 30 | <button id="settings-btn" class="btn btn-small" title="Settings" aria-label="Settings">⚙</button> | |
| 28 | 31 | </div> | |
| 29 | 32 | </header> | |
| @@ -41,8 +44,8 @@ | |||
| 41 | 44 | </li> | |
| 42 | 45 | </ul> | |
| 43 | 46 | </nav> | |
| 44 | - | <div id="saved-articles-btn" class="sidebar-saved" title="Reading List"> | |
| 45 | - | <span class="source-name">Reading List</span> | |
| 47 | + | <div id="saved-articles-btn" class="sidebar-saved" title="Saved Articles"> | |
| 48 | + | <span class="source-name">Saved Articles</span> | |
| 46 | 49 | <span id="saved-count" class="source-count">0</span> | |
| 47 | 50 | </div> | |
| 48 | 51 | <div class="sidebar-footer"> |
| @@ -17,22 +17,22 @@ | |||
| 17 | 17 | common::insert_item(&db, &feed, "rss:mr2", "Article 2", 2).await; | |
| 18 | 18 | common::insert_item(&db, &feed, "rss:mr3", "Article 3", 3).await; | |
| 19 | 19 | ||
| 20 | - | let gen = FeedGenerator::new(db.clone()); | |
| 20 | + | let fg = FeedGenerator::new(db.clone()); | |
| 21 | 21 | ||
| 22 | 22 | // All 3 unread initially | |
| 23 | - | assert_eq!(gen.unread_count().await.unwrap(), 3); | |
| 23 | + | assert_eq!(fg.unread_count().await.unwrap(), 3); | |
| 24 | 24 | ||
| 25 | 25 | // Mark one read via generator (uses external_id lookup) | |
| 26 | - | gen.mark_read("rss:mr1", true).await.unwrap(); | |
| 27 | - | assert_eq!(gen.unread_count().await.unwrap(), 2); | |
| 26 | + | fg.mark_read("rss:mr1", true).await.unwrap(); | |
| 27 | + | assert_eq!(fg.unread_count().await.unwrap(), 2); | |
| 28 | 28 | ||
| 29 | 29 | // Mark another read | |
| 30 | - | gen.mark_read("rss:mr2", true).await.unwrap(); | |
| 31 | - | assert_eq!(gen.unread_count().await.unwrap(), 1); | |
| 30 | + | fg.mark_read("rss:mr2", true).await.unwrap(); | |
| 31 | + | assert_eq!(fg.unread_count().await.unwrap(), 1); | |
| 32 | 32 | ||
| 33 | 33 | // Mark one back to unread | |
| 34 | - | gen.mark_read("rss:mr1", false).await.unwrap(); | |
| 35 | - | assert_eq!(gen.unread_count().await.unwrap(), 2); | |
| 34 | + | fg.mark_read("rss:mr1", false).await.unwrap(); | |
| 35 | + | assert_eq!(fg.unread_count().await.unwrap(), 2); | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | #[tokio::test] | |
| @@ -42,15 +42,15 @@ | |||
| 42 | 42 | common::insert_item(&db, &feed, "rss:ms1", "Article 1", 1).await; | |
| 43 | 43 | common::insert_item(&db, &feed, "rss:ms2", "Article 2", 2).await; | |
| 44 | 44 | ||
| 45 | - | let gen = FeedGenerator::new(db.clone()); | |
| 46 | - | assert_eq!(gen.unread_count().await.unwrap(), 2); | |
| 45 | + | let fg = FeedGenerator::new(db.clone()); | |
| 46 | + | assert_eq!(fg.unread_count().await.unwrap(), 2); | |
| 47 | 47 | ||
| 48 | 48 | // Starring should not change unread count | |
| 49 | - | gen.mark_starred("rss:ms1", true).await.unwrap(); | |
| 50 | - | assert_eq!(gen.unread_count().await.unwrap(), 2); | |
| 49 | + | fg.mark_starred("rss:ms1", true).await.unwrap(); | |
| 50 | + | assert_eq!(fg.unread_count().await.unwrap(), 2); | |
| 51 | 51 | ||
| 52 | - | gen.mark_starred("rss:ms1", false).await.unwrap(); | |
| 53 | - | assert_eq!(gen.unread_count().await.unwrap(), 2); | |
| 52 | + | fg.mark_starred("rss:ms1", false).await.unwrap(); | |
| 53 | + | assert_eq!(fg.unread_count().await.unwrap(), 2); | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | 56 | #[tokio::test] | |
| @@ -59,11 +59,11 @@ | |||
| 59 | 59 | let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await; | |
| 60 | 60 | common::insert_item(&db, &feed, "rss:1", "Article", 1).await; | |
| 61 | 61 | ||
| 62 | - | let gen = FeedGenerator::new(db.clone()); | |
| 62 | + | let fg = FeedGenerator::new(db.clone()); | |
| 63 | 63 | // Should not panic or error | |
| 64 | - | gen.mark_read("nonexistent:999", true).await.unwrap(); | |
| 64 | + | fg.mark_read("nonexistent:999", true).await.unwrap(); | |
| 65 | 65 | // Original item should remain unread | |
| 66 | - | assert_eq!(gen.unread_count().await.unwrap(), 1); | |
| 66 | + | assert_eq!(fg.unread_count().await.unwrap(), 1); | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | 69 | // ── Ordering ───────────────────────────────────────────────────────── | |
| @@ -102,8 +102,8 @@ | |||
| 102 | 102 | .unwrap(); | |
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | - | let gen = FeedGenerator::new(db).with_order(bb_feed::OrderBy::Score); | |
| 106 | - | let result = gen.get_items(0).await.unwrap(); | |
| 105 | + | let fg = FeedGenerator::new(db).with_order(bb_feed::OrderBy::Score); | |
| 106 | + | let result = fg.get_items(0).await.unwrap(); | |
| 107 | 107 | assert_eq!(result.items.len(), 3); | |
| 108 | 108 | assert_eq!(result.items[0].meta.score, Some(100)); | |
| 109 | 109 | assert_eq!(result.items[1].meta.score, Some(10)); | |
| @@ -119,8 +119,8 @@ | |||
| 119 | 119 | ||
| 120 | 120 | db.items().mark_starred(item2.id, true).await.unwrap(); | |
| 121 | 121 | ||
| 122 | - | let gen = FeedGenerator::new(db).with_order(bb_feed::OrderBy::StarredFirst); | |
| 123 | - | let result = gen.get_items(0).await.unwrap(); | |
| 122 | + | let fg = FeedGenerator::new(db).with_order(bb_feed::OrderBy::StarredFirst); | |
| 123 | + | let result = fg.get_items(0).await.unwrap(); | |
| 124 | 124 | assert!(result.items[0].is_starred, "starred item should sort first"); | |
| 125 | 125 | assert!(!result.items[1].is_starred); | |
| 126 | 126 | // item1 should not have id leakage | |
| @@ -136,8 +136,8 @@ | |||
| 136 | 136 | ||
| 137 | 137 | db.items().mark_read(item1.id, true).await.unwrap(); | |
| 138 | 138 | ||
| 139 | - | let gen = FeedGenerator::new(db).with_order(bb_feed::OrderBy::UnreadFirst); | |
| 140 | - | let result = gen.get_items(0).await.unwrap(); | |
| 139 | + | let fg = FeedGenerator::new(db).with_order(bb_feed::OrderBy::UnreadFirst); | |
| 140 | + | let result = fg.get_items(0).await.unwrap(); | |
| 141 | 141 | // UnreadFirst groups items by read status — verify they're grouped | |
| 142 | 142 | let first_read = result.items[0].is_read; | |
| 143 | 143 | let last_read = result.items[1].is_read; | |
| @@ -154,8 +154,8 @@ | |||
| 154 | 154 | common::insert_item(&db, &feed, "rss:2", "B", 2).await; | |
| 155 | 155 | common::insert_item(&db, &feed, "rss:3", "C", 3).await; | |
| 156 | 156 | ||
| 157 | - | let gen = FeedGenerator::new(db); | |
| 158 | - | assert_eq!(gen.count().await.unwrap(), 3); | |
| 157 | + | let fg = FeedGenerator::new(db); | |
| 158 | + | assert_eq!(fg.count().await.unwrap(), 3); | |
| 159 | 159 | } | |
| 160 | 160 | ||
| 161 | 161 | #[tokio::test] | |
| @@ -167,8 +167,8 @@ | |||
| 167 | 167 | common::insert_item(&db, &feed_hn, "hn:1", "B", 1).await; | |
| 168 | 168 | common::insert_item(&db, &feed_hn, "hn:2", "C", 2).await; | |
| 169 | 169 | ||
| 170 | - | let gen = FeedGenerator::new(db.clone()).with_filter(FeedFilter::new().source("hn")); | |
| 171 | - | assert_eq!(gen.count().await.unwrap(), 2); | |
| 170 | + | let fg = FeedGenerator::new(db.clone()).with_filter(FeedFilter::new().source("hn")); | |
| 171 | + | assert_eq!(fg.count().await.unwrap(), 2); | |
| 172 | 172 | ||
| 173 | 173 | // Source that doesn't exist | |
| 174 | 174 | let gen2 = FeedGenerator::new(db).with_filter(FeedFilter::new().source("nonexistent")); | |
| @@ -185,6 +185,6 @@ | |||
| 185 | 185 | ||
| 186 | 186 | db.items().mark_read(item1.id, true).await.unwrap(); | |
| 187 | 187 | ||
| 188 | - | let gen = FeedGenerator::new(db).with_filter(FeedFilter::new().unread_only()); | |
| 189 | - | assert_eq!(gen.count().await.unwrap(), 2); | |
| 188 | + | let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().unread_only()); | |
| 189 | + | assert_eq!(fg.count().await.unwrap(), 2); | |
| 190 | 190 | } |
| @@ -296,10 +296,10 @@ | |||
| 296 | 296 | common::insert_item(&db, &feed_rss, "rss:2", "RSS 2", 2).await; | |
| 297 | 297 | common::insert_item(&db, &feed_hn, "hn:1", "HN 1", 1).await; | |
| 298 | 298 | ||
| 299 | - | let gen = FeedGenerator::new(db.clone()); | |
| 299 | + | let fg = FeedGenerator::new(db.clone()); | |
| 300 | 300 | ||
| 301 | 301 | // Initial state: RSS has 2 unread, HN has 1 unread | |
| 302 | - | let sources = gen.get_sources().await.unwrap(); | |
| 302 | + | let sources = fg.get_sources().await.unwrap(); | |
| 303 | 303 | let rss = sources.iter().find(|s| s.id == "rss").unwrap(); | |
| 304 | 304 | assert_eq!(rss.unread_count, 2); | |
| 305 | 305 | let hn = sources.iter().find(|s| s.id == "hn").unwrap(); | |
| @@ -308,7 +308,7 @@ | |||
| 308 | 308 | // Mark one RSS item read | |
| 309 | 309 | db.items().mark_read(rss1.id, true).await.unwrap(); | |
| 310 | 310 | ||
| 311 | - | let sources = gen.get_sources().await.unwrap(); | |
| 311 | + | let sources = fg.get_sources().await.unwrap(); | |
| 312 | 312 | let rss = sources.iter().find(|s| s.id == "rss").unwrap(); | |
| 313 | 313 | assert_eq!(rss.unread_count, 1); | |
| 314 | 314 | assert_eq!(rss.total_count, 2); // total unchanged | |
| @@ -337,8 +337,8 @@ | |||
| 337 | 337 | assert!(refreshed.circuit_broken); | |
| 338 | 338 | ||
| 339 | 339 | // Replicate the health mapping from sources.rs | |
| 340 | - | let gen = FeedGenerator::new(db.clone()); | |
| 341 | - | let sources = gen.get_sources().await.unwrap(); | |
| 340 | + | let fg = FeedGenerator::new(db.clone()); | |
| 341 | + | let sources = fg.get_sources().await.unwrap(); | |
| 342 | 342 | let source = &sources[0]; | |
| 343 | 343 | ||
| 344 | 344 | // The sources.rs command checks circuit_broken first, before failure count | |
| @@ -372,8 +372,8 @@ | |||
| 372 | 372 | // Reset via the repository method (mirrors reset_circuit_breaker command) | |
| 373 | 373 | db.feeds().reset_circuit_breaker(feed.id).await.unwrap(); | |
| 374 | 374 | ||
| 375 | - | let gen = FeedGenerator::new(db.clone()); | |
| 376 | - | let sources = gen.get_sources().await.unwrap(); | |
| 375 | + | let fg = FeedGenerator::new(db.clone()); | |
| 376 | + | let sources = fg.get_sources().await.unwrap(); | |
| 377 | 377 | let source = &sources[0]; | |
| 378 | 378 | ||
| 379 | 379 | assert!(!source.circuit_broken); | |
| @@ -409,8 +409,8 @@ | |||
| 409 | 409 | .await | |
| 410 | 410 | .unwrap(); | |
| 411 | 411 | ||
| 412 | - | let gen = FeedGenerator::new(db); | |
| 413 | - | let sources = gen.get_sources().await.unwrap(); | |
| 412 | + | let fg = FeedGenerator::new(db); | |
| 413 | + | let sources = fg.get_sources().await.unwrap(); | |
| 414 | 414 | ||
| 415 | 415 | let rss = sources.iter().find(|s| s.id == "rss").unwrap(); | |
| 416 | 416 | assert!(rss.tags.contains(&"tech".to_string())); | |
| @@ -428,8 +428,8 @@ | |||
| 428 | 428 | common::create_rss_feed(&db, "Empty RSS", "https://example.com/rss").await; | |
| 429 | 429 | common::create_other_feed(&db, "hn", "Empty HN").await; | |
| 430 | 430 | ||
| 431 | - | let gen = FeedGenerator::new(db); | |
| 432 | - | let sources = gen.get_sources().await.unwrap(); | |
| 431 | + | let fg = FeedGenerator::new(db); | |
| 432 | + | let sources = fg.get_sources().await.unwrap(); | |
| 433 | 433 | assert_eq!(sources.len(), 2); | |
| 434 | 434 | ||
| 435 | 435 | for source in &sources { |
| @@ -115,16 +115,16 @@ | |||
| 115 | 115 | common::insert_item(&db, &feed, &format!("rss:p{i}"), &format!("Item {i}"), i).await; | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | - | let gen = FeedGenerator::new(db).with_page_size(3); | |
| 119 | - | let page0 = gen.get_items(0).await.unwrap(); | |
| 118 | + | let fg = FeedGenerator::new(db).with_page_size(3); | |
| 119 | + | let page0 = fg.get_items(0).await.unwrap(); | |
| 120 | 120 | assert_eq!(page0.items.len(), 3); | |
| 121 | 121 | assert!(page0.has_more); | |
| 122 | 122 | ||
| 123 | - | let page1 = gen.get_items(1).await.unwrap(); | |
| 123 | + | let page1 = fg.get_items(1).await.unwrap(); | |
| 124 | 124 | assert_eq!(page1.items.len(), 3); | |
| 125 | 125 | assert!(page1.has_more); | |
| 126 | 126 | ||
| 127 | - | let page2 = gen.get_items(2).await.unwrap(); | |
| 127 | + | let page2 = fg.get_items(2).await.unwrap(); | |
| 128 | 128 | assert_eq!(page2.items.len(), 1); | |
| 129 | 129 | assert!(!page2.has_more); | |
| 130 | 130 | } | |
| @@ -138,8 +138,8 @@ | |||
| 138 | 138 | common::insert_item(&db, &feed_hn, "hn:1", "HN Article", 1).await; | |
| 139 | 139 | common::insert_item(&db, &feed_hn, "hn:2", "HN Article 2", 2).await; | |
| 140 | 140 | ||
| 141 | - | let gen = FeedGenerator::new(db).with_filter(FeedFilter::new().source("hn")); | |
| 142 | - | let result = gen.get_items(0).await.unwrap(); | |
| 141 | + | let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().source("hn")); | |
| 142 | + | let result = fg.get_items(0).await.unwrap(); | |
| 143 | 143 | assert_eq!(result.items.len(), 2); | |
| 144 | 144 | for item in &result.items { | |
| 145 | 145 | assert_eq!(item.id.source, "hn"); | |
| @@ -155,8 +155,8 @@ | |||
| 155 | 155 | ||
| 156 | 156 | db.items().mark_read(item1.id, true).await.unwrap(); | |
| 157 | 157 | ||
| 158 | - | let gen = FeedGenerator::new(db).with_filter(FeedFilter::new().unread_only()); | |
| 159 | - | let result = gen.get_items(0).await.unwrap(); | |
| 158 | + | let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().unread_only()); | |
| 159 | + | let result = fg.get_items(0).await.unwrap(); | |
| 160 | 160 | assert_eq!(result.items.len(), 1); | |
| 161 | 161 | assert!(!result.items[0].is_read); | |
| 162 | 162 | } | |
| @@ -170,8 +170,8 @@ | |||
| 170 | 170 | ||
| 171 | 171 | db.items().mark_starred(item2.id, true).await.unwrap(); | |
| 172 | 172 | ||
| 173 | - | let gen = FeedGenerator::new(db).with_filter(FeedFilter::new().starred_only()); | |
| 174 | - | let result = gen.get_items(0).await.unwrap(); | |
| 173 | + | let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().starred_only()); | |
| 174 | + | let result = fg.get_items(0).await.unwrap(); | |
| 175 | 175 | assert_eq!(result.items.len(), 1); | |
| 176 | 176 | assert!(result.items[0].is_starred); | |
| 177 | 177 | } | |
| @@ -307,25 +307,25 @@ | |||
| 307 | 307 | ||
| 308 | 308 | // Filter: source=rss — gets both RSS items (source branch doesn't also | |
| 309 | 309 | // filter by unread; only list_search combines source+unread) | |
| 310 | - | let gen = FeedGenerator::new(db.clone()) | |
| 310 | + | let fg = FeedGenerator::new(db.clone()) | |
| 311 | 311 | .with_filter(FeedFilter::new().source("rss")); | |
| 312 | - | let result = gen.get_items(0).await.unwrap(); | |
| 312 | + | let result = fg.get_items(0).await.unwrap(); | |
| 313 | 313 | assert_eq!(result.items.len(), 2); | |
| 314 | 314 | for item in &result.items { | |
| 315 | 315 | assert_eq!(item.id.source, "rss"); | |
| 316 | 316 | } | |
| 317 | 317 | ||
| 318 | 318 | // Filter: starred only — should get rss:2 only | |
| 319 | - | let gen2 = FeedGenerator::new(db.clone()) | |
| 319 | + | let fg2 = FeedGenerator::new(db.clone()) | |
| 320 | 320 | .with_filter(FeedFilter::new().starred_only()); | |
| 321 | - | let result2 = gen2.get_items(0).await.unwrap(); | |
| 321 | + | let result2 = fg2.get_items(0).await.unwrap(); | |
| 322 | 322 | assert_eq!(result2.items.len(), 1); | |
| 323 | 323 | assert!(result2.items[0].is_starred); | |
| 324 | 324 | ||
| 325 | 325 | // Search + source + unread combines all filters in SQL | |
| 326 | - | let gen3 = FeedGenerator::new(db) | |
| 326 | + | let fg3 = FeedGenerator::new(db) | |
| 327 | 327 | .with_filter(FeedFilter::new().search("RSS").source("rss").unread_only()); | |
| 328 | - | let result3 = gen3.get_items(0).await.unwrap(); | |
| 328 | + | let result3 = fg3.get_items(0).await.unwrap(); | |
| 329 | 329 | assert_eq!(result3.items.len(), 1); | |
| 330 | 330 | assert!(!result3.items[0].is_read); | |
| 331 | 331 | } | |
| @@ -335,8 +335,8 @@ | |||
| 335 | 335 | #[tokio::test] | |
| 336 | 336 | async fn list_items_empty_database() { | |
| 337 | 337 | let db = common::test_db().await; | |
| 338 | - | let gen = FeedGenerator::new(db); | |
| 339 | - | let result = gen.get_items(0).await.unwrap(); | |
| 338 | + | let fg = FeedGenerator::new(db); | |
| 339 | + | let result = fg.get_items(0).await.unwrap(); | |
| 340 | 340 | assert!(result.items.is_empty()); | |
| 341 | 341 | assert!(!result.has_more); | |
| 342 | 342 | } | |
| @@ -344,8 +344,8 @@ | |||
| 344 | 344 | #[tokio::test] | |
| 345 | 345 | async fn list_sources_empty_database() { | |
| 346 | 346 | let db = common::test_db().await; | |
| 347 | - | let gen = FeedGenerator::new(db); | |
| 348 | - | let sources = gen.get_sources().await.unwrap(); | |
| 347 | + | let fg = FeedGenerator::new(db); | |
| 348 | + | let sources = fg.get_sources().await.unwrap(); | |
| 349 | 349 | assert!(sources.is_empty()); | |
| 350 | 350 | } | |
| 351 | 351 | ||
| @@ -354,15 +354,15 @@ | |||
| 354 | 354 | let db = common::test_db().await; | |
| 355 | 355 | assert_eq!(db.items().count_unread().await.unwrap(), 0); | |
| 356 | 356 | ||
| 357 | - | let gen = FeedGenerator::new(db); | |
| 358 | - | assert_eq!(gen.unread_count().await.unwrap(), 0); | |
| 357 | + | let fg = FeedGenerator::new(db); | |
| 358 | + | assert_eq!(fg.unread_count().await.unwrap(), 0); | |
| 359 | 359 | } | |
| 360 | 360 | ||
| 361 | 361 | #[tokio::test] | |
| 362 | 362 | async fn count_empty_database() { | |
| 363 | 363 | let db = common::test_db().await; | |
| 364 | - | let gen = FeedGenerator::new(db); | |
| 365 | - | assert_eq!(gen.count().await.unwrap(), 0); | |
| 364 | + | let fg = FeedGenerator::new(db); | |
| 365 | + | assert_eq!(fg.count().await.unwrap(), 0); | |
| 366 | 366 | } | |
| 367 | 367 | ||
| 368 | 368 | // ── Mark Read/Star: Verify Persisted State in Filtered Queries ─────── | |
| @@ -375,19 +375,19 @@ | |||
| 375 | 375 | common::insert_item(&db, &feed, "rss:ex2", "Article 2", 2).await; | |
| 376 | 376 | common::insert_item(&db, &feed, "rss:ex3", "Article 3", 3).await; | |
| 377 | 377 | ||
| 378 | - | let gen = FeedGenerator::new(db.clone()) | |
| 378 | + | let fg = FeedGenerator::new(db.clone()) | |
| 379 | 379 | .with_filter(FeedFilter::new().unread_only()); | |
| 380 | 380 | ||
| 381 | 381 | // All 3 visible | |
| 382 | - | assert_eq!(gen.get_items(0).await.unwrap().items.len(), 3); | |
| 382 | + | assert_eq!(fg.get_items(0).await.unwrap().items.len(), 3); | |
| 383 | 383 | ||
| 384 | 384 | // Mark one read | |
| 385 | 385 | db.items().mark_read(item1.id, true).await.unwrap(); | |
| 386 | 386 | ||
| 387 | 387 | // Only 2 visible now | |
| 388 | - | assert_eq!(gen.get_items(0).await.unwrap().items.len(), 2); | |
| 388 | + | assert_eq!(fg.get_items(0).await.unwrap().items.len(), 2); | |
| 389 | 389 | // None of the returned items should be the read one | |
| 390 | - | let ids: Vec<String> = gen | |
| 390 | + | let ids: Vec<String> = fg | |
| 391 | 391 | .get_items(0) | |
| 392 | 392 | .await | |
| 393 | 393 | .unwrap() | |
| @@ -406,23 +406,23 @@ | |||
| 406 | 406 | let item2 = common::insert_item(&db, &feed, "rss:st2", "Article 2", 2).await; | |
| 407 | 407 | common::insert_item(&db, &feed, "rss:st3", "Article 3", 3).await; | |
| 408 | 408 | ||
| 409 | - | let gen_starred = FeedGenerator::new(db.clone()) | |
| 409 | + | let fg_starred = FeedGenerator::new(db.clone()) | |
| 410 | 410 | .with_filter(FeedFilter::new().starred_only()); | |
| 411 | 411 | ||
| 412 | 412 | // Nothing starred yet | |
| 413 | - | assert!(gen_starred.get_items(0).await.unwrap().items.is_empty()); | |
| 413 | + | assert!(fg_starred.get_items(0).await.unwrap().items.is_empty()); | |
| 414 | 414 | ||
| 415 | 415 | // Star one item | |
| 416 | 416 | db.items().mark_starred(item2.id, true).await.unwrap(); | |
| 417 | 417 | ||
| 418 | - | let result = gen_starred.get_items(0).await.unwrap(); | |
| 418 | + | let result = fg_starred.get_items(0).await.unwrap(); | |
| 419 | 419 | assert_eq!(result.items.len(), 1); | |
| 420 | 420 | assert_eq!(result.items[0].id.item_id, "rss:st2"); | |
| 421 | 421 | assert!(result.items[0].is_starred); | |
| 422 | 422 | ||
| 423 | 423 | // Unstar it | |
| 424 | 424 | db.items().mark_starred(item2.id, false).await.unwrap(); | |
| 425 | - | assert!(gen_starred.get_items(0).await.unwrap().items.is_empty()); | |
| 425 | + | assert!(fg_starred.get_items(0).await.unwrap().items.is_empty()); | |
| 426 | 426 | } | |
| 427 | 427 | ||
| 428 | 428 | // ── Upsert Preserves User State ────────────────────────────────────── |