Skip to main content

max / balanced_breakfast

Initial commit
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-05 18:51 UTC
Commit: 1ff74504cfda2a4f2403a0d3f58117090cdfd86f
138 files changed, +18539 insertions, -0 deletions
A .build.yml +31
@@ -0,0 +1,31 @@
1 + image: archlinux
2 + packages:
3 + - rust
4 + - cmake
5 + - clang
6 + - git
7 + - pkg-config
8 + - perl
9 + - webkit2gtk-4.1
10 + - gtk3
11 + - openssl
12 + - libayatana-appindicator
13 + sources:
14 + - https://git.sr.ht/~maxmj/balanced_breakfast
15 + environment:
16 + CARGO_INCREMENTAL: "0"
17 + RUST_BACKTRACE: "1"
18 + tasks:
19 + - check: |
20 + cd balanced_breakfast
21 + cargo check --workspace 2>&1
22 + - test: |
23 + cd balanced_breakfast
24 + cargo test --workspace 2>&1
25 + - clippy: |
26 + cd balanced_breakfast
27 + cargo clippy --workspace --all-targets -- -D warnings 2>&1
28 + - audit: |
29 + cargo install --locked cargo-audit
30 + cd balanced_breakfast
31 + cargo audit 2>&1
@@ -0,0 +1,7 @@
1 + # BalancedBreakfast Configuration
2 +
3 + # SQLite database URL
4 + DATABASE_URL=sqlite:balanced_breakfast.db?mode=rwc
5 +
6 + # Plugins directory (relative or absolute path)
7 + PLUGINS_DIR=plugins
A .gitignore +25
@@ -0,0 +1,25 @@
1 + # Build artifacts
2 + /target/
3 +
4 + # Environment
5 + .env
6 +
7 + # IDE
8 + .idea/
9 + .vscode/
10 + *.swp
11 + *.swo
12 +
13 + # macOS
14 + .DS_Store
15 +
16 + # Plugin binaries
17 + plugins/*.dylib
18 + plugins/*.so
19 + plugins/*.dll
20 +
21 + # Release artifacts
22 + dist/
23 +
24 + # Tauri generated
25 + src-tauri/gen/
A Cargo.lock +500
@@ -0,0 +1,6549 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "adler2"
7 + version = "2.0.1"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10 +
11 + [[package]]
12 + name = "aead"
13 + version = "0.5.2"
14 + source = "registry+https://github.com/rust-lang/crates.io-index"
15 + checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
16 + dependencies = [
17 + "crypto-common",
18 + "generic-array",
19 + ]
20 +
21 + [[package]]
22 + name = "aes"
23 + version = "0.8.4"
24 + source = "registry+https://github.com/rust-lang/crates.io-index"
25 + checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
26 + dependencies = [
27 + "cfg-if",
28 + "cipher",
29 + "cpufeatures",
30 + ]
31 +
32 + [[package]]
33 + name = "aes-gcm"
34 + version = "0.10.3"
35 + source = "registry+https://github.com/rust-lang/crates.io-index"
36 + checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
37 + dependencies = [
38 + "aead",
39 + "aes",
40 + "cipher",
41 + "ctr",
42 + "ghash",
43 + "subtle",
44 + ]
45 +
46 + [[package]]
47 + name = "ahash"
48 + version = "0.8.12"
49 + source = "registry+https://github.com/rust-lang/crates.io-index"
50 + checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
51 + dependencies = [
52 + "cfg-if",
53 + "const-random",
54 + "getrandom 0.3.4",
55 + "once_cell",
56 + "version_check",
57 + "zerocopy",
58 + ]
59 +
60 + [[package]]
61 + name = "aho-corasick"
62 + version = "1.1.4"
63 + source = "registry+https://github.com/rust-lang/crates.io-index"
64 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
65 + dependencies = [
66 + "memchr",
67 + ]
68 +
69 + [[package]]
70 + name = "alloc-no-stdlib"
71 + version = "2.0.4"
72 + source = "registry+https://github.com/rust-lang/crates.io-index"
73 + checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
74 +
75 + [[package]]
76 + name = "alloc-stdlib"
77 + version = "0.2.2"
78 + source = "registry+https://github.com/rust-lang/crates.io-index"
79 + checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
80 + dependencies = [
81 + "alloc-no-stdlib",
82 + ]
83 +
84 + [[package]]
85 + name = "allocator-api2"
86 + version = "0.2.21"
87 + source = "registry+https://github.com/rust-lang/crates.io-index"
88 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
89 +
90 + [[package]]
91 + name = "android_system_properties"
92 + version = "0.1.5"
93 + source = "registry+https://github.com/rust-lang/crates.io-index"
94 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
95 + dependencies = [
96 + "libc",
97 + ]
98 +
99 + [[package]]
100 + name = "anyhow"
101 + version = "1.0.101"
102 + source = "registry+https://github.com/rust-lang/crates.io-index"
103 + checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
104 +
105 + [[package]]
106 + name = "argon2"
107 + version = "0.5.3"
108 + source = "registry+https://github.com/rust-lang/crates.io-index"
109 + checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
110 + dependencies = [
111 + "base64ct",
112 + "blake2",
113 + "cpufeatures",
114 + "password-hash",
115 + ]
116 +
117 + [[package]]
118 + name = "atk"
119 + version = "0.18.2"
120 + source = "registry+https://github.com/rust-lang/crates.io-index"
121 + checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
122 + dependencies = [
123 + "atk-sys",
124 + "glib",
125 + "libc",
126 + ]
127 +
128 + [[package]]
129 + name = "atk-sys"
130 + version = "0.18.2"
131 + source = "registry+https://github.com/rust-lang/crates.io-index"
132 + checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
133 + dependencies = [
134 + "glib-sys",
135 + "gobject-sys",
136 + "libc",
137 + "system-deps",
138 + ]
139 +
140 + [[package]]
141 + name = "atoi"
142 + version = "2.0.0"
143 + source = "registry+https://github.com/rust-lang/crates.io-index"
144 + checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
145 + dependencies = [
146 + "num-traits",
147 + ]
148 +
149 + [[package]]
150 + name = "atomic-waker"
151 + version = "1.1.2"
152 + source = "registry+https://github.com/rust-lang/crates.io-index"
153 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
154 +
155 + [[package]]
156 + name = "autocfg"
157 + version = "1.5.0"
158 + source = "registry+https://github.com/rust-lang/crates.io-index"
159 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
160 +
161 + [[package]]
162 + name = "balanced-breakfast-desktop"
163 + version = "0.1.0"
164 + dependencies = [
165 + "base64 0.22.1",
166 + "bb-core",
167 + "bb-db",
168 + "bb-feed",
169 + "bb-interface",
170 + "chrono",
171 + "rand 0.8.5",
172 + "roxmltree",
173 + "serde",
174 + "serde_json",
175 + "sha2",
176 + "sqlx",
177 + "synckit-client",
178 + "tauri",
179 + "tauri-build",
180 + "tauri-plugin-dialog",
181 + "tauri-plugin-shell",
182 + "tauri-plugin-window-state",
183 + "tokio",
184 + "toml 0.8.2",
185 + "tracing",
186 + "tracing-subscriber",
187 + "uuid",
188 + ]
189 +
190 + [[package]]
191 + name = "base64"
192 + version = "0.21.7"
193 + source = "registry+https://github.com/rust-lang/crates.io-index"
194 + checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
195 +
196 + [[package]]
197 + name = "base64"
198 + version = "0.22.1"
199 + source = "registry+https://github.com/rust-lang/crates.io-index"
200 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
201 +
202 + [[package]]
203 + name = "base64ct"
204 + version = "1.8.3"
205 + source = "registry+https://github.com/rust-lang/crates.io-index"
206 + checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
207 +
208 + [[package]]
209 + name = "bb-core"
210 + version = "0.1.0"
211 + dependencies = [
212 + "aes-gcm",
213 + "base64 0.22.1",
214 + "bb-db",
215 + "bb-feed",
216 + "bb-interface",
217 + "chrono",
218 + "html2text",
219 + "rand 0.8.5",
220 + "regex",
221 + "rhai",
222 + "roxmltree",
223 + "serde",
224 + "serde_json",
225 + "sqlx",
226 + "thiserror 1.0.69",
227 + "tokio",
228 + "tracing",
229 + "ureq",
230 + "url",
231 + ]
232 +
233 + [[package]]
234 + name = "bb-db"
235 + version = "0.1.0"
236 + dependencies = [
237 + "bb-interface",
238 + "chrono",
239 + "serde",
240 + "serde_json",
241 + "sqlx",
242 + "thiserror 1.0.69",
243 + "tokio",
244 + "tracing",
245 + "uuid",
246 + ]
247 +
248 + [[package]]
249 + name = "bb-feed"
250 + version = "0.1.0"
251 + dependencies = [
252 + "bb-db",
253 + "bb-interface",
254 + "chrono",
255 + "serde_json",
256 + "sqlx",
257 + "thiserror 1.0.69",
258 + "tokio",
259 + "tracing",
260 + ]
261 +
262 + [[package]]
263 + name = "bb-interface"
264 + version = "0.1.0"
265 + dependencies = [
266 + "chrono",
267 + "serde",
268 + ]
269 +
270 + [[package]]
271 + name = "bitflags"
272 + version = "1.3.2"
273 + source = "registry+https://github.com/rust-lang/crates.io-index"
274 + checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
275 +
276 + [[package]]
277 + name = "bitflags"
278 + version = "2.10.0"
279 + source = "registry+https://github.com/rust-lang/crates.io-index"
280 + checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
281 + dependencies = [
282 + "serde_core",
283 + ]
284 +
285 + [[package]]
286 + name = "blake2"
287 + version = "0.10.6"
288 + source = "registry+https://github.com/rust-lang/crates.io-index"
289 + checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
290 + dependencies = [
291 + "digest",
292 + ]
293 +
294 + [[package]]
295 + name = "block-buffer"
296 + version = "0.10.4"
297 + source = "registry+https://github.com/rust-lang/crates.io-index"
298 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
299 + dependencies = [
300 + "generic-array",
301 + ]
302 +
303 + [[package]]
304 + name = "block2"
305 + version = "0.6.2"
306 + source = "registry+https://github.com/rust-lang/crates.io-index"
307 + checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
308 + dependencies = [
309 + "objc2",
310 + ]
311 +
312 + [[package]]
313 + name = "brotli"
314 + version = "8.0.2"
315 + source = "registry+https://github.com/rust-lang/crates.io-index"
316 + checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
317 + dependencies = [
318 + "alloc-no-stdlib",
319 + "alloc-stdlib",
320 + "brotli-decompressor",
321 + ]
322 +
323 + [[package]]
324 + name = "brotli-decompressor"
325 + version = "5.0.0"
326 + source = "registry+https://github.com/rust-lang/crates.io-index"
327 + checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
328 + dependencies = [
329 + "alloc-no-stdlib",
330 + "alloc-stdlib",
331 + ]
332 +
333 + [[package]]
334 + name = "bumpalo"
335 + version = "3.19.1"
336 + source = "registry+https://github.com/rust-lang/crates.io-index"
337 + checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
338 +
339 + [[package]]
340 + name = "bytemuck"
341 + version = "1.25.0"
342 + source = "registry+https://github.com/rust-lang/crates.io-index"
343 + checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
344 +
345 + [[package]]
346 + name = "byteorder"
347 + version = "1.5.0"
348 + source = "registry+https://github.com/rust-lang/crates.io-index"
349 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
350 +
351 + [[package]]
352 + name = "bytes"
353 + version = "1.11.1"
354 + source = "registry+https://github.com/rust-lang/crates.io-index"
355 + checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
356 + dependencies = [
357 + "serde",
358 + ]
359 +
360 + [[package]]
361 + name = "cairo-rs"
362 + version = "0.18.5"
363 + source = "registry+https://github.com/rust-lang/crates.io-index"
364 + checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
365 + dependencies = [
366 + "bitflags 2.10.0",
367 + "cairo-sys-rs",
368 + "glib",
369 + "libc",
370 + "once_cell",
371 + "thiserror 1.0.69",
372 + ]
373 +
374 + [[package]]
375 + name = "cairo-sys-rs"
376 + version = "0.18.2"
377 + source = "registry+https://github.com/rust-lang/crates.io-index"
378 + checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
379 + dependencies = [
380 + "glib-sys",
381 + "libc",
382 + "system-deps",
383 + ]
384 +
385 + [[package]]
386 + name = "camino"
387 + version = "1.2.2"
388 + source = "registry+https://github.com/rust-lang/crates.io-index"
389 + checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
390 + dependencies = [
391 + "serde_core",
392 + ]
393 +
394 + [[package]]
395 + name = "cargo-platform"
396 + version = "0.1.9"
397 + source = "registry+https://github.com/rust-lang/crates.io-index"
398 + checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
399 + dependencies = [
400 + "serde",
401 + ]
402 +
403 + [[package]]
404 + name = "cargo_metadata"
405 + version = "0.19.2"
406 + source = "registry+https://github.com/rust-lang/crates.io-index"
407 + checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
408 + dependencies = [
409 + "camino",
410 + "cargo-platform",
411 + "semver",
412 + "serde",
413 + "serde_json",
414 + "thiserror 2.0.18",
415 + ]
416 +
417 + [[package]]
418 + name = "cargo_toml"
419 + version = "0.22.3"
420 + source = "registry+https://github.com/rust-lang/crates.io-index"
421 + checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
422 + dependencies = [
423 + "serde",
424 + "toml 0.9.12+spec-1.1.0",
425 + ]
426 +
427 + [[package]]
428 + name = "cc"
429 + version = "1.2.55"
430 + source = "registry+https://github.com/rust-lang/crates.io-index"
431 + checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
432 + dependencies = [
433 + "find-msvc-tools",
434 + "shlex",
435 + ]
436 +
437 + [[package]]
438 + name = "cesu8"
439 + version = "1.1.0"
440 + source = "registry+https://github.com/rust-lang/crates.io-index"
441 + checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
442 +
443 + [[package]]
444 + name = "cfb"
445 + version = "0.7.3"
446 + source = "registry+https://github.com/rust-lang/crates.io-index"
447 + checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
448 + dependencies = [
449 + "byteorder",
450 + "fnv",
451 + "uuid",
452 + ]
453 +
454 + [[package]]
455 + name = "cfg-expr"
456 + version = "0.15.8"
457 + source = "registry+https://github.com/rust-lang/crates.io-index"
458 + checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
459 + dependencies = [
460 + "smallvec",
461 + "target-lexicon",
462 + ]
463 +
464 + [[package]]
465 + name = "cfg-if"
466 + version = "1.0.4"
467 + source = "registry+https://github.com/rust-lang/crates.io-index"
468 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
469 +
470 + [[package]]
471 + name = "chacha20"
472 + version = "0.9.1"
473 + source = "registry+https://github.com/rust-lang/crates.io-index"
474 + checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
475 + dependencies = [
476 + "cfg-if",
477 + "cipher",
478 + "cpufeatures",
479 + ]
480 +
481 + [[package]]
482 + name = "chacha20poly1305"
483 + version = "0.10.1"
484 + source = "registry+https://github.com/rust-lang/crates.io-index"
485 + checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
486 + dependencies = [
487 + "aead",
488 + "chacha20",
489 + "cipher",
490 + "poly1305",
491 + "zeroize",
492 + ]
493 +
494 + [[package]]
495 + name = "chrono"
496 + version = "0.4.43"
497 + source = "registry+https://github.com/rust-lang/crates.io-index"
498 + checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
499 + dependencies = [
500 + "iana-time-zone",
Lines truncated
A Cargo.toml +49
@@ -0,0 +1,49 @@
1 + [workspace]
2 + resolver = "2"
3 + members = [
4 + "crates/bb-interface",
5 + "crates/bb-core",
6 + "crates/bb-feed",
7 + "crates/bb-db",
8 + "src-tauri",
9 + ]
10 + default-members = ["src-tauri"]
11 +
12 + [workspace.package]
13 + version = "0.1.0"
14 + edition = "2021"
15 + authors = ["BalancedBreakfast Contributors"]
16 + license-file = "LICENSE"
17 +
18 + [workspace.dependencies]
19 + # Core dependencies
20 + tokio = { version = "1.49.0", features = ["rt-multi-thread", "sync", "time", "macros"] }
21 + thiserror = "1.0.69"
22 + serde = { version = "1.0.228", features = ["derive"] }
23 + serde_json = "1.0.149"
24 + tracing = "0.1.44"
25 + tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
26 +
27 + # Database
28 + sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "chrono", "uuid"] }
29 +
30 + # XML parsing
31 + roxmltree = "0.19.0"
32 +
33 + # Encryption
34 + aes-gcm = "0.10"
35 + base64 = "0.22"
36 + rand = "0.8"
37 +
38 + # Utilities
39 + chrono = { version = "0.4.43", features = ["serde"] }
40 + uuid = { version = "1.20.0", features = ["v4", "serde"] }
41 + toml = "0.8.2"
42 + dirs = "6.0.0"
43 +
44 + # Internal crates
45 + bb-interface = { path = "crates/bb-interface" }
46 + bb-core = { path = "crates/bb-core" }
47 + bb-feed = { path = "crates/bb-feed" }
48 + bb-db = { path = "crates/bb-db" }
49 + synckit-client = { path = "../../synckit-client" }
A LICENSE +118
@@ -0,0 +1,118 @@
1 + PolyForm Noncommercial License 1.0.0
2 +
3 + <https://polyformproject.org/licenses/noncommercial/1.0.0>
4 +
5 + Acceptance
6 +
7 + In order to get any license under these terms, you must agree to them as
8 + both strict obligations and conditions to all your licenses.
9 +
10 + Copyright License
11 +
12 + The licensor grants you a copyright license for the software to do
13 + everything you might do with the software that would otherwise infringe
14 + the licensor's copyright in it for any permitted purpose. However, you
15 + may only distribute the software according to Distribution License and
16 + make changes or new works based on the software according to Changes and
17 + New Works License.
18 +
19 + Distribution License
20 +
21 + The licensor grants you an additional copyright license to distribute
22 + copies of the software. Your license to distribute covers distributing
23 + the software with changes and new works permitted by Changes and New
24 + Works License.
25 +
26 + Notices
27 +
28 + You must ensure that anyone who gets a copy of any part of the software
29 + from you also gets a copy of these terms or the URL for them above, as
30 + well as copies of any plain-text lines beginning with "Required Notice:"
31 + that the licensor provided with the software. For example:
32 +
33 + Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
34 +
35 + Changes and New Works License
36 +
37 + The licensor grants you an additional copyright license to make changes
38 + and new works based on the software for any permitted purpose.
39 +
40 + Patent License
41 +
42 + The licensor grants you a patent license for the software that covers
43 + patent claims the licensor can license, or becomes able to license, that
44 + you would infringe by using the software.
45 +
46 + Noncommercial Purposes
47 +
48 + Any noncommercial purpose is a permitted purpose.
49 +
50 + Personal Uses
51 +
52 + Personal use for research, experiment, and testing for the benefit of
53 + public knowledge, personal study, private entertainment, hobby projects,
54 + amateur pursuits, or religious observance, without any anticipated
55 + commercial application, is use for a permitted purpose.
56 +
57 + Noncommercial Organizations
58 +
59 + Use by any charitable organization, educational institution, public
60 + research organization, public safety or health organization,
61 + environmental protection organization, or government institution is use
62 + for a permitted purpose regardless of the source of funding or
63 + obligations resulting from the funding.
64 +
65 + Fair Use
66 +
67 + You may have "fair use" rights for the software under the law. These
68 + terms do not limit them.
69 +
70 + No Other Rights
71 +
72 + These terms do not allow you to sublicense or transfer any of your
73 + licenses to anyone else, or prevent the licensor from granting licenses
74 + to anyone else. These terms do not imply any other licenses.
75 +
76 + Patent Defense
77 +
78 + If you make any written claim that the software infringes or contributes
79 + to infringement of any patent, your patent license for the software
80 + granted under these terms ends immediately. If your company makes such a
81 + claim, your patent license ends immediately for work on behalf of your
82 + company.
83 +
84 + Violations
85 +
86 + The first time you are notified in writing that you have violated any of
87 + these terms, or done anything with the software not covered by your
88 + licenses, your licenses can nonetheless continue if you come into full
89 + compliance with these terms, and take practical steps to correct past
90 + violations, within 32 days of receiving notice. Otherwise, all your
91 + licenses end immediately.
92 +
93 + No Liability
94 +
95 + As far as the law allows, the software comes as is, without any warranty
96 + or condition, and the licensor will not be liable to you for any damages
97 + arising out of these terms or the use or nature of the software, under
98 + any kind of legal claim.
99 +
100 + Definitions
101 +
102 + The licensor is the individual or entity offering these terms, and the
103 + software is the software the licensor makes available under these terms.
104 +
105 + You refers to the individual or entity agreeing to these terms.
106 +
107 + Your company is any legal entity, sole proprietorship, or other kind of
108 + organization that you work for, plus all organizations that have control
109 + over, are under the control of, or are under common control with that
110 + organization. Control means ownership of substantially all the assets of
111 + an entity, or the power to direct its management and policies by vote,
112 + contract, or otherwise. Control can be direct or indirect.
113 +
114 + Your licenses are all the licenses granted to you for the software under
115 + these terms.
116 +
117 + Use means anything you do with the software requiring one of your
118 + licenses.
A README.md +294
@@ -0,0 +1,294 @@
1 + # Balanced Breakfast
2 +
3 + A desktop feed aggregator that unifies RSS, Hacker News, arXiv, and other sources into a single timeline. Built with Tauri 2, Rust, and a Rhai plugin system for extensible feed fetching.
4 +
5 + ## Prerequisites
6 +
7 + - **Rust** (stable toolchain, 2021 edition)
8 + - **Tauri 2 CLI** (`cargo install tauri-cli --version '^2'`)
9 + - **Linux only:** system dependencies for WebKitGTK
10 + ```
11 + # Debian/Ubuntu
12 + sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \
13 + libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
14 +
15 + # Arch
16 + sudo pacman -S webkit2gtk-4.1 base-devel curl wget file openssl \
17 + appmenu-gtk-module libappindicator-gtk3 librsvg2-dev
18 + ```
19 + - **macOS / Windows:** no extra system dependencies beyond Rust and the Tauri CLI.
20 +
21 + ## Build and Run
22 +
23 + ```sh
24 + # Development (hot-reload frontend, debug backend)
25 + cargo tauri dev
26 +
27 + # Production build
28 + cargo tauri build
29 +
30 + # Run all workspace tests
31 + cargo test --workspace
32 + ```
33 +
34 + ## Workspace Architecture
35 +
36 + The project is a Cargo workspace with four library crates and one application crate:
37 +
38 + | Crate | Path | Role |
39 + |-------|------|------|
40 + | `bb-interface` | `crates/bb-interface/` | Shared types for the plugin system: `FeedItem`, `FetchResult`, `ConfigSchema`, `ConfigField`, `BusserCapabilities`. Defines the contract between plugins and the host. |
41 + | `bb-core` | `crates/bb-core/` | Orchestrator (feed refresh scheduling, lifecycle), Rhai plugin runtime (engine setup, script loading, host function registration), config encryption, URL tracker-stripping. |
42 + | `bb-feed` | `crates/bb-feed/` | Feed aggregation and ordering. Merges items from all active sources and applies sort modes (newest, scored, unread-first, starred-first). |
43 + | `bb-db` | `crates/bb-db/` | SQLite persistence via sqlx. Repositories for feeds, items, tags, and busser key-value state. FTS5 full-text search. |
44 + | `src-tauri` | `src-tauri/` | Tauri 2 desktop shell. Tauri commands (thin wrappers over the library crates), app state, background auto-fetch, frontend (vanilla HTML/CSS/JS). |
45 +
46 + Dependency flow: `bb-interface` is leaf (no internal deps) -> `bb-core` and `bb-feed` depend on `bb-interface` -> `bb-db` depends on `bb-interface` -> `src-tauri` depends on all four.
47 +
48 + ## Plugin Authoring
49 +
50 + Plugins (called "bussers") are `.rhai` script files. Drop a `.rhai` file into the plugins directory and it is loaded on next launch -- no compilation required.
51 +
52 + **Plugin directory locations:**
53 + - Development: `plugins/` at the project root
54 + - Production: `<app_config_dir>/plugins/` (e.g. `~/Library/Application Support/com.balancedbreakfast.app/plugins/` on macOS)
55 +
56 + ### Required Functions
57 +
58 + Every plugin must define four functions:
59 +
60 + ```rhai
61 + fn id() { "my-source" }
62 +
63 + fn name() { "My Source" }
64 +
65 + fn config_schema() {
66 + #{
67 + description: "What this plugin does.",
68 + fields: [
69 + #{
70 + key: "feed_url",
71 + label: "Feed URL",
72 + field_type: "url",
73 + required: true,
74 + description: "Help text shown below the field",
75 + placeholder: "https://example.com/feed",
76 + default_value: ""
77 + }
78 + ]
79 + }
80 + }
81 +
82 + fn fetch(config, cursor) {
83 + // config is a map with keys from your schema + `feeds` array + `feed_url` shorthand
84 + // cursor is a string on subsequent pages, or () on first fetch
85 + #{
86 + items: [],
87 + has_more: false,
88 + next_cursor: ()
89 + }
90 + }
91 + ```
92 +
93 + ### Optional: capabilities()
94 +
95 + Return a map to advertise what your plugin supports. All fields default to `false`/`900` if omitted.
96 +
97 + ```rhai
98 + fn capabilities() {
99 + #{
100 + supports_pagination: true,
101 + supports_search: false,
102 + supports_date_filter: false,
103 + supports_read_state: false,
104 + supports_starring: false,
105 + requires_auth: false,
106 + fetch_interval_secs: 900 // auto-fetch interval; 900 = 15 min, 0 = disable
107 + }
108 + }
109 + ```
110 +
111 + `fetch_interval_secs` controls how often the background scheduler re-fetches this source. The default is 900 seconds (15 minutes). Set to 0 to disable auto-fetch entirely for a plugin.
112 +
113 + ### Config Schema Fields
114 +
115 + Each field in the `fields` array is a map with these keys:
116 +
117 + | Key | Type | Required | Notes |
118 + |-----|------|----------|-------|
119 + | `key` | string | yes | Config key passed to `fetch()` via `config.key` |
120 + | `label` | string | yes | Display label in the UI |
121 + | `field_type` | string | yes | One of: `text`, `textarea`, `secret`, `url`, `number`, `toggle`, `select` |
122 + | `required` | bool | no | Whether the field must be filled (default `false`) |
123 + | `description` | string | no | Help text shown below the field |
124 + | `default_value` | string | no | Pre-filled value (use `default_value`, not `default`, since `default` is a reserved word in Rhai) |
125 + | `placeholder` | string | no | Placeholder text |
126 + | `options` | array | no | String array of choices for `select` fields |
127 +
128 + ### Fetch Return Shape
129 +
130 + `fetch(config, cursor)` must return a map:
131 +
132 + ```rhai
133 + #{
134 + items: [
135 + #{
136 + id: "unique-item-id", // string, or #{ source: "my-source", item_id: "123" }
137 + bite: #{
138 + author: "Author Name", // shown in the item list
139 + text: "Headline or summary", // primary display text
140 + secondary: "42 pts", // optional, shown as secondary info
141 + indicator: "icon" // optional, type indicator
142 + },
143 + content: #{
144 + title: "Full Title", // optional
145 + body: "<p>HTML body</p>", // optional
146 + url: "https://example.com" // optional, link to original
147 + },
148 + meta: #{
149 + source_name: "My Source", // display name in source list
150 + published_at: 1700000000, // Unix timestamp (seconds)
151 + score: 42, // optional, for score-based ordering
152 + tags: ["tag1", "tag2"] // optional
153 + }
154 + }
155 + ],
156 + has_more: false, // true if more pages available
157 + next_cursor: () // opaque string passed to next fetch() call, or () if done
158 + }
159 + ```
160 +
161 + All sub-maps (`bite`, `content`, `meta`) are optional. Missing fields fall back to sensible defaults (empty strings, current timestamp, plugin ID as source name).
162 +
163 + ### Host Functions
164 +
165 + These functions are available to all Rhai scripts:
166 +
167 + **HTTP**
168 +
169 + | Function | Signature | Description |
170 + |----------|-----------|-------------|
171 + | `http_get` | `(url: string) -> string` | GET request, returns response body as string |
172 + | `http_get_json` | `(url: string) -> Dynamic` | GET request, returns parsed JSON as a Rhai map/array |
173 +
174 + **Parsing**
175 +
176 + | Function | Signature | Description |
177 + |----------|-----------|-------------|
178 + | `parse_feed` | `(input: string) -> map` | Auto-detects RSS/Atom/JSON Feed, returns `#{ title, link, entries }` where each entry has `id`, `title`, `summary`, `link`, `published`, `author`, `tags` |
179 + | `parse_xml` | `(xml: string) -> map` | Parses XML into nested maps with `tag`, `text`, `attrs`, `children` keys |
180 + | `parse_json` | `(json: string) -> Dynamic` | Parses a JSON string into Rhai values |
181 +
182 + **String Utilities**
183 +
184 + | Function | Signature | Description |
185 + |----------|-----------|-------------|
186 + | `truncate` | `(text, max_len) -> string` | Truncate with ellipsis (`...`) |
187 + | `str_contains` | `(text, pattern) -> bool` | Substring check |
188 + | `str_split` | `(text, separator) -> array` | Split into string array |
189 + | `str_replace` | `(text, from, to) -> string` | Replace all occurrences |
190 + | `str_trim` | `(text) -> string` | Trim whitespace |
191 + | `html_to_text` | `(html) -> string` | Strip HTML tags to plain text |
192 +
193 + **Date/Time**
194 +
195 + | Function | Signature | Description |
196 + |----------|-----------|-------------|
197 + | `timestamp_now` | `() -> int` | Current UTC Unix timestamp (seconds) |
198 + | `parse_datetime` | `(date_str) -> int` | Parse RFC 3339 or RFC 2822 date to Unix timestamp |
199 +
200 + **Other**
201 +
202 + | Function | Signature | Description |
203 + |----------|-----------|-------------|
204 + | `parse_int` | `(text) -> int or ()` | Parse string to integer; returns `()` on failure |
205 + | `strip_tracking` | `(url) -> string` | Remove UTM and other tracking query parameters |
206 + | `debug_print` | `(value) -> ()` | Print to the debug log (visible in dev console) |
207 +
208 + ### Minimal Example
209 +
210 + A complete plugin that fetches an RSS feed:
211 +
212 + ```rhai
213 + fn id() { "my-rss" }
214 + fn name() { "My RSS Plugin" }
215 +
216 + fn config_schema() {
217 + #{
218 + description: "A simple RSS reader.",
219 + fields: [
220 + #{
221 + key: "feed_url",
222 + label: "Feed URL",
223 + field_type: "url",
224 + required: true
225 + }
226 + ]
227 + }
228 + }
229 +
230 + fn fetch(config, cursor) {
231 + let xml = http_get(config.feed_url);
232 + let feed = parse_feed(xml);
233 + let items = [];
234 +
235 + for entry in feed.entries {
236 + let published = timestamp_now();
237 + if entry.published != () {
238 + published = entry.published;
239 + }
240 +
241 + items.push(#{
242 + id: entry.id,
243 + bite: #{ author: feed.title, text: truncate(entry.title, 100) },
244 + content: #{ title: entry.title, body: entry.summary, url: entry.link },
245 + meta: #{ source_name: feed.title, published_at: published }
246 + });
247 + }
248 +
249 + #{ items: items, has_more: false }
250 + }
251 + ```
252 +
253 + ### Sandbox Limits
254 +
255 + Rhai scripts run with safety limits to prevent hangs:
256 +
257 + - **100,000 max operations** per execution (a typical RSS fetch uses 1k-5k)
258 + - **128 max expression depth** for both expressions and function calls
259 +
260 + Scripts that exceed these limits are terminated with an error.
261 +
262 + ### JSON API Example
263 +
264 + For sources with JSON APIs (no XML), use `http_get_json` instead of `parse_feed`:
265 +
266 + ```rhai
267 + fn fetch(config, cursor) {
268 + let data = http_get_json("https://api.example.com/posts");
269 + let items = [];
270 +
271 + for post in data {
272 + items.push(#{
273 + id: "" + post.id,
274 + bite: #{ author: post.author, text: truncate(post.title, 100) },
275 + content: #{ title: post.title, body: post.body, url: post.url },
276 + meta: #{ source_name: "Example", published_at: parse_datetime(post.date) }
277 + });
278 + }
279 +
280 + #{ items: items, has_more: false }
281 + }
282 + ```
283 +
284 + ## Bundled Plugins
285 +
286 + Three plugins ship with the app:
287 +
288 + - **rss.rhai** -- RSS, Atom, and JSON Feed support
289 + - **hackernews.rhai** -- Hacker News stories (Top, New, Best, Ask, Show, Jobs)
290 + - **arxiv.rhai** -- arXiv papers by category
291 +
292 + ## License
293 +
294 + PolyForm Noncommercial 1.0.0
@@ -0,0 +1,40 @@
1 + [package]
2 + name = "bb-core"
3 + version.workspace = true
4 + edition.workspace = true
5 + description = "Core orchestrator and plugin manager for BalancedBreakfast"
6 +
7 + [dependencies]
8 + bb-interface.workspace = true
9 + bb-db.workspace = true
10 + bb-feed.workspace = true
11 + sqlx.workspace = true
12 + tokio.workspace = true
13 + thiserror.workspace = true
14 + tracing.workspace = true
15 + serde.workspace = true
16 + serde_json.workspace = true
17 + chrono.workspace = true
18 +
19 + # Encryption for plugin secrets
20 + aes-gcm = { workspace = true }
21 + base64 = { workspace = true }
22 + rand = { workspace = true }
23 +
24 + # Rhai scripting engine for plugins
25 + rhai = { version = "1.24.0", features = ["sync", "serde"] }
26 +
27 + # HTTP client for plugin scripts (blocking)
28 + ureq = { version = "2.12.1", features = ["json"] }
29 +
30 + # XML parsing for RSS/Atom feeds in plugins
31 + roxmltree.workspace = true
32 +
33 + # HTML to text conversion
34 + html2text = "0.12.6"
35 +
36 + # URL parsing for tracker stripping
37 + url = "2"
38 +
39 + # Regex for HTML URL rewriting
40 + regex = "1"
@@ -0,0 +1,253 @@
1 + //! Encryption helpers for plugin secrets at rest.
2 + //!
3 + //! Secret-type config fields are encrypted using AES-256-GCM before storage.
4 + //! The encrypted format is: `bb_enc:v1:<base64(nonce[12] || ciphertext || tag[16])>`
5 + //!
6 + //! Backward compatibility: `decrypt_field()` checks for the `bb_enc:v1:` prefix.
7 + //! If absent, returns the value as-is (plaintext passthrough). This allows existing
8 + //! configs to work without migration.
9 +
10 + use aes_gcm::aead::{Aead, OsRng};
11 + use aes_gcm::{AeadCore, Aes256Gcm, KeyInit};
12 + use base64::engine::general_purpose::STANDARD as BASE64;
13 + use base64::Engine;
14 + use bb_interface::{ConfigFieldType, ConfigSchema};
15 + use rand::RngCore;
16 + use std::path::Path;
17 +
18 + const PREFIX: &str = "bb_enc:v1:";
19 +
20 + /// Generate a random 256-bit encryption key.
21 + pub fn generate_key() -> [u8; 32] {
22 + let mut key = [0u8; 32];
23 + rand::thread_rng().fill_bytes(&mut key);
24 + key
25 + }
26 +
27 + /// Load an encryption key from a file, or generate and save one if it doesn't exist.
28 + pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], String> {
29 + if path.exists() {
30 + let data = std::fs::read(path).map_err(|e| format!("Failed to read encryption key: {e}"))?;
31 + if data.len() != 32 {
32 + return Err(format!(
33 + "Encryption key file has wrong size: {} bytes (expected 32)",
34 + data.len()
35 + ));
36 + }
37 + let mut key = [0u8; 32];
38 + key.copy_from_slice(&data);
39 + Ok(key)
40 + } else {
41 + let key = generate_key();
42 + std::fs::write(path, key).map_err(|e| format!("Failed to write encryption key: {e}"))?;
43 + #[cfg(unix)]
44 + {
45 + use std::os::unix::fs::PermissionsExt;
46 + let perms = std::fs::Permissions::from_mode(0o600);
47 + std::fs::set_permissions(path, perms)
48 + .map_err(|e| format!("Failed to set key file permissions: {e}"))?;
49 + }
50 + Ok(key)
51 + }
52 + }
53 +
54 + /// Encrypt a plaintext string field using AES-256-GCM.
55 + /// Returns a string in the format `bb_enc:v1:<base64(nonce || ciphertext || tag)>`.
56 + pub fn encrypt_field(plaintext: &str, key: &[u8; 32]) -> Result<String, String> {
57 + let cipher = Aes256Gcm::new(key.into());
58 + let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
59 + let ciphertext = cipher
60 + .encrypt(&nonce, plaintext.as_bytes())
61 + .map_err(|e| format!("Encryption failed: {e}"))?;
62 +
63 + // nonce (12 bytes) || ciphertext+tag
64 + let mut payload = Vec::with_capacity(12 + ciphertext.len());
65 + payload.extend_from_slice(&nonce);
66 + payload.extend_from_slice(&ciphertext);
67 +
68 + Ok(format!("{}{}", PREFIX, BASE64.encode(&payload)))
69 + }
70 +
71 + /// Decrypt a field value. If the value has the `bb_enc:v1:` prefix, decrypts it.
72 + /// Otherwise returns the value as-is (plaintext passthrough for backward compatibility).
73 + pub fn decrypt_field(value: &str, key: &[u8; 32]) -> Result<String, String> {
74 + let Some(encoded) = value.strip_prefix(PREFIX) else {
75 + return Ok(value.to_string());
76 + };
77 +
78 + let payload = BASE64
79 + .decode(encoded)
80 + .map_err(|e| format!("Base64 decode failed: {e}"))?;
81 +
82 + if payload.len() < 12 {
83 + return Err("Encrypted payload too short".to_string());
84 + }
85 +
86 + let (nonce_bytes, ciphertext) = payload.split_at(12);
87 + let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
88 + let cipher = Aes256Gcm::new(key.into());
89 +
90 + let plaintext = cipher
91 + .decrypt(nonce, ciphertext)
92 + .map_err(|e| format!("Decryption failed: {e}"))?;
93 +
94 + String::from_utf8(plaintext).map_err(|e| format!("Decrypted data is not valid UTF-8: {e}"))
95 + }
96 +
97 + /// Encrypt Secret-type fields in a config JSON object in-place.
98 + pub fn encrypt_config_secrets(
99 + config: &mut serde_json::Value,
100 + schema: &ConfigSchema,
101 + key: &[u8; 32],
102 + ) {
103 + let Some(obj) = config.as_object_mut() else {
104 + return;
105 + };
106 + for field in &schema.fields {
107 + if field.field_type != ConfigFieldType::Secret {
108 + continue;
109 + }
110 + if let Some(serde_json::Value::String(val)) = obj.get(&field.key) {
111 + if !val.is_empty() && !val.starts_with(PREFIX) {
112 + if let Ok(encrypted) = encrypt_field(val, key) {
113 + obj.insert(field.key.clone(), serde_json::Value::String(encrypted));
114 + }
115 + }
116 + }
117 + }
118 + }
119 +
120 + /// Decrypt Secret-type fields in a config JSON object in-place.
121 + pub fn decrypt_config_secrets(
122 + config: &mut serde_json::Value,
123 + schema: &ConfigSchema,
124 + key: &[u8; 32],
125 + ) {
126 + let Some(obj) = config.as_object_mut() else {
127 + return;
128 + };
129 + for field in &schema.fields {
130 + if field.field_type != ConfigFieldType::Secret {
131 + continue;
132 + }
133 + if let Some(serde_json::Value::String(val)) = obj.get(&field.key) {
134 + if val.starts_with(PREFIX) {
135 + if let Ok(decrypted) = decrypt_field(val, key) {
136 + obj.insert(field.key.clone(), serde_json::Value::String(decrypted));
137 + }
138 + }
139 + }
140 + }
141 + }
142 +
143 + #[cfg(test)]
144 + mod tests {
145 + use super::*;
146 + use bb_interface::ConfigField;
147 +
148 + #[test]
149 + fn roundtrip_encrypt_decrypt() {
150 + let key = generate_key();
151 + let plaintext = "my-secret-api-key-12345";
152 + let encrypted = encrypt_field(plaintext, &key).unwrap();
153 + assert!(encrypted.starts_with(PREFIX));
154 + assert_ne!(encrypted, plaintext);
155 +
156 + let decrypted = decrypt_field(&encrypted, &key).unwrap();
157 + assert_eq!(decrypted, plaintext);
158 + }
159 +
160 + #[test]
161 + fn passthrough_non_prefixed() {
162 + let key = generate_key();
163 + let plaintext = "just-a-regular-value";
164 + let result = decrypt_field(plaintext, &key).unwrap();
165 + assert_eq!(result, plaintext);
166 + }
167 +
168 + #[test]
169 + fn different_nonces_per_call() {
170 + let key = generate_key();
171 + let plaintext = "same-input";
172 + let a = encrypt_field(plaintext, &key).unwrap();
173 + let b = encrypt_field(plaintext, &key).unwrap();
174 + // Different nonces should produce different ciphertexts
175 + assert_ne!(a, b);
176 + // Both should decrypt to the same value
177 + assert_eq!(decrypt_field(&a, &key).unwrap(), plaintext);
178 + assert_eq!(decrypt_field(&b, &key).unwrap(), plaintext);
179 + }
180 +
181 + #[test]
182 + fn wrong_key_fails() {
183 + let key1 = generate_key();
184 + let key2 = generate_key();
185 + let encrypted = encrypt_field("secret", &key1).unwrap();
186 + assert!(decrypt_field(&encrypted, &key2).is_err());
187 + }
188 +
189 + #[test]
190 + fn encrypt_config_secrets_only_secrets() {
191 + let key = generate_key();
192 + let schema = ConfigSchema {
193 + description: "test".to_string(),
194 + fields: vec![
195 + ConfigField {
196 + key: "url".to_string(),
197 + label: "URL".to_string(),
198 + description: None,
199 + field_type: ConfigFieldType::Url,
200 + required: true,
201 + default: None,
202 + options: vec![],
203 + placeholder: None,
204 + },
205 + ConfigField {
206 + key: "api_key".to_string(),
207 + label: "API Key".to_string(),
208 + description: None,
209 + field_type: ConfigFieldType::Secret,
210 + required: true,
211 + default: None,
212 + options: vec![],
213 + placeholder: None,
214 + },
215 + ],
216 + };
217 +
218 + let mut config = serde_json::json!({
219 + "url": "https://example.com/feed",
220 + "api_key": "sk-12345"
221 + });
222 +
223 + encrypt_config_secrets(&mut config, &schema, &key);
224 +
225 + // URL should be unchanged
226 + assert_eq!(config["url"], "https://example.com/feed");
227 + // Secret should be encrypted
228 + let encrypted = config["api_key"].as_str().unwrap();
229 + assert!(encrypted.starts_with(PREFIX));
230 +
231 + // Decrypt and verify
232 + decrypt_config_secrets(&mut config, &schema, &key);
233 + assert_eq!(config["api_key"], "sk-12345");
234 + }
235 +
236 + #[test]
237 + fn load_or_create_key_creates_and_reloads() {
238 + let dir = std::env::temp_dir().join(format!("bb_test_{}", std::process::id()));
239 + std::fs::create_dir_all(&dir).unwrap();
240 + let path = dir.join("test.key");
241 +
242 + // First call creates the key
243 + let key1 = load_or_create_key(&path).unwrap();
244 + assert!(path.exists());
245 +
246 + // Second call loads the same key
247 + let key2 = load_or_create_key(&path).unwrap();
248 + assert_eq!(key1, key2);
249 +
250 + // Cleanup
251 + let _ = std::fs::remove_dir_all(&dir);
252 + }
253 + }
@@ -0,0 +1,16 @@
1 + //! BalancedBreakfast Core
2 + //!
3 + //! Central crate containing the orchestrator (feed refresh scheduling and
4 + //! lifecycle management) and the Rhai-based plugin system. The orchestrator
5 + //! coordinates plugins, the database, and the feed generator; the plugin
6 + //! manager handles loading, configuration, and sandboxed script execution.
7 +
8 + pub mod crypto;
9 + pub mod orchestrator;
10 + pub mod plugin_manager;
11 + pub mod rhai_plugin;
12 + pub mod url_cleaner;
13 +
14 + pub use orchestrator::*;
15 + pub use plugin_manager::*;
16 + pub use rhai_plugin::{RhaiPlugin, RhaiPluginError, RhaiPluginManager};
@@ -0,0 +1,469 @@
1 + //! Core orchestrator that ties all components together.
2 + //!
3 + //! The orchestrator owns the database, plugin manager, and feed generator.
4 + //! It coordinates plugin lifecycle, fetch scheduling, and data flow from
5 + //! bussers into the SQLite store.
6 +
7 + use std::sync::Arc;
8 +
9 + use bb_db::Database;
10 + use bb_interface::{BusserConfig, ConfigFieldType};
11 + use thiserror::Error;
12 + use tokio::sync::RwLock;
13 + use tracing::{debug, error, info};
14 +
15 + use crate::url_cleaner;
16 + use crate::PluginManager;
17 +
18 + #[derive(Error, Debug)]
19 + pub enum OrchestratorError {
20 + #[error("Database error: {0}")]
21 + Database(#[from] sqlx::Error),
22 + #[error("Plugin error: {0}")]
23 + Plugin(#[from] crate::PluginError),
24 + #[error("Feed error: {0}")]
25 + Feed(#[from] bb_feed::FeedError),
26 + #[error("Configuration error: {0}")]
27 + Config(String),
28 + }
29 +
30 + /// Configuration for the orchestrator
31 + #[derive(Clone)]
32 + pub struct OrchestratorConfig {
33 + /// SQLite connection URL (e.g. `sqlite:app.db?mode=rwc`).
34 + pub database_url: String,
35 + /// Directory containing `.rhai` plugin scripts.
36 + pub plugins_dir: String,
37 + /// Default auto-fetch interval in seconds.
38 + pub fetch_interval_secs: u64,
39 + }
40 +
41 + impl Default for OrchestratorConfig {
42 + fn default() -> Self {
43 + Self {
44 + database_url: "sqlite:balanced_breakfast.db?mode=rwc".to_string(),
45 + plugins_dir: "plugins".to_string(),
46 + fetch_interval_secs: 300, // 5 minutes
47 + }
48 + }
49 + }
50 +
51 + /// The main orchestrator that coordinates all components
52 + pub struct Orchestrator {
53 + db: Database,
54 + plugins: Arc<RwLock<PluginManager>>,
55 + /// AES-256-GCM key for encrypting/decrypting plugin secrets at rest.
56 + encryption_key: Option<[u8; 32]>,
57 + }
58 +
59 + impl Orchestrator {
60 + /// Create a new orchestrator
61 + pub async fn new(config: OrchestratorConfig) -> Result<Self, OrchestratorError> {
62 + info!("Initializing orchestrator");
63 +
64 + // Connect to database
65 + let db = Database::connect(&config.database_url).await?;
66 + info!("Connected to database");
67 +
68 + // Create plugin manager
69 + let plugins = PluginManager::new(&config.plugins_dir);
70 +
71 + Ok(Self {
72 + db,
73 + plugins: Arc::new(RwLock::new(plugins)),
74 + encryption_key: None,
75 + })
76 + }
77 +
78 + /// Run database migrations
79 + pub async fn migrate(&self) -> Result<(), OrchestratorError> {
80 + self.db.migrate().await.map_err(|e| {
81 + OrchestratorError::Config(format!("Migration failed: {}", e))
82 + })?;
83 + info!("Database migrations complete");
84 + Ok(())
85 + }
86 +
87 + /// Load all plugins
88 + pub async fn load_plugins(&self) -> Result<Vec<String>, OrchestratorError> {
89 + let mut plugins = self.plugins.write().await;
90 + let loaded = plugins.load_all()?;
91 + info!("Loaded {} plugins", loaded.len());
92 + Ok(loaded)
93 + }
94 +
95 + /// Initialize a plugin with config from database
96 + pub async fn init_plugin_from_db(
97 + &self,
98 + plugin_id: &str,
99 + ) -> Result<(), OrchestratorError> {
100 + // Get feeds for this busser from database
101 + let feeds = self.db.feeds().get_by_busser(plugin_id).await?;
102 +
103 + if feeds.is_empty() {
104 + info!("No feeds configured for plugin: {}", plugin_id);
105 + return Ok(());
106 + }
107 +
108 + // Get the plugin's config schema to identify URL fields
109 + let url_fields: Vec<String> = {
110 + let plugins = self.plugins.read().await;
111 + match plugins.get_config_schema(plugin_id) {
112 + Some(schema) => schema
113 + .fields
114 + .iter()
115 + .filter(|f| f.field_type == ConfigFieldType::Url)
116 + .map(|f| f.key.clone())
117 + .collect(),
118 + None => {
119 + debug!(
120 + "No config schema available for plugin {}, treating all fields as options",
121 + plugin_id
122 + );
123 + Vec::new()
124 + }
125 + }
126 + };
127 +
128 + // Get the full schema for decryption
129 + let full_schema: Option<bb_interface::ConfigSchema> = {
130 + let plugins = self.plugins.read().await;
131 + plugins.get_config_schema(plugin_id)
132 + };
133 +
134 + // Build config from feeds
135 + let mut config = BusserConfig::new();
136 + for feed in &feeds {
137 + let mut config_val = feed.config_json();
138 +
139 + // Decrypt Secret fields before passing to plugin
140 + if let (Some(key), Some(schema)) = (self.encryption_key.as_ref(), &full_schema) {
141 + crate::crypto::decrypt_config_secrets(&mut config_val, schema, key);
142 + }
143 +
144 + if let Some(obj) = config_val.as_object() {
145 + for (key, value) in obj {
146 + if let Some(v) = value.as_str() {
147 + if url_fields.contains(key) {
148 + // URL-type fields become feeds
149 + config = config.add_feed(v);
150 + } else {
151 + // Other fields become options
152 + config = config.add_option(key, v);
153 + }
154 + }
155 + }
156 + }
157 + }
158 +
159 + let plugins = self.plugins.read().await;
160 + plugins.initialize_plugin(plugin_id, config)?;
161 +
162 + Ok(())
163 + }
164 +
165 + /// Fetch items from a specific plugin
166 + pub async fn fetch_plugin(
167 + &self,
168 + plugin_id: &str,
169 + ) -> Result<usize, OrchestratorError> {
170 + // Get feed ID for this busser
171 + let feeds = self.db.feeds().get_by_busser(plugin_id).await?;
172 + let feed_id = feeds.first().map(|f| f.id);
173 +
174 + let Some(feed_id) = feed_id else {
175 + debug!("No feeds configured for plugin {}, skipping store", plugin_id);
176 + return Ok(0);
177 + };
178 +
179 + let plugins = self.plugins.read().await;
180 + let result = match plugins.fetch(plugin_id, None) {
181 + Ok(r) => r,
182 + Err(e) => {
183 + // Record fetch failure before propagating
184 + let error_msg = e.to_string();
185 + if let Err(db_err) = self
186 + .db
187 + .feeds()
188 + .record_fetch_failure(feed_id, &error_msg)
189 + .await
190 + {
191 + error!("Failed to record fetch failure: {}", db_err);
192 + }
193 + return Err(e.into());
194 + }
195 + };
196 +
197 + // Store items in database
198 + let mut count = 0;
199 + for item in result.items.iter() {
200 + let mut create_item = bb_db::CreateFeedItem::from_feed_item(item, feed_id);
201 +
202 + // Strip tracking parameters from item URL and body
203 + if let Some(ref url) = create_item.url {
204 + create_item.url = Some(url_cleaner::strip_tracking_params(url));
205 + }
206 + if let Some(ref body) = create_item.body {
207 + create_item.body = Some(url_cleaner::strip_tracking_from_html(body));
208 + }
209 +
210 + match self.db.items().upsert(create_item).await {
211 + Ok(_) => count += 1,
212 + Err(e) => {
213 + error!("Failed to store item: {}", e);
214 + }
215 + }
216 + }
217 +
218 + // Record successful fetch (resets failure counter)
219 + self.db.feeds().record_fetch_success(feed_id).await?;
220 +
221 + info!("Fetched {} items from {}", count, plugin_id);
222 + Ok(count)
223 + }
224 +
225 + /// Fetch from all active plugins
226 + pub async fn fetch_all(&self) -> Result<usize, OrchestratorError> {
227 + let plugin_ids = {
228 + let plugins = self.plugins.read().await;
229 + plugins.list_plugins()
230 + };
231 +
232 + let mut total = 0;
233 + for plugin_id in plugin_ids {
234 + match self.fetch_plugin(&plugin_id).await {
235 + Ok(count) => total += count,
236 + Err(e) => {
237 + error!("Failed to fetch from {}: {}", plugin_id, e);
238 + }
239 + }
240 + }
241 +
242 + Ok(total)
243 + }
244 +
245 + /// Get the database handle
246 + pub fn database(&self) -> &Database {
247 + &self.db
248 + }
249 +
250 + /// Get plugin manager
251 + pub fn plugins(&self) -> Arc<RwLock<PluginManager>> {
252 + self.plugins.clone()
253 + }
254 +
255 + /// Get a plugin's preferred fetch interval in seconds.
256 + pub async fn fetch_interval_secs(&self, plugin_id: &str) -> u64 {
257 + let plugins = self.plugins.read().await;
258 + plugins
259 + .get_capabilities(plugin_id)
260 + .map(|c| c.fetch_interval_secs)
261 + .unwrap_or(bb_interface::DEFAULT_FETCH_INTERVAL_SECS)
262 + }
263 +
264 + /// Set the encryption key for Secret-field encryption at rest.
265 + pub fn set_encryption_key(&mut self, key: [u8; 32]) {
266 + self.encryption_key = Some(key);
267 + }
268 +
269 + /// Get the encryption key, if set.
270 + pub fn encryption_key(&self) -> Option<&[u8; 32]> {
271 + self.encryption_key.as_ref()
272 + }
273 +
274 + /// Encrypt any plaintext Secret fields in existing feeds.
275 + /// Called once after plugin load to migrate legacy configs.
276 + pub async fn encrypt_existing_secrets(&self) -> Result<(), OrchestratorError> {
277 + let Some(key) = self.encryption_key.as_ref() else {
278 + return Ok(());
279 + };
280 +
281 + let all_feeds = self.db.feeds().list_all().await?;
282 + let plugins = self.plugins.read().await;
283 +
284 + for feed in &all_feeds {
285 + let schema = match plugins.get_config_schema(feed.busser_id.as_str()) {
286 + Some(s) => s,
287 + None => continue,
288 + };
289 +
290 + // Check if any Secret field needs encryption
291 + let mut config = feed.config_json();
292 + let has_plaintext_secret = schema.fields.iter().any(|f| {
293 + f.field_type == ConfigFieldType::Secret
294 + && config
295 + .get(&f.key)
296 + .and_then(|v| v.as_str())
297 + .is_some_and(|s| !s.is_empty() && !s.starts_with("bb_enc:v1:"))
298 + });
299 +
300 + if !has_plaintext_secret {
301 + continue;
302 + }
303 +
304 + crate::crypto::encrypt_config_secrets(&mut config, &schema, key);
305 +
306 + // Update the feed's config in the database
307 + let config_str =
308 + serde_json::to_string(&config).unwrap_or_else(|_| "{}".to_string());
309 + self.db.feeds().update_config(feed.id, &config_str).await?;
310 +
311 + info!("Encrypted secrets for feed: {}", feed.name);
312 + }
313 +
314 + Ok(())
315 + }
316 +
317 + /// Graceful shutdown
318 + pub async fn shutdown(&self) {
319 + info!("Shutting down orchestrator");
320 + let plugins = self.plugins.write().await;
321 + plugins.shutdown_all();
322 + }
323 + }
324 +
325 + #[cfg(test)]
326 + mod tests {
327 + use super::*;
328 + use bb_db::{BusserId, CreateFeed, CreateFeedItem};
329 + use chrono::Utc;
330 + use std::env::temp_dir;
331 +
332 + /// Build an `OrchestratorConfig` that uses in-memory SQLite and an empty
333 + /// temp directory for plugins.
334 + fn test_config(suffix: &str) -> OrchestratorConfig {
335 + let dir = temp_dir().join(format!("bb_orch_test_{}", suffix));
336 + let _ = std::fs::create_dir_all(&dir);
337 + OrchestratorConfig {
338 + database_url: "sqlite::memory:".to_string(),
339 + plugins_dir: dir.to_string_lossy().to_string(),
340 + fetch_interval_secs: 300,
341 + }
342 + }
343 +
344 + #[test]
345 + fn orchestrator_config_default_values() {
346 + let config = OrchestratorConfig::default();
347 + assert_eq!(config.fetch_interval_secs, 300);
348 + assert_eq!(config.plugins_dir, "plugins");
349 + assert!(config.database_url.contains("balanced_breakfast"));
350 + }
351 +
352 + #[tokio::test]
353 + async fn orchestrator_new_and_migrate() {
354 + let config = test_config("new_migrate");
355 + let orchestrator = Orchestrator::new(config).await.unwrap();
356 + // Migration should succeed on fresh in-memory DB.
357 + orchestrator.migrate().await.unwrap();
358 + }
359 +
360 + #[tokio::test]
361 + async fn fetch_interval_secs_default_for_unknown_plugin() {
362 + let config = test_config("fetch_interval");
363 + let orchestrator = Orchestrator::new(config).await.unwrap();
364 + // Unknown plugin returns the global default (900).
365 + let interval = orchestrator.fetch_interval_secs("nonexistent").await;
366 + assert_eq!(interval, bb_interface::DEFAULT_FETCH_INTERVAL_SECS);
367 + }
368 +
369 + #[tokio::test]
370 + async fn store_items_persists() {
371 + let config = test_config("store_items");
372 + let orchestrator = Orchestrator::new(config).await.unwrap();
373 + orchestrator.migrate().await.unwrap();
374 +
375 + let db = orchestrator.database();
376 +
377 + // Create a feed to attach items to.
378 + let feed = db
379 + .feeds()
380 + .create(CreateFeed {
381 + busser_id: BusserId::new("test"),
382 + name: "Test Feed".to_string(),
383 + config: serde_json::json!({}),
384 + })
385 + .await
386 + .unwrap();
387 + let feed_id = feed.id;
388 +
389 + // Insert an item directly through the DB layer.
390 + let item = CreateFeedItem {
391 + external_id: "test:item-1".to_string(),
392 + feed_id,
393 + busser_id: BusserId::new("test"),
394 + bite_author: "Author".to_string(),
395 + bite_text: "Hello world".to_string(),
396 + bite_secondary: None,
397 + bite_indicator: None,
398 + title: Some("Title".to_string()),
399 + body: None,
400 + url: Some("https://example.com".to_string()),
401 + media: vec![],
402 + published_at: Utc::now(),
403 + source_name: "Test".to_string(),
404 + score: None,
405 + tags: vec![],
406 + };
407 +
408 + db.items().upsert(item).await.unwrap();
409 +
410 + // Verify the item is persisted.
411 + let page = db
412 + .items()
413 + .list_by_feed(feed_id, 10, 0)
414 + .await
415 + .unwrap();
416 + assert_eq!(page.len(), 1);
417 + assert_eq!(page[0].bite_text, "Hello world");
418 + }
419 +
420 + #[tokio::test]
421 + async fn store_items_dedup() {
422 + let config = test_config("store_dedup");
423 + let orchestrator = Orchestrator::new(config).await.unwrap();
424 + orchestrator.migrate().await.unwrap();
425 +
426 + let db = orchestrator.database();
427 +
428 + let feed = db
429 + .feeds()
430 + .create(CreateFeed {
431 + busser_id: BusserId::new("test"),
432 + name: "Dedup Feed".to_string(),
433 + config: serde_json::json!({}),
434 + })
435 + .await
436 + .unwrap();
437 + let feed_id = feed.id;
438 +
439 + let make_item = || CreateFeedItem {
440 + external_id: "test:dup-1".to_string(),
441 + feed_id,
442 + busser_id: BusserId::new("test"),
443 + bite_author: "Author".to_string(),
444 + bite_text: "Duplicate item".to_string(),
445 + bite_secondary: None,
446 + bite_indicator: None,
447 + title: Some("Dup Title".to_string()),
448 + body: None,
449 + url: Some("https://example.com/dup".to_string()),
450 + media: vec![],
451 + published_at: Utc::now(),
452 + source_name: "Test".to_string(),
453 + score: None,
454 + tags: vec![],
455 + };
456 +
457 + // Insert the same item twice (same external_id).
458 + db.items().upsert(make_item()).await.unwrap();
459 + db.items().upsert(make_item()).await.unwrap();
460 +
461 + // Verify only one copy exists.
462 + let page = db
463 + .items()
464 + .list_by_feed(feed_id, 10, 0)
465 + .await
466 + .unwrap();
467 + assert_eq!(page.len(), 1);
468 + }
469 + }
@@ -0,0 +1,351 @@
1 + //! Plugin manager for Rhai script plugins
2 + //!
3 + //! Discovers and loads .rhai plugin scripts from the plugins directory.
4 +
5 + use std::collections::HashMap;
6 + use std::path::{Path, PathBuf};
7 + use std::sync::RwLock;
8 +
9 + use bb_interface::{BusserCapabilities, BusserConfig, ConfigSchema, FetchResult};
10 + use thiserror::Error;
11 + use tracing::{debug, error, info, warn};
12 +
13 + use crate::rhai_plugin::{RhaiPluginError, RhaiPluginManager};
14 +
15 + #[derive(Error, Debug)]
16 + pub enum PluginError {
17 + #[error("Failed to load plugin: {0}")]
18 + LoadError(String),
19 + #[error("Plugin not found: {0}")]
20 + NotFound(String),
21 + #[error("Plugin initialization failed: {0}")]
22 + InitError(String),
23 + #[error("Plugin fetch failed: {0}")]
24 + FetchError(String),
25 + #[error("Plugin already loaded: {0}")]
26 + AlreadyLoaded(String),
27 + #[error("IO error: {0}")]
28 + IoError(#[from] std::io::Error),
29 + #[error("Rhai error: {0}")]
30 + RhaiError(#[from] RhaiPluginError),
31 + #[error("Lock poisoned: {0}")]
32 + LockPoisoned(String),
33 + }
34 +
35 + /// Manages Rhai plugin loading and lifecycle
36 + pub struct PluginManager {
37 + plugins_dir: PathBuf,
38 + rhai_manager: RhaiPluginManager,
39 + plugin_configs: RwLock<HashMap<String, BusserConfig>>,
40 + }
41 +
42 + impl PluginManager {
43 + pub fn new(plugins_dir: impl AsRef<Path>) -> Self {
44 + Self {
45 + plugins_dir: plugins_dir.as_ref().to_path_buf(),
46 + rhai_manager: RhaiPluginManager::new(),
47 + plugin_configs: RwLock::new(HashMap::new()),
48 + }
49 + }
50 +
51 + /// Get the plugins directory
52 + pub fn plugins_dir(&self) -> &Path {
53 + &self.plugins_dir
54 + }
55 +
56 + /// Discover all .rhai plugin files in the plugins directory
57 + pub fn discover_plugins(&self) -> Result<Vec<PathBuf>, PluginError> {
58 + let mut plugins = Vec::new();
59 +
60 + if !self.plugins_dir.exists() {
61 + info!("Creating plugins directory: {:?}", self.plugins_dir);
62 + std::fs::create_dir_all(&self.plugins_dir)?;
63 + return Ok(plugins);
64 + }
65 +
66 + for entry in std::fs::read_dir(&self.plugins_dir)? {
67 + let entry = entry?;
68 + let path = entry.path();
69 +
70 + if Self::is_plugin_file(&path) {
71 + plugins.push(path);
72 + }
73 + }
74 +
75 + info!("Discovered {} Rhai plugins", plugins.len());
76 + Ok(plugins)
77 + }
78 +
79 + /// Check if a path is a valid plugin file (.rhai extension)
80 + fn is_plugin_file(path: &Path) -> bool {
81 + path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("rhai")
82 + }
83 +
84 + /// Load a plugin from a .rhai file path
85 + pub fn load_plugin(&mut self, path: impl AsRef<Path>) -> Result<String, PluginError> {
86 + let path = path.as_ref();
87 + info!("Loading Rhai plugin from: {:?}", path);
88 +
89 + let id = self
90 + .rhai_manager
91 + .load_plugin(path)
92 + .map_err(|e| PluginError::LoadError(format!("{}: {}", path.display(), e)))?;
93 +
94 + debug!("Loaded Rhai plugin: {}", id);
95 + Ok(id)
96 + }
97 +
98 + /// Load all discovered plugins
99 + pub fn load_all(&mut self) -> Result<Vec<String>, PluginError> {
100 + let paths = self.discover_plugins()?;
101 + let mut loaded = Vec::new();
102 +
103 + for path in paths {
104 + match self.load_plugin(&path) {
105 + Ok(id) => loaded.push(id),
106 + Err(e) => {
107 + warn!("Failed to load plugin {:?}: {}", path, e);
108 + }
109 + }
110 + }
111 +
112 + Ok(loaded)
113 + }
114 +
115 + /// Initialize a plugin with configuration
116 + pub fn initialize_plugin(
117 + &self,
118 + plugin_id: &str,
119 + config: BusserConfig,
120 + ) -> Result<(), PluginError> {
121 + // Verify plugin exists
122 + if self.rhai_manager.get(plugin_id).is_none() {
123 + return Err(PluginError::NotFound(plugin_id.to_string()));
124 + }
125 +
126 + // Store config for later use in fetch
127 + let mut configs = self
128 + .plugin_configs
129 + .write()
130 + .map_err(|e| PluginError::LockPoisoned(e.to_string()))?;
131 + configs.insert(plugin_id.to_string(), config);
132 +
133 + info!("Initialized plugin: {}", plugin_id);
134 + Ok(())
135 + }
136 +
137 + /// Fetch items from a plugin
138 + pub fn fetch(
139 + &self,
140 + plugin_id: &str,
141 + cursor: Option<String>,
142 + ) -> Result<FetchResult, PluginError> {
143 + let plugin = self
144 + .rhai_manager
145 + .get(plugin_id)
146 + .ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))?;
147 +
148 + let configs = self
149 + .plugin_configs
150 + .read()
151 + .map_err(|e| PluginError::LockPoisoned(e.to_string()))?;
152 + let config = configs
153 + .get(plugin_id)
154 + .ok_or_else(|| PluginError::InitError("Plugin not initialized".to_string()))?;
155 +
156 + plugin
157 + .fetch(config, cursor)
158 + .map_err(|e| PluginError::FetchError(e.to_string()))
159 + }
160 +
161 + /// Shutdown a plugin (no-op for Rhai plugins, kept for API compatibility)
162 + pub fn shutdown_plugin(&self, plugin_id: &str) -> Result<(), PluginError> {
163 + if self.rhai_manager.get(plugin_id).is_none() {
164 + return Err(PluginError::NotFound(plugin_id.to_string()));
165 + }
166 +
167 + info!("Shutdown plugin: {}", plugin_id);
168 + Ok(())
169 + }
170 +
171 + /// Shutdown all plugins
172 + pub fn shutdown_all(&self) {
173 + let plugin_ids = self.rhai_manager.list();
174 + for id in plugin_ids {
175 + if let Err(e) = self.shutdown_plugin(&id) {
176 + error!("Failed to shutdown plugin {}: {}", id, e);
177 + }
178 + }
179 + }
180 +
181 + /// Get list of loaded plugin IDs
182 + pub fn list_plugins(&self) -> Vec<String> {
183 + self.rhai_manager.list()
184 + }
185 +
186 + /// Get plugin info
187 + pub fn get_plugin_info(&self, plugin_id: &str) -> Option<(String, String, PathBuf)> {
188 + self.rhai_manager.get_info(plugin_id)
189 + }
190 +
191 + /// Get plugin capabilities
192 + pub fn get_capabilities(&self, plugin_id: &str) -> Option<BusserCapabilities> {
193 + self.rhai_manager.get(plugin_id).map(|p| p.capabilities())
194 + }
195 +
196 + /// Get plugin configuration schema
197 + pub fn get_config_schema(&self, plugin_id: &str) -> Option<ConfigSchema> {
198 + self.rhai_manager.get(plugin_id).and_then(|p| {
199 + match p.config_schema() {
200 + Ok(s) => Some(s),
201 + Err(e) => {
202 + warn!("Plugin {} config_schema() failed: {}", plugin_id, e);
203 + None
204 + }
205 + }
206 + })
207 + }
208 + }
209 +
210 + impl Drop for PluginManager {
211 + fn drop(&mut self) {
212 + self.shutdown_all();
213 + }
214 + }
215 +
216 + #[cfg(test)]
217 + mod tests {
218 + use super::*;
219 + use std::env::temp_dir;
220 +
221 + #[test]
222 + fn test_plugin_manager_creation() {
223 + let dir = temp_dir().join("bb_test_plugins");
224 + let manager = PluginManager::new(&dir);
225 + assert_eq!(manager.plugins_dir(), dir);
226 + }
227 +
228 + #[test]
229 + fn test_discover_empty_dir() {
230 + let dir = temp_dir().join("bb_test_plugins_empty");
231 + let _ = std::fs::remove_dir_all(&dir);
232 + let manager = PluginManager::new(&dir);
233 + let plugins = manager.discover_plugins().unwrap();
234 + assert!(plugins.is_empty());
235 + }
236 +
237 + #[test]
238 + fn test_is_plugin_file() {
239 + // Create a temporary test file
240 + let dir = temp_dir().join("bb_test_plugin_file");
241 + let _ = std::fs::create_dir_all(&dir);
242 + let rhai_path = dir.join("test.rhai");
243 + let rs_path = dir.join("test.rs");
244 + let dylib_path = dir.join("test.dylib");
245 +
246 + // Create the files
247 + std::fs::write(&rhai_path, "// test").unwrap();
248 + std::fs::write(&rs_path, "// test").unwrap();
249 + std::fs::write(&dylib_path, "test").unwrap();
250 +
251 + assert!(PluginManager::is_plugin_file(&rhai_path));
252 + assert!(!PluginManager::is_plugin_file(&rs_path));
253 + assert!(!PluginManager::is_plugin_file(&dylib_path));
254 +
255 + // Cleanup
256 + let _ = std::fs::remove_dir_all(&dir);
257 + }
258 +
259 + #[test]
260 + fn test_load_rhai_plugin() {
261 + let dir = temp_dir().join("bb_test_rhai_plugin");
262 + let _ = std::fs::create_dir_all(&dir);
263 +
264 + // Write a simple test plugin
265 + let plugin_code = r#"
266 + fn id() { "test" }
267 + fn name() { "Test Plugin" }
268 + fn config_schema() {
269 + #{
270 + description: "Test plugin",
271 + fields: []
272 + }
273 + }
274 + fn fetch(config, cursor) {
275 + #{
276 + items: [],
277 + has_more: false
278 + }
279 + }
280 + "#;
281 + let plugin_path = dir.join("test.rhai");
282 + std::fs::write(&plugin_path, plugin_code).unwrap();
283 +
284 + // Load the plugin
285 + let mut manager = PluginManager::new(&dir);
286 + let result = manager.load_plugin(&plugin_path);
287 + assert!(result.is_ok());
288 + assert_eq!(result.unwrap(), "test");
289 +
290 + // Verify the plugin is loaded
291 + let plugins = manager.list_plugins();
292 + assert!(plugins.contains(&"test".to_string()));
293 +
294 + // Verify we can get the schema
295 + let schema = manager.get_config_schema("test");
296 + assert!(schema.is_some());
297 + assert_eq!(schema.unwrap().description, "Test plugin");
298 +
299 + // Cleanup
300 + let _ = std::fs::remove_dir_all(&dir);
301 + }
302 +
303 + #[test]
304 + fn initialize_plugin_unknown_returns_not_found() {
305 + let dir = temp_dir().join("bb_test_init_unknown");
306 + let manager = PluginManager::new(&dir);
307 + let result = manager.initialize_plugin("nonexistent", BusserConfig::new());
308 + assert!(result.is_err());
309 + assert!(result.unwrap_err().to_string().contains("not found"));
310 + }
311 +
312 + #[test]
313 + fn fetch_without_init_returns_error() {
314 + let dir = temp_dir().join("bb_test_fetch_no_init");
315 + let _ = std::fs::create_dir_all(&dir);
316 +
317 + let plugin_code = r#"
318 + fn id() { "test_fetch" }
319 + fn name() { "Test Fetch" }
320 + fn config_schema() { #{ description: "test", fields: [] } }
321 + fn fetch(config, cursor) { #{ items: [], has_more: false } }
322 + "#;
323 + let plugin_path = dir.join("test_fetch.rhai");
324 + std::fs::write(&plugin_path, plugin_code).unwrap();
325 +
326 + let mut manager = PluginManager::new(&dir);
327 + manager.load_plugin(&plugin_path).unwrap();
328 +
329 + // Fetch without calling initialize_plugin first
330 + let result = manager.fetch("test_fetch", None);
331 + assert!(result.is_err());
332 +
333 + let _ = std::fs::remove_dir_all(&dir);
334 + }
335 +
336 + #[test]
337 + fn shutdown_plugin_unknown_returns_not_found() {
338 + let dir = temp_dir().join("bb_test_shutdown_unknown");
339 + let manager = PluginManager::new(&dir);
340 + let result = manager.shutdown_plugin("nonexistent");
341 + assert!(result.is_err());
342 + assert!(result.unwrap_err().to_string().contains("not found"));
343 + }
344 +
345 + #[test]
346 + fn get_capabilities_unknown_returns_none() {
347 + let dir = temp_dir().join("bb_test_caps_unknown");
348 + let manager = PluginManager::new(&dir);
349 + assert!(manager.get_capabilities("nonexistent").is_none());
350 + }
351 + }
@@ -0,0 +1,1234 @@
1 + //! Rhai <-> Rust type conversions: JSON, XML feed parsing, and Dynamic-to-domain mapping.
2 + //!
3 + //! # Plugin `fetch()` return contract
4 + //!
5 + //! A Rhai plugin's `fetch(config, cursor)` function must return a map with:
6 + //!
7 + //! - **`items`** (array, required) -- array of item maps, each containing:
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
14 + //! `fetch()` call for pagination.
15 + //!
16 + //! Missing optional fields fall back to sensible defaults (empty strings,
17 + //! current timestamp, etc.). See [`dynamic_to_fetch_result`] and
18 + //! [`dynamic_to_feed_item`] for the full conversion logic.
19 +
20 + use bb_interface::{
21 + BiteDisplay, BusserCapabilities, BusserConfig, ConfigField, ConfigFieldType, ConfigSchema,
22 + FeedItem, FeedItemContent, FeedItemId, FeedItemMeta, FetchResult,
23 + };
24 + use rhai::{Dynamic, Map};
25 + use tracing::warn;
26 +
27 + use super::RhaiPluginError;
28 +
29 + /// Convert serde_json::Value to Rhai Dynamic.
30 + pub(super) fn json_to_dynamic(val: serde_json::Value) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
31 + match val {
32 + serde_json::Value::Null => Ok(Dynamic::UNIT),
33 + serde_json::Value::Bool(b) => Ok(b.into()),
34 + serde_json::Value::Number(n) => {
35 + if let Some(i) = n.as_i64() {
36 + Ok(i.into())
37 + } else if let Some(f) = n.as_f64() {
38 + Ok(f.into())
39 + } else {
40 + Ok(n.to_string().into())
41 + }
42 + }
43 + serde_json::Value::String(s) => Ok(s.into()),
44 + serde_json::Value::Array(arr) => {
45 + let rhai_arr: Result<rhai::Array, _> = arr.into_iter().map(json_to_dynamic).collect();
46 + Ok(rhai_arr?.into())
47 + }
48 + serde_json::Value::Object(obj) => {
49 + let mut map = Map::new();
50 + for (k, v) in obj {
51 + map.insert(k.into(), json_to_dynamic(v)?);
52 + }
53 + Ok(map.into())
54 + }
55 + }
56 + }
57 +
58 + /// Parse XML into a simplified Dynamic structure.
59 + pub(super) fn parse_xml_to_dynamic(xml_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
60 + let doc =
61 + roxmltree::Document::parse(xml_str).map_err(|e| format!("XML parse error: {}", e))?;
62 +
63 + fn element_to_dynamic(node: roxmltree::Node) -> Dynamic {
64 + let mut map = Map::new();
65 +
66 + // Tag name
67 + if let Some(tag) = node.tag_name().name().into() {
68 + map.insert("tag".into(), Dynamic::from(tag.to_string()));
69 + }
70 +
71 + // Text content
72 + let text: String = node
73 + .children()
74 + .filter(|n| n.is_text())
75 + .map(|n| n.text().unwrap_or(""))
76 + .collect();
77 + if !text.trim().is_empty() {
78 + map.insert("text".into(), Dynamic::from(text.trim().to_string()));
79 + }
80 +
81 + // Attributes
82 + let mut attrs = Map::new();
83 + for attr in node.attributes() {
84 + attrs.insert(attr.name().into(), Dynamic::from(attr.value().to_string()));
85 + }
86 + if !attrs.is_empty() {
87 + map.insert("attrs".into(), Dynamic::from(attrs));
88 + }
89 +
90 + // Children
91 + let children: rhai::Array = node
92 + .children()
93 + .filter(|n| n.is_element())
94 + .map(element_to_dynamic)
95 + .collect();
96 + if !children.is_empty() {
97 + map.insert("children".into(), Dynamic::from(children));
98 + }
99 +
100 + Dynamic::from(map)
101 + }
102 +
103 + Ok(element_to_dynamic(doc.root_element()))
104 + }
105 +
106 + /// Parse RSS/Atom feed XML into a feed-friendly structure.
107 + pub(super) fn parse_feed_xml(xml_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
108 + let doc =
109 + roxmltree::Document::parse(xml_str).map_err(|e| format!("XML parse error: {}", e))?;
110 + let root = doc.root_element();
111 +
112 + let mut feed = Map::new();
113 + let mut entries: rhai::Array = Vec::new();
114 +
115 + // Detect feed type
116 + let is_atom = root.tag_name().name() == "feed";
117 + let is_rss = root.tag_name().name() == "rss" || root.tag_name().name() == "RDF";
118 +
119 + if is_atom {
120 + for child in root.children().filter(|n| n.is_element()) {
121 + match child.tag_name().name() {
122 + "title" => {
123 + feed.insert("title".into(), get_text_content(&child).into());
124 + }
125 + "link" => {
126 + if let Some(href) = child.attribute("href") {
127 + feed.insert("link".into(), href.to_string().into());
128 + }
129 + }
130 + "entry" => {
131 + entries.push(parse_atom_entry(&child));
132 + }
133 + _ => {}
134 + }
135 + }
136 + } else if is_rss {
137 + let channel = root
138 + .children()
139 + .find(|n| n.is_element() && n.tag_name().name() == "channel");
140 +
141 + if let Some(channel) = channel {
142 + for child in channel.children().filter(|n| n.is_element()) {
143 + match child.tag_name().name() {
144 + "title" => {
145 + feed.insert("title".into(), get_text_content(&child).into());
146 + }
147 + "link" => {
148 + feed.insert("link".into(), get_text_content(&child).into());
149 + }
150 + "item" => {
151 + entries.push(parse_rss_item(&child));
152 + }
153 + _ => {}
154 + }
155 + }
156 + }
157 + }
158 +
159 + feed.insert("entries".into(), entries.into());
160 + Ok(feed.into())
161 + }
162 +
163 + fn get_text_content(node: &roxmltree::Node) -> String {
164 + node.text().unwrap_or("").trim().to_string()
165 + }
166 +
167 + fn parse_atom_entry(node: &roxmltree::Node) -> Dynamic {
168 + let mut entry = Map::new();
169 +
170 + for child in node.children().filter(|n| n.is_element()) {
171 + match child.tag_name().name() {
172 + "id" => {
173 + entry.insert("id".into(), get_text_content(&child).into());
174 + }
175 + "title" => {
176 + entry.insert("title".into(), get_text_content(&child).into());
177 + }
178 + "summary" | "content" => {
179 + if !entry.contains_key("summary") {
180 + entry.insert("summary".into(), get_text_content(&child).into());
181 + }
182 + }
183 + "link" => {
184 + if let Some(href) = child.attribute("href") {
185 + entry.insert("link".into(), href.to_string().into());
186 + }
187 + }
188 + "published" | "updated" => {
189 + if !entry.contains_key("published") {
190 + let date_str = get_text_content(&child);
191 + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) {
192 + entry.insert("published".into(), dt.timestamp().into());
193 + }
194 + }
195 + }
196 + "author" => {
197 + if let Some(name_node) =
198 + child.children().find(|n| n.tag_name().name() == "name")
199 + {
200 + entry.insert("author".into(), get_text_content(&name_node).into());
201 + }
202 + }
203 + "category" => {
204 + if let Some(term) = child.attribute("term") {
205 + let tags_arr = entry
206 + .get("tags")
207 + .and_then(|existing| existing.clone().try_cast::<rhai::Array>())
208 + .unwrap_or_default();
209 + let mut tags_arr = tags_arr;
210 + tags_arr.push(term.to_string().into());
211 + entry.insert("tags".into(), tags_arr.into());
212 + }
213 + }
214 + _ => {}
215 + }
216 + }
217 +
218 + entry.into()
219 + }
220 +
221 + fn parse_rss_item(node: &roxmltree::Node) -> Dynamic {
222 + let mut item = Map::new();
223 +
224 + for child in node.children().filter(|n| n.is_element()) {
225 + match child.tag_name().name() {
226 + "guid" => {
227 + item.insert("id".into(), get_text_content(&child).into());
228 + }
229 + "title" => {
230 + item.insert("title".into(), get_text_content(&child).into());
231 + }
232 + "description" => {
233 + item.insert("summary".into(), get_text_content(&child).into());
234 + }
235 + "link" => {
236 + item.insert("link".into(), get_text_content(&child).into());
237 + }
238 + "pubDate" => {
239 + let date_str = get_text_content(&child);
240 + if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(&date_str) {
241 + item.insert("published".into(), dt.timestamp().into());
242 + }
243 + }
244 + "author" | "creator" => {
245 + item.insert("author".into(), get_text_content(&child).into());
246 + }
247 + "category" => {
248 + let tags_arr = item
249 + .get("tags")
250 + .and_then(|existing| existing.clone().try_cast::<rhai::Array>())
251 + .unwrap_or_default();
252 + let mut tags_arr = tags_arr;
253 + tags_arr.push(get_text_content(&child).into());
254 + item.insert("tags".into(), tags_arr.into());
255 + }
256 + _ => {}
257 + }
258 + }
259 +
260 + // If no guid, use link as id
261 + if !item.contains_key("id") {
262 + if let Some(link) = item.get("link") {
263 + item.insert("id".into(), link.clone());
264 + }
265 + }
266 +
267 + item.into()
268 + }
269 +
270 + /// Parse a JSON Feed (1.0/1.1) string into the same structure as `parse_feed_xml`:
271 + /// `{ title, link, entries: [{ id, title, summary, link, published, author, tags }] }`
272 + ///
273 + /// JSON Feed spec: <https://www.jsonfeed.org/version/1.1/>
274 + pub(super) fn parse_json_feed(json_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
275 + let json: serde_json::Value =
276 + serde_json::from_str(json_str).map_err(|e| format!("JSON parse error: {}", e))?;
277 +
278 + let obj = json
279 + .as_object()
280 + .ok_or("JSON Feed root must be an object")?;
281 +
282 + // Validate it's a JSON Feed by checking the version field
283 + let version = obj
284 + .get("version")
285 + .and_then(|v| v.as_str())
286 + .unwrap_or("");
287 + if !version.starts_with("https://jsonfeed.org/version/") {
288 + return Err(format!("Not a valid JSON Feed: version = {:?}", version).into());
289 + }
290 +
291 + let mut feed = Map::new();
292 +
293 + if let Some(title) = obj.get("title").and_then(|v| v.as_str()) {
294 + feed.insert("title".into(), title.to_string().into());
295 + }
296 + if let Some(link) = obj.get("home_page_url").and_then(|v| v.as_str()) {
297 + feed.insert("link".into(), link.to_string().into());
298 + }
299 +
300 + let mut entries: rhai::Array = Vec::new();
301 +
302 + if let Some(items) = obj.get("items").and_then(|v| v.as_array()) {
303 + for item in items {
304 + if let Some(item_obj) = item.as_object() {
305 + let mut entry = Map::new();
306 +
307 + if let Some(id) = item_obj.get("id").and_then(|v| v.as_str()) {
308 + entry.insert("id".into(), id.to_string().into());
309 + }
310 + if let Some(title) = item_obj.get("title").and_then(|v| v.as_str()) {
311 + entry.insert("title".into(), title.to_string().into());
312 + }
313 + if let Some(url) = item_obj.get("url").and_then(|v| v.as_str()) {
314 + entry.insert("link".into(), url.to_string().into());
315 + }
316 +
317 + // Prefer content_html over content_text for body/summary
318 + let body = item_obj
319 + .get("content_html")
320 + .and_then(|v| v.as_str())
321 + .or_else(|| item_obj.get("content_text").and_then(|v| v.as_str()))
322 + .or_else(|| item_obj.get("summary").and_then(|v| v.as_str()));
323 + if let Some(body) = body {
324 + entry.insert("summary".into(), body.to_string().into());
325 + }
326 +
327 + // Published date
328 + if let Some(date_str) = item_obj
329 + .get("date_published")
330 + .and_then(|v| v.as_str())
331 + {
332 + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(date_str) {
333 + entry.insert("published".into(), dt.timestamp().into());
334 + }
335 + }
336 +
337 + // Author: JSON Feed 1.1 uses "authors" array, 1.0 uses "author" object
338 + let author_name = item_obj
339 + .get("authors")
340 + .and_then(|v| v.as_array())
341 + .and_then(|arr| arr.first())
342 + .and_then(|a| a.get("name"))
343 + .and_then(|v| v.as_str())
344 + .or_else(|| {
345 + item_obj
346 + .get("author")
347 + .and_then(|v| v.get("name"))
348 + .and_then(|v| v.as_str())
349 + });
350 + if let Some(name) = author_name {
351 + entry.insert("author".into(), name.to_string().into());
352 + }
353 +
354 + // Tags
355 + if let Some(tags) = item_obj.get("tags").and_then(|v| v.as_array()) {
356 + let tags_arr: rhai::Array = tags
357 + .iter()
358 + .filter_map(|t| t.as_str().map(|s| Dynamic::from(s.to_string())))
359 + .collect();
360 + if !tags_arr.is_empty() {
361 + entry.insert("tags".into(), tags_arr.into());
362 + }
363 + }
364 +
365 + entries.push(entry.into());
366 + }
367 + }
368 + }
369 +
370 + feed.insert("entries".into(), entries.into());
371 + Ok(feed.into())
372 + }
373 +
374 + /// Convert BusserConfig to Rhai Dynamic map.
375 + pub(super) fn busser_config_to_dynamic(config: &BusserConfig) -> Dynamic {
376 + let mut map = Map::new();
377 +
378 + for (k, v) in &config.options {
379 + map.insert(k.clone().into(), v.clone().into());
380 + }
381 +
382 + let feeds: rhai::Array = config.feeds.iter().map(|f| f.clone().into()).collect();
383 + map.insert("feeds".into(), feeds.into());
384 +
385 + if let Some(first_feed) = config.feeds.first() {
386 + map.insert("feed_url".into(), first_feed.clone().into());
387 + }
388 +
389 + map.into()
390 + }
391 +
392 + /// Convert Dynamic to ConfigSchema.
393 + pub(super) fn dynamic_to_config_schema(val: Dynamic) -> Result<ConfigSchema, RhaiPluginError> {
394 + let map = val.try_cast::<Map>().ok_or_else(|| {
395 + RhaiPluginError::InvalidReturnType(
396 + "config_schema".into(),
397 + "expected object/map".into(),
398 + )
399 + })?;
400 +
401 + let description = map
402 + .get("description")
403 + .and_then(|v| v.clone().try_cast::<String>())
404 + .unwrap_or_default();
405 +
406 + let mut schema = ConfigSchema::new(description);
407 +
408 + if let Some(fields_val) = map.get("fields") {
409 + if let Some(fields) = fields_val.clone().try_cast::<rhai::Array>() {
410 + for field_val in fields {
411 + if let Some(field_map) = field_val.try_cast::<Map>() {
412 + let key = field_map
413 + .get("key")
414 + .and_then(|v| v.clone().try_cast::<String>())
415 + .unwrap_or_default();
416 +
417 + let label = field_map
418 + .get("label")
419 + .and_then(|v| v.clone().try_cast::<String>())
420 + .unwrap_or_default();
421 +
422 + let field_type_str = field_map
423 + .get("field_type")
424 + .and_then(|v| v.clone().try_cast::<String>())
425 + .unwrap_or_else(|| "text".to_string());
426 +
427 + let field_type = match field_type_str.to_lowercase().as_str() {
428 + "url" => ConfigFieldType::Url,
429 + "secret" => ConfigFieldType::Secret,
430 + "textarea" => ConfigFieldType::TextArea,
431 + "number" => ConfigFieldType::Number,
432 + "toggle" => ConfigFieldType::Toggle,
433 + "select" => ConfigFieldType::Select,
434 + _ => ConfigFieldType::Text,
435 + };
436 +
437 + let required = field_map
438 + .get("required")
439 + .and_then(|v| v.clone().try_cast::<bool>())
440 + .unwrap_or(false);
441 +
442 + // Try both "default" and "default_value" since "default" is reserved in Rhai
443 + let default_val = field_map
444 + .get("default_value")
445 + .or_else(|| field_map.get("default"))
446 + .and_then(|v| v.clone().try_cast::<String>());
447 +
448 + let mut field = ConfigField {
449 + key,
450 + label,
451 + field_type,
452 + required,
453 + description: field_map
454 + .get("description")
455 + .and_then(|v| v.clone().try_cast::<String>()),
456 + default: default_val,
457 + placeholder: field_map
458 + .get("placeholder")
459 + .and_then(|v| v.clone().try_cast::<String>()),
460 + options: Vec::new(),
461 + };
462 +
463 + if let Some(opts_val) = field_map.get("options") {
464 + if let Some(opts) = opts_val.clone().try_cast::<rhai::Array>() {
465 + field.options = opts
466 + .into_iter()
467 + .filter_map(|v| v.try_cast::<String>())
468 + .collect();
469 + }
470 + }
471 +
472 + schema.fields.push(field);
473 + }
474 + }
475 + }
476 + }
477 +
478 + Ok(schema)
479 + }
480 +
481 + /// Convert Dynamic to BusserCapabilities.
482 + pub(super) fn dynamic_to_capabilities(val: Dynamic) -> BusserCapabilities {
483 + let map = match val.try_cast::<Map>() {
484 + Some(m) => m,
485 + None => return BusserCapabilities::default(),
486 + };
487 +
488 + let defaults = BusserCapabilities::default();
489 +
490 + BusserCapabilities {
491 + supports_pagination: map
492 + .get("supports_pagination")
493 + .and_then(|v| v.clone().try_cast::<bool>())
494 + .unwrap_or(false),
495 + supports_search: map
496 + .get("supports_search")
497 + .and_then(|v| v.clone().try_cast::<bool>())
498 + .unwrap_or(false),
499 + supports_date_filter: map
500 + .get("supports_date_filter")
Lines truncated
@@ -0,0 +1,311 @@
1 + //! Host functions registered into the Rhai engine for plugin scripts.
2 +
3 + use rhai::{Dynamic, Engine};
4 +
5 + use super::conversions::{json_to_dynamic, parse_feed_xml, parse_json_feed, parse_xml_to_dynamic};
6 + use crate::url_cleaner;
7 +
8 + /// Register host functions available to Rhai scripts.
9 + ///
10 + /// # Trust model
11 + ///
12 + /// `http_get` and `http_get_json` allow plugins to fetch any URL without
13 + /// restriction. This is acceptable because plugins are local `.rhai` files
14 + /// that the user explicitly installs into their plugins directory — they are
15 + /// not downloaded or executed automatically. Users should only install plugins
16 + /// they trust, similar to shell scripts. If BB ever supports remote/untrusted
17 + /// plugin sources, HTTP sandboxing (domain allowlist or per-plugin permissions)
18 + /// must be added before that feature ships.
19 + pub(super) fn register_host_functions(engine: &mut Engine) {
20 + // HTTP GET returning string (see trust model above)
21 + engine.register_fn("http_get", |url: &str| -> Result<String, Box<rhai::EvalAltResult>> {
22 + ureq::get(url)
23 + .call()
24 + .map_err(|e| format!("HTTP request failed: {}", e))?
25 + .into_string()
26 + .map_err(|e| format!("Failed to read response: {}", e).into())
27 + });
28 +
29 + // HTTP GET returning parsed JSON as Dynamic
30 + engine.register_fn(
31 + "http_get_json",
32 + |url: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> {
33 + let response = ureq::get(url)
34 + .call()
35 + .map_err(|e| format!("HTTP request failed: {}", e))?;
36 +
37 + let json: serde_json::Value = response
38 + .into_json()
39 + .map_err(|e| format!("JSON parse failed: {}", e))?;
40 +
41 + json_to_dynamic(json)
42 + },
43 + );
44 +
45 + // Parse JSON string to Dynamic
46 + engine.register_fn(
47 + "parse_json",
48 + |json_str: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> {
49 + let json: serde_json::Value =
50 + serde_json::from_str(json_str).map_err(|e| format!("JSON parse error: {}", e))?;
51 + json_to_dynamic(json)
52 + },
53 + );
54 +
55 + // Parse XML string to a nested Dynamic map. "Simplified structure" means
56 + // each XML element becomes a map with "name", "attrs", "text", and "children"
57 + // keys — not a full DOM, but enough for plugins to walk RSS/Atom structures.
58 + engine.register_fn(
59 + "parse_xml",
60 + |xml_str: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> {
61 + parse_xml_to_dynamic(xml_str)
62 + },
63 + );
64 +
65 + // Parse an RSS/Atom/JSON Feed into a structured Dynamic with "title",
66 + // "entries", etc. Auto-detects JSON Feed (input starts with `{`) vs XML.
67 + // Extracts standard feed fields (title, link, published, author, body)
68 + // so plugins don't have to manually walk the tree.
69 + engine.register_fn(
70 + "parse_feed",
71 + |input: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> {
72 + if input.trim_start().starts_with('{') {
73 + parse_json_feed(input)
74 + } else {
75 + parse_feed_xml(input)
76 + }
77 + },
78 + );
79 +
80 + // Current UTC timestamp as Unix epoch seconds (i64).
81 + engine.register_fn("timestamp_now", || -> i64 { chrono::Utc::now().timestamp() });
82 +
83 + // Strip HTML tags and render to plain text (80-char line width).
84 + engine.register_fn("html_to_text", |html: &str| -> String {
85 + html2text::from_read(html.as_bytes(), 80)
86 + });
87 +
88 + // Truncate text with ellipsis
89 + engine.register_fn("truncate", |text: &str, max_len: i64| -> String {
90 + let max = max_len as usize;
91 + if text.len() <= max {
92 + text.to_string()
93 + } else if max <= 3 {
94 + text.chars().take(max).collect()
95 + } else {
96 + let truncated: String = text.chars().take(max - 3).collect();
97 + format!("{}...", truncated)
98 + }
99 + });
100 +
101 + // String contains check
102 + engine.register_fn("str_contains", |text: &str, pattern: &str| -> bool {
103 + text.contains(pattern)
104 + });
105 +
106 + // String split — returns a Rhai Array of strings (not an iterator).
107 + engine.register_fn("str_split", |text: &str, sep: &str| -> rhai::Array {
108 + text.split(sep).map(|s| Dynamic::from(s.to_string())).collect()
109 + });
110 +
111 + // String replace
112 + engine.register_fn("str_replace", |text: &str, from: &str, to: &str| -> String {
113 + text.replace(from, to)
114 + });
115 +
116 + // String trim
117 + engine.register_fn("str_trim", |text: &str| -> String { text.trim().to_string() });
118 +
119 + // Parse ISO 8601 date to timestamp
120 + engine.register_fn(
121 + "parse_datetime",
122 + |date_str: &str| -> Result<i64, Box<rhai::EvalAltResult>> {
123 + chrono::DateTime::parse_from_rfc3339(date_str)
124 + .or_else(|_| chrono::DateTime::parse_from_rfc2822(date_str))
125 + .map(|dt| dt.timestamp())
126 + .map_err(|e| format!("Date parse error: {}", e).into())
127 + },
128 + );
129 +
130 + // Debug print — outputs to tracing at debug level, visible in dev console.
131 + engine.register_fn("debug_print", |val: Dynamic| {
132 + tracing::debug!("Rhai debug: {:?}", val);
133 + });
134 +
135 + // Strip known tracking query parameters (utm_*, fbclid, gclid, etc.) from a URL.
136 + engine.register_fn("strip_tracking", |url: &str| -> String {
137 + url_cleaner::strip_tracking_params(url)
138 + });
139 +
140 + // Parse string to integer. Returns Dynamic::UNIT (Rhai's nil) on failure
141 + // instead of Option<i64> because Rhai doesn't natively handle Rust Options.
142 + engine.register_fn("parse_int", |text: &str| -> Dynamic {
143 + match text.parse::<i64>() {
144 + Ok(n) => n.into(),
145 + Err(_) => Dynamic::UNIT,
146 + }
147 + });
148 + }
149 +
150 + #[cfg(test)]
151 + mod tests {
152 + use rhai::Dynamic;
153 +
154 + /// Truncate text with ellipsis (mirrors the Rhai-registered closure for testing).
155 + fn truncate_text(text: &str, max: usize) -> String {
156 + if text.len() <= max {
157 + text.to_string()
158 + } else if max <= 3 {
159 + text.chars().take(max).collect()
160 + } else {
161 + let truncated: String = text.chars().take(max - 3).collect();
162 + format!("{}...", truncated)
163 + }
164 + }
165 +
166 + /// Parse string to integer (mirrors the Rhai-registered closure for testing).
167 + fn parse_int(text: &str) -> Dynamic {
168 + match text.parse::<i64>() {
169 + Ok(n) => n.into(),
170 + Err(_) => Dynamic::UNIT,
171 + }
172 + }
173 +
174 + /// Parse ISO 8601 / RFC 2822 date to Unix timestamp (mirrors the Rhai closure).
175 + fn parse_datetime(date_str: &str) -> Result<i64, String> {
176 + chrono::DateTime::parse_from_rfc3339(date_str)
177 + .or_else(|_| chrono::DateTime::parse_from_rfc2822(date_str))
178 + .map(|dt| dt.timestamp())
179 + .map_err(|e| format!("Date parse error: {}", e))
180 + }
181 +
182 + /// Strip HTML tags and render to plain text (mirrors the Rhai closure).
183 + fn html_to_text(html: &str) -> String {
184 + html2text::from_read(html.as_bytes(), 80)
185 + }
186 +
187 + /// String contains check (mirrors the Rhai closure).
188 + fn str_contains(text: &str, pattern: &str) -> bool {
189 + text.contains(pattern)
190 + }
191 +
192 + /// String split (mirrors the Rhai closure, returns Vec<String> for easier testing).
193 + fn str_split(text: &str, sep: &str) -> Vec<String> {
194 + text.split(sep).map(|s| s.to_string()).collect()
195 + }
196 +
197 + /// Current UTC timestamp as Unix epoch seconds (mirrors the Rhai closure).
198 + fn timestamp_now() -> i64 {
199 + chrono::Utc::now().timestamp()
200 + }
201 +
202 + #[test]
203 + fn truncate_shorter_than_max() {
204 + assert_eq!(truncate_text("hello", 10), "hello");
205 + }
206 +
207 + #[test]
208 + fn truncate_exact_length() {
209 + assert_eq!(truncate_text("hello", 5), "hello");
210 + }
211 +
212 + #[test]
213 + fn truncate_longer_than_max() {
214 + assert_eq!(truncate_text("hello world", 8), "hello...");
215 + }
216 +
217 + #[test]
218 + fn truncate_max_le_3() {
219 + assert_eq!(truncate_text("hello", 2), "he");
220 + }
221 +
222 + // ── parse_int tests ─────────────────────────────────────────
223 +
224 + #[test]
225 + fn parse_int_valid() {
226 + let result = parse_int("42");
227 + assert_eq!(result.as_int().unwrap(), 42);
228 + }
229 +
230 + #[test]
231 + fn parse_int_invalid() {
232 + let result = parse_int("abc");
233 + assert!(result.is_unit());
234 + }
235 +
236 + #[test]
237 + fn parse_int_negative() {
238 + let result = parse_int("-5");
239 + assert_eq!(result.as_int().unwrap(), -5);
240 + }
241 +
242 + // ── parse_datetime tests ────────────────────────────────────
243 +
244 + #[test]
245 + fn parse_datetime_iso8601() {
246 + let result = parse_datetime("2026-01-15T12:00:00Z").unwrap();
247 + // 2026-01-15T12:00:00Z as a Unix timestamp
248 + let expected = chrono::DateTime::parse_from_rfc3339("2026-01-15T12:00:00Z")
249 + .unwrap()
250 + .timestamp();
251 + assert_eq!(result, expected);
252 + }
253 +
254 + #[test]
255 + fn parse_datetime_invalid() {
256 + let result = parse_datetime("not-a-date");
257 + assert!(result.is_err());
258 + }
259 +
260 + // ── html_to_text tests ──────────────────────────────────────
261 +
262 + #[test]
263 + fn html_to_text_basic() {
264 + let result = html_to_text("<p>Hello</p>");
265 + assert!(result.contains("Hello"));
266 + }
267 +
268 + #[test]
269 + fn html_to_text_nested() {
270 + let result = html_to_text("<div><p>First</p><p>Second</p></div>");
271 + assert!(result.contains("First"));
272 + assert!(result.contains("Second"));
273 + }
274 +
275 + // ── str_contains tests ──────────────────────────────────────
276 +
277 + #[test]
278 + fn str_contains_found() {
279 + assert!(str_contains("hello world", "world"));
280 + }
281 +
282 + #[test]
283 + fn str_contains_not_found() {
284 + assert!(!str_contains("hello world", "xyz"));
285 + }
286 +
287 + // ── str_split tests ─────────────────────────────────────────
288 +
289 + #[test]
290 + fn str_split_basic() {
291 + let result = str_split("a,b,c", ",");
292 + assert_eq!(result, vec!["a", "b", "c"]);
293 + }
294 +
295 + #[test]
296 + fn str_split_no_match() {
297 + let result = str_split("abc", ",");
298 + assert_eq!(result, vec!["abc"]);
299 + }
300 +
301 + // ── timestamp_now tests ─────────────────────────────────────
302 +
303 + #[test]
304 + fn timestamp_now_reasonable() {
305 + let before = chrono::Utc::now().timestamp();
306 + let result = timestamp_now();
307 + let after = chrono::Utc::now().timestamp();
308 + assert!(result >= before);
309 + assert!(result <= after);
310 + }
311 + }
@@ -0,0 +1,248 @@
1 + //! Rhai plugin runtime for BalancedBreakfast.
2 + //!
3 + //! This module provides the Rhai scripting engine integration for plugins.
4 + //! Plugins are simple .rhai text files that implement the busser interface.
5 + //!
6 + //! Submodules:
7 + //! - [`conversions`]: Rhai ↔ Rust type conversions, XML/JSON parsing
8 + //! - [`host_functions`]: Functions registered into the Rhai engine for scripts
9 +
10 + mod conversions;
11 + mod host_functions;
12 +
13 + use std::collections::HashMap;
14 + use std::path::Path;
15 + use std::sync::Arc;
16 +
17 + use bb_interface::{BusserCapabilities, ConfigSchema};
18 + use rhai::{Dynamic, Engine, Scope, AST};
19 + use thiserror::Error;
20 + use tracing::debug;
21 +
22 + use conversions::{
23 + busser_config_to_dynamic, dynamic_to_capabilities, dynamic_to_config_schema,
24 + dynamic_to_fetch_result,
25 + };
26 + use host_functions::register_host_functions;
27 +
28 + #[derive(Error, Debug)]
29 + pub enum RhaiPluginError {
30 + #[error("Script compilation failed: {0}")]
31 + CompileError(String),
32 + #[error("Script execution failed: {0}")]
33 + RuntimeError(String),
34 + #[error("Missing required function: {0}")]
35 + MissingFunction(String),
36 + #[error("Invalid return type from function {0}: {1}")]
37 + InvalidReturnType(String, String),
38 + #[error("HTTP request failed: {0}")]
39 + HttpError(String),
40 + #[error("XML parse error: {0}")]
41 + XmlError(String),
42 + #[error("JSON parse error: {0}")]
43 + JsonError(String),
44 + }
45 +
46 + /// A compiled Rhai plugin.
47 + pub struct RhaiPlugin {
48 + pub id: String,
49 + pub name: String,
50 + pub path: std::path::PathBuf,
51 + ast: AST,
52 + engine: Arc<Engine>,
53 + }
54 +
55 + impl RhaiPlugin {
56 + /// Get the plugin's configuration schema.
57 + pub fn config_schema(&self) -> Result<ConfigSchema, RhaiPluginError> {
58 + let mut scope = Scope::new();
59 + let result: Dynamic = self
60 + .engine
61 + .call_fn(&mut scope, &self.ast, "config_schema", ())
62 + .map_err(|e| RhaiPluginError::RuntimeError(e.to_string()))?;
63 +
64 + dynamic_to_config_schema(result)
65 + }
66 +
67 + /// Get the plugin's capabilities.
68 + pub fn capabilities(&self) -> BusserCapabilities {
69 + let mut scope = Scope::new();
70 + match self
71 + .engine
72 + .call_fn::<Dynamic>(&mut scope, &self.ast, "capabilities", ())
73 + {
74 + Ok(result) => dynamic_to_capabilities(result),
75 + Err(e) => {
76 + tracing::warn!("Plugin {} capabilities() failed: {}", self.id, e);
77 + BusserCapabilities::default()
78 + }
79 + }
80 + }
81 +
82 + /// Fetch items from the plugin.
83 + pub fn fetch(
84 + &self,
85 + config: &bb_interface::BusserConfig,
86 + cursor: Option<String>,
87 + ) -> Result<bb_interface::FetchResult, RhaiPluginError> {
88 + let mut scope = Scope::new();
89 +
90 + let config_map = busser_config_to_dynamic(config);
91 +
92 + let cursor_val: Dynamic = match cursor {
93 + Some(c) => c.into(),
94 + None => Dynamic::UNIT,
95 + };
96 +
97 + let result: Dynamic = self
98 + .engine
99 + .call_fn(&mut scope, &self.ast, "fetch", (config_map, cursor_val))
100 + .map_err(|e| RhaiPluginError::RuntimeError(e.to_string()))?;
101 +
102 + dynamic_to_fetch_result(result, &self.id)
103 + }
104 + }
105 +
106 + /// Manager for Rhai plugins.
107 + pub struct RhaiPluginManager {
108 + engine: Arc<Engine>,
109 + plugins: HashMap<String, RhaiPlugin>,
110 + }
111 +
112 + impl RhaiPluginManager {
113 + /// Create a new plugin manager with the Rhai engine configured.
114 + ///
115 + /// Safety limits prevent malicious or buggy scripts from hanging the app:
116 + /// - `max_operations(100_000)`: caps total operations per script execution.
117 + /// A typical RSS fetch costs 1k–5k ops; 100k allows complex plugins while
118 + /// catching infinite loops.
119 + /// - `max_expr_depths(128, 128)`: limits AST nesting depth for both expressions
120 + /// and functions, preventing stack overflows from deeply recursive scripts.
121 + pub fn new() -> Self {
122 + let mut engine = Engine::new();
123 +
124 + engine.set_max_expr_depths(128, 128);
125 + engine.set_max_operations(100_000);
126 +
127 + register_host_functions(&mut engine);
128 +
129 + Self {
130 + engine: Arc::new(engine),
131 + plugins: HashMap::new(),
132 + }
133 + }
134 +
135 + /// Load a plugin from a .rhai file.
136 + pub fn load_plugin(&mut self, path: &Path) -> Result<String, RhaiPluginError> {
137 + let script = std::fs::read_to_string(path)
138 + .map_err(|e| RhaiPluginError::CompileError(format!("Failed to read file: {}", e)))?;
139 +
140 + let ast = self
141 + .engine
142 + .compile(&script)
143 + .map_err(|e| RhaiPluginError::CompileError(e.to_string()))?;
144 +
145 + let mut scope = Scope::new();
146 +
147 + let id: String = self
148 + .engine
149 + .call_fn(&mut scope, &ast, "id", ())
150 + .map_err(|e| RhaiPluginError::MissingFunction(format!("id(): {}", e)))?;
151 +
152 + let name: String = self
153 + .engine
154 + .call_fn(&mut scope, &ast, "name", ())
155 + .map_err(|e| RhaiPluginError::MissingFunction(format!("name(): {}", e)))?;
156 +
157 + let _: Dynamic = self
158 + .engine
159 + .call_fn(&mut scope, &ast, "config_schema", ())
160 + .map_err(|e| RhaiPluginError::MissingFunction(format!("config_schema(): {}", e)))?;
161 +
162 + debug!("Loaded Rhai plugin: {} ({})", name, id);
163 +
164 + let plugin = RhaiPlugin {
165 + id: id.clone(),
166 + name,
167 + path: path.to_path_buf(),
168 + ast,
169 + engine: self.engine.clone(),
170 + };
171 +
172 + self.plugins.insert(id.clone(), plugin);
173 + Ok(id)
174 + }
175 +
176 + /// Get a loaded plugin by ID.
177 + pub fn get(&self, id: &str) -> Option<&RhaiPlugin> {
178 + self.plugins.get(id)
179 + }
180 +
181 + /// List all loaded plugin IDs.
182 + pub fn list(&self) -> Vec<String> {
183 + self.plugins.keys().cloned().collect()
184 + }
185 +
186 + /// Get plugin info (id, name, path).
187 + pub fn get_info(&self, id: &str) -> Option<(String, String, std::path::PathBuf)> {
188 + self.plugins
189 + .get(id)
190 + .map(|p| (p.id.clone(), p.name.clone(), p.path.clone()))
191 + }
192 + }
193 +
194 + impl Default for RhaiPluginManager {
195 + fn default() -> Self {
196 + Self::new()
197 + }
198 + }
199 +
200 + #[cfg(test)]
201 + mod tests {
202 + use super::*;
203 +
204 + #[test]
205 + fn error_display_compile() {
206 + let e = RhaiPluginError::CompileError("syntax error".into());
207 + assert_eq!(e.to_string(), "Script compilation failed: syntax error");
208 + }
209 +
210 + #[test]
211 + fn error_display_runtime() {
212 + let e = RhaiPluginError::RuntimeError("nil access".into());
213 + assert_eq!(e.to_string(), "Script execution failed: nil access");
214 + }
215 +
216 + #[test]
217 + fn error_display_missing_fn() {
218 + let e = RhaiPluginError::MissingFunction("fetch".into());
219 + assert_eq!(e.to_string(), "Missing required function: fetch");
220 + }
221 +
222 + #[test]
223 + fn error_display_invalid_return() {
224 + let e = RhaiPluginError::InvalidReturnType("config".into(), "expected map".into());
225 + assert_eq!(
226 + e.to_string(),
227 + "Invalid return type from function config: expected map"
228 + );
229 + }
230 +
231 + #[test]
232 + fn error_display_http() {
233 + let e = RhaiPluginError::HttpError("timeout".into());
234 + assert_eq!(e.to_string(), "HTTP request failed: timeout");
235 + }
236 +
237 + #[test]
238 + fn error_display_xml() {
239 + let e = RhaiPluginError::XmlError("unclosed tag".into());
240 + assert_eq!(e.to_string(), "XML parse error: unclosed tag");
241 + }
242 +
243 + #[test]
244 + fn error_display_json() {
245 + let e = RhaiPluginError::JsonError("unexpected token".into());
246 + assert_eq!(e.to_string(), "JSON parse error: unexpected token");
247 + }
248 + }
@@ -0,0 +1,180 @@
1 + //! URL tracker parameter stripping.
2 + //!
3 + //! Removes known tracking parameters (utm_*, fbclid, gclid, etc.) from URLs
4 + //! to improve privacy and reduce link clutter. Also provides an HTML rewriter
5 + //! that cleans URLs inside `href` and `src` attributes.
6 +
7 + use std::sync::LazyLock;
8 +
9 + use regex::Regex;
10 + use url::Url;
11 +
12 + /// Tracking parameter prefixes — any query parameter whose name starts with one
13 + /// of these (case-insensitive) is stripped.
14 + const TRACKING_PREFIXES: &[&str] = &["utm_"];
15 +
16 + /// Tracking parameter exact names (case-insensitive).
17 + const TRACKING_PARAMS: &[&str] = &[
18 + "fbclid",
19 + "gclid",
20 + "msclkid",
21 + "twclid",
22 + "dclid",
23 + "mc_cid",
24 + "mc_eid",
25 + "oly_anon_id",
26 + "oly_enc_id",
27 + "_openstat",
28 + "vero_id",
29 + "wickedid",
30 + "yclid",
31 + "zanpid",
32 + "_hsenc",
33 + "_hsmi",
34 + "hsa_cam",
35 + "hsa_grp",
36 + "hsa_mt",
37 + "hsa_src",
38 + "hsa_ad",
39 + "hsa_acc",
40 + "hsa_net",
41 + "hsa_ver",
42 + "hsa_la",
43 + "hsa_ol",
44 + "hsa_kw",
45 + "hsa_tgt",
46 + ];
47 +
48 + /// Returns `true` if a query parameter name is a known tracker.
49 + fn is_tracking_param(name: &str) -> bool {
50 + let lower = name.to_ascii_lowercase();
51 + if TRACKING_PARAMS.iter().any(|&p| lower == p) {
52 + return true;
53 + }
54 + TRACKING_PREFIXES
55 + .iter()
56 + .any(|&prefix| lower.starts_with(prefix))
57 + }
58 +
59 + /// Strip known tracking query parameters from a URL string.
60 + ///
61 + /// Returns the cleaned URL. If the input is not a valid URL it is returned unchanged.
62 + pub fn strip_tracking_params(url_str: &str) -> String {
63 + let mut parsed = match Url::parse(url_str) {
64 + Ok(u) => u,
65 + Err(_) => return url_str.to_string(),
66 + };
67 +
68 + let clean_pairs: Vec<(String, String)> = parsed
69 + .query_pairs()
70 + .filter(|(name, _)| !is_tracking_param(name))
71 + .map(|(k, v)| (k.into_owned(), v.into_owned()))
72 + .collect();
73 +
74 + // Clear and rebuild query string
75 + if clean_pairs.is_empty() {
76 + parsed.set_query(None);
77 + } else {
78 + parsed
79 + .query_pairs_mut()
80 + .clear()
81 + .extend_pairs(clean_pairs);
82 + }
83 +
84 + parsed.to_string()
85 + }
86 +
87 + /// Regex matching `href="..."` and `src="..."` attribute values in HTML.
88 + static ATTR_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
89 + Regex::new(r#"(href|src)\s*=\s*"([^"]+)""#).expect("invalid regex")
90 + });
91 +
92 + /// Strip tracking parameters from all `href` and `src` URLs in an HTML string.
93 + pub fn strip_tracking_from_html(html: &str) -> String {
94 + ATTR_URL_RE
95 + .replace_all(html, |caps: &regex::Captures| {
96 + let attr = &caps[1];
97 + let url = &caps[2];
98 + let cleaned = strip_tracking_params(url);
99 + format!("{}=\"{}\"", attr, cleaned)
100 + })
101 + .into_owned()
102 + }
103 +
104 + #[cfg(test)]
105 + mod tests {
106 + use super::*;
107 +
108 + #[test]
109 + fn strips_utm_params() {
110 + let url = "https://example.com/page?utm_source=twitter&utm_medium=social&id=42";
111 + let cleaned = strip_tracking_params(url);
112 + assert_eq!(cleaned, "https://example.com/page?id=42");
113 + }
114 +
115 + #[test]
116 + fn strips_fbclid() {
117 + let url = "https://example.com/?fbclid=abc123&page=1";
118 + let cleaned = strip_tracking_params(url);
119 + assert_eq!(cleaned, "https://example.com/?page=1");
120 + }
121 +
122 + #[test]
123 + fn strips_gclid() {
124 + let url = "https://example.com/?gclid=xyz&ref=home";
125 + let cleaned = strip_tracking_params(url);
126 + assert_eq!(cleaned, "https://example.com/?ref=home");
127 + }
128 +
129 + #[test]
130 + fn preserves_clean_params() {
131 + let url = "https://example.com/search?q=rust&page=2";
132 + let cleaned = strip_tracking_params(url);
133 + assert_eq!(cleaned, "https://example.com/search?q=rust&page=2");
134 + }
135 +
136 + #[test]
137 + fn handles_no_query() {
138 + let url = "https://example.com/page";
139 + let cleaned = strip_tracking_params(url);
140 + assert_eq!(cleaned, "https://example.com/page");
141 + }
142 +
143 + #[test]
144 + fn handles_all_tracking_removed() {
145 + let url = "https://example.com/?utm_source=x&fbclid=y";
146 + let cleaned = strip_tracking_params(url);
147 + assert_eq!(cleaned, "https://example.com/");
148 + }
149 +
150 + #[test]
151 + fn handles_invalid_url() {
152 + let url = "not a url at all";
153 + let cleaned = strip_tracking_params(url);
154 + assert_eq!(cleaned, "not a url at all");
155 + }
156 +
157 + #[test]
158 + fn strips_from_html_body() {
159 + let html = r#"<a href="https://example.com/?utm_source=rss&id=1">click</a> and <img src="https://img.example.com/pic.jpg?fbclid=abc">"#;
160 + let cleaned = strip_tracking_from_html(html);
161 + assert_eq!(
162 + cleaned,
163 + r#"<a href="https://example.com/?id=1">click</a> and <img src="https://img.example.com/pic.jpg">"#
164 + );
165 + }
166 +
167 + #[test]
168 + fn html_preserves_clean_urls() {
169 + let html = r#"<a href="https://example.com/page">link</a>"#;
170 + let cleaned = strip_tracking_from_html(html);
171 + assert_eq!(cleaned, html);
172 + }
173 +
174 + #[test]
175 + fn case_insensitive_param_match() {
176 + let url = "https://example.com/?UTM_SOURCE=x&FBCLID=y&keep=z";
177 + let cleaned = strip_tracking_params(url);
178 + assert_eq!(cleaned, "https://example.com/?keep=z");
179 + }
180 + }
@@ -0,0 +1,16 @@
1 + [package]
2 + name = "bb-db"
3 + version.workspace = true
4 + edition.workspace = true
5 + description = "Database layer for BalancedBreakfast"
6 +
7 + [dependencies]
8 + bb-interface.workspace = true
9 + sqlx.workspace = true
10 + tokio.workspace = true
11 + thiserror.workspace = true
12 + tracing.workspace = true
13 + chrono.workspace = true
14 + uuid.workspace = true
15 + serde.workspace = true
16 + serde_json.workspace = true
@@ -0,0 +1,257 @@
1 + //! Strongly-typed entity ID newtypes.
2 + //!
3 + //! These wrappers prevent accidental ID mixups (e.g. passing a FeedId where
4 + //! an ItemId is expected). They serialize transparently as UUID strings.
5 +
6 + /// Define a UUID-based entity ID newtype with all necessary trait impls.
7 + ///
8 + /// Generated traits: Clone, Copy, Debug, PartialEq, Eq, Hash, Display,
9 + /// FromStr, Serialize, Deserialize, sqlx Type/Encode/Decode (SQLite TEXT).
10 + macro_rules! define_uuid_id {
11 + ($($name:ident),+ $(,)?) => {$(
12 + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
13 + pub struct $name(uuid::Uuid);
14 +
15 + impl Default for $name {
16 + fn default() -> Self {
17 + Self::new()
18 + }
19 + }
20 +
21 + impl $name {
22 + /// Generate a new random ID.
23 + pub fn new() -> Self {
24 + Self(uuid::Uuid::new_v4())
25 + }
26 +
27 + /// Wrap an existing UUID.
28 + pub fn from_uuid(uuid: uuid::Uuid) -> Self {
29 + Self(uuid)
30 + }
31 +
32 + /// Access the inner UUID.
33 + pub fn as_uuid(&self) -> &uuid::Uuid {
34 + &self.0
35 + }
36 + }
37 +
38 + impl std::fmt::Display for $name {
39 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 + self.0.fmt(f)
41 + }
42 + }
43 +
44 + impl std::str::FromStr for $name {
45 + type Err = uuid::Error;
46 + fn from_str(s: &str) -> Result<Self, Self::Err> {
47 + uuid::Uuid::parse_str(s).map(Self)
48 + }
49 + }
50 +
51 + impl serde::Serialize for $name {
52 + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
53 + self.0.serialize(serializer)
54 + }
55 + }
56 +
57 + impl<'de> serde::Deserialize<'de> for $name {
58 + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
59 + uuid::Uuid::deserialize(deserializer).map(Self)
60 + }
61 + }
62 +
63 + impl sqlx::Type<sqlx::Sqlite> for $name {
64 + fn type_info() -> sqlx::sqlite::SqliteTypeInfo {
65 + <String as sqlx::Type<sqlx::Sqlite>>::type_info()
66 + }
67 +
68 + fn compatible(ty: &sqlx::sqlite::SqliteTypeInfo) -> bool {
69 + <String as sqlx::Type<sqlx::Sqlite>>::compatible(ty)
70 + }
71 + }
72 +
73 + impl<'q> sqlx::Encode<'q, sqlx::Sqlite> for $name {
74 + fn encode_by_ref(
75 + &self,
76 + buf: &mut <sqlx::Sqlite as sqlx::Database>::ArgumentBuffer<'q>,
77 + ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
78 + let s = self.0.to_string();
79 + <String as sqlx::Encode<'q, sqlx::Sqlite>>::encode_by_ref(&s, buf)
80 + }
81 + }
82 +
83 + impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for $name {
84 + fn decode(
85 + value: <sqlx::Sqlite as sqlx::Database>::ValueRef<'r>,
86 + ) -> Result<Self, sqlx::error::BoxDynError> {
87 + let s = <&str as sqlx::Decode<'r, sqlx::Sqlite>>::decode(value)?;
88 + Ok(Self(uuid::Uuid::parse_str(s)?))
89 + }
90 + }
91 + )+};
92 + }
93 +
94 + define_uuid_id!(FeedId, ItemId, BusserStateId);
95 +
96 + // ── BusserId ─────────────────────────────────────────────────────
97 +
98 + /// Plugin/busser identifier (e.g. "rss", "hn", "arxiv").
99 + ///
100 + /// Unlike the UUID-based IDs above, BusserId wraps a plain string.
101 + /// Implements `Deref<Target = str>` for transparent string access.
102 + #[derive(Clone, Debug, PartialEq, Eq, Hash)]
103 + pub struct BusserId(String);
104 +
105 + impl BusserId {
106 + pub fn new(s: impl Into<String>) -> Self {
107 + Self(s.into())
108 + }
109 +
110 + pub fn as_str(&self) -> &str {
111 + &self.0
112 + }
113 +
114 + pub fn into_inner(self) -> String {
115 + self.0
116 + }
117 + }
118 +
119 + impl std::ops::Deref for BusserId {
120 + type Target = str;
121 + fn deref(&self) -> &str {
122 + &self.0
123 + }
124 + }
125 +
126 + impl std::fmt::Display for BusserId {
127 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 + self.0.fmt(f)
129 + }
130 + }
131 +
132 + impl PartialEq<str> for BusserId {
133 + fn eq(&self, other: &str) -> bool {
134 + self.0 == other
135 + }
136 + }
137 +
138 + impl PartialEq<&str> for BusserId {
139 + fn eq(&self, other: &&str) -> bool {
140 + self.0 == *other
141 + }
142 + }
143 +
144 + impl From<String> for BusserId {
145 + fn from(s: String) -> Self {
146 + Self(s)
147 + }
148 + }
149 +
150 + impl From<&str> for BusserId {
151 + fn from(s: &str) -> Self {
152 + Self(s.to_string())
153 + }
154 + }
155 +
156 + impl serde::Serialize for BusserId {
157 + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
158 + self.0.serialize(serializer)
159 + }
160 + }
161 +
162 + impl<'de> serde::Deserialize<'de> for BusserId {
163 + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
164 + String::deserialize(deserializer).map(Self)
165 + }
166 + }
167 +
168 + impl sqlx::Type<sqlx::Sqlite> for BusserId {
169 + fn type_info() -> sqlx::sqlite::SqliteTypeInfo {
170 + <String as sqlx::Type<sqlx::Sqlite>>::type_info()
171 + }
172 +
173 + fn compatible(ty: &sqlx::sqlite::SqliteTypeInfo) -> bool {
174 + <String as sqlx::Type<sqlx::Sqlite>>::compatible(ty)
175 + }
176 + }
177 +
178 + impl<'q> sqlx::Encode<'q, sqlx::Sqlite> for BusserId {
179 + fn encode_by_ref(
180 + &self,
181 + buf: &mut <sqlx::Sqlite as sqlx::Database>::ArgumentBuffer<'q>,
182 + ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
183 + <String as sqlx::Encode<'q, sqlx::Sqlite>>::encode_by_ref(&self.0, buf)
184 + }
185 + }
186 +
187 + impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for BusserId {
188 + fn decode(
189 + value: <sqlx::Sqlite as sqlx::Database>::ValueRef<'r>,
190 + ) -> Result<Self, sqlx::error::BoxDynError> {
191 + let s = <String as sqlx::Decode<'r, sqlx::Sqlite>>::decode(value)?;
192 + Ok(Self(s))
193 + }
194 + }
195 +
196 + #[cfg(test)]
197 + mod tests {
198 + use super::*;
199 +
200 + #[test]
201 + fn feed_id_roundtrip() {
202 + let id = FeedId::new();
203 + let s = id.to_string();
204 + let parsed: FeedId = s.parse().unwrap();
205 + assert_eq!(id, parsed);
206 + }
207 +
208 + #[test]
209 + fn item_id_roundtrip() {
210 + let id = ItemId::new();
211 + let s = id.to_string();
212 + let parsed: ItemId = s.parse().unwrap();
213 + assert_eq!(id, parsed);
214 + }
215 +
216 + #[test]
217 + fn different_id_types_are_distinct() {
218 + assert_ne!(
219 + std::any::TypeId::of::<FeedId>(),
220 + std::any::TypeId::of::<ItemId>()
221 + );
222 + assert_ne!(
223 + std::any::TypeId::of::<FeedId>(),
224 + std::any::TypeId::of::<BusserStateId>()
225 + );
226 + }
227 +
228 + #[test]
229 + fn busser_id_deref_to_str() {
230 + let id = BusserId::new("rss");
231 + assert_eq!(&*id, "rss");
232 + assert_eq!(id.as_str(), "rss");
233 + assert_eq!(id, "rss");
234 + }
235 +
236 + #[test]
237 + fn busser_id_display() {
238 + let id = BusserId::new("hn");
239 + assert_eq!(format!("{}", id), "hn");
240 + }
241 +
242 + #[test]
243 + fn uuid_id_serde_roundtrip() {
244 + let id = FeedId::new();
245 + let json = serde_json::to_string(&id).unwrap();
246 + let parsed: FeedId = serde_json::from_str(&json).unwrap();
247 + assert_eq!(id, parsed);
248 + }
249 +
250 + #[test]
251 + fn busser_id_serde_roundtrip() {
252 + let id = BusserId::new("rss");
253 + let json = serde_json::to_string(&id).unwrap();
254 + let parsed: BusserId = serde_json::from_str(&json).unwrap();
255 + assert_eq!(id, parsed);
256 + }
257 + }
A docs/todo.md +127
A logo.svg +43