| 1 |
# Contributing to Balanced Breakfast |
| 2 |
|
| 3 |
Patterns, conventions, and rules for working on the Balanced Breakfast codebase. |
| 4 |
|
| 5 |
## Project Structure |
| 6 |
|
| 7 |
``` |
| 8 |
balanced_breakfast/ (workspace root) |
| 9 |
Cargo.toml # Workspace definition |
| 10 |
crates/ |
| 11 |
bb-interface/ # Shared types (leaf crate, no internal deps) |
| 12 |
bb-core/ # Orchestrator, Rhai plugin runtime, crypto |
| 13 |
bb-feed/ # Feed aggregation, ordering, scoring |
| 14 |
bb-db/ # SQLite persistence, repositories |
| 15 |
src-tauri/ |
| 16 |
src/ |
| 17 |
main.rs # Tauri setup |
| 18 |
state.rs # AppState, scheduler, abort handles |
| 19 |
commands/ # Tauri commands (thin wrappers) |
| 20 |
sync_service.rs # SyncKit change tracking |
| 21 |
frontend/ |
| 22 |
js/ # JavaScript modules (IIFE + BB.* namespace) |
| 23 |
css/ # Styles |
| 24 |
tests/ # Command integration tests |
| 25 |
plugins/ # Bundled Rhai plugins (rss, mastodon, hackernews, etc.) |
| 26 |
migrations/sqlite/ # SQLx SQLite migrations |
| 27 |
``` |
| 28 |
|
| 29 |
### Crate Dependency Flow |
| 30 |
|
| 31 |
``` |
| 32 |
bb-interface (leaf, shared types) |
| 33 |
↓ |
| 34 |
bb-core (orchestrator, Rhai runtime) |
| 35 |
bb-feed (aggregation, ordering) |
| 36 |
bb-db (SQLite persistence) |
| 37 |
↓ |
| 38 |
src-tauri (Tauri shell) |
| 39 |
``` |
| 40 |
|
| 41 |
**Strict rule:** `bb-interface` has no internal dependencies. Library crates never depend on `src-tauri`. |
| 42 |
|
| 43 |
## Orchestrator Pattern |
| 44 |
|
| 45 |
The `Orchestrator` (`bb-core::orchestrator`) is the central coordinator. It owns the `Database` and a `PluginManager` behind `Arc<RwLock<>>`. |
| 46 |
|
| 47 |
**Responsibilities:** |
| 48 |
- Plugin lifecycle (load, init, fetch, shutdown) |
| 49 |
- Upserting fetched items (with HTML sanitization and URL tracker stripping) |
| 50 |
- Secret encryption for plugin configs |
| 51 |
- Circuit breaker enforcement (10 consecutive failures disables auto-fetch) |
| 52 |
|
| 53 |
**Lock discipline:** Hold the `PluginManager` lock only during synchronous operations. Release before any `.await`: |
| 54 |
|
| 55 |
```rust |
| 56 |
// GOOD: Release lock before async DB ops |
| 57 |
let fetch_result = { |
| 58 |
let plugins = self.plugins.read().await; |
| 59 |
plugins.fetch(plugin_id, None) |
| 60 |
}; |
| 61 |
// Lock released here, now safe to await |
| 62 |
self.db.feeds().record_fetch_success(feed_id).await?; |
| 63 |
``` |
| 64 |
|
| 65 |
## Plugin System |
| 66 |
|
| 67 |
### Contract: 4 Required Functions |
| 68 |
|
| 69 |
Every `.rhai` plugin must implement: |
| 70 |
|
| 71 |
|
| 72 |
|
| 73 |
| `fn id()` | `String` | Unique identifier (e.g., `"rss"`) | |
| 74 |
| `fn name()` | `String` | Display name (e.g., `"RSS"`) | |
| 75 |
| `fn config_schema()` | `Map` | `#{ description, fields: [...] }` | |
| 76 |
| `fn fetch(config, cursor)` | `Map` | `#{ items: [...], has_more: bool }` | |
| 77 |
|
| 78 |
Config field types: `Text`, `TextArea`, `Secret`, `Url`, `Number`, `Toggle`, `Select`. |
| 79 |
|
| 80 |
### Sandbox Limits |
| 81 |
|
| 82 |
Each plugin runs in an isolated Rhai engine with strict limits (operations, recursion, HTTP timeout/count, response size, SSRF blocking). Full limit table and isolation details in `docs/architecture.md` § Sandboxing. |
| 83 |
|
| 84 |
### Plugin Style |
| 85 |
|
| 86 |
All `.rhai` plugins follow the cross-project Rhai style guide. Run `_meta/scripts/lint-rhai.sh` to check formatting. Key points: 4-space indent, `snake_case` functions, `UPPER_CASE` constants, header comment block, contract functions before helpers. |
| 87 |
|
| 88 |
### Host Functions |
| 89 |
|
| 90 |
Plugins call host-provided functions: `http_get(url)`, `http_get_json(url)`, `parse_feed(xml)`, `parse_html(html)`, `extract_readability(url)`, `truncate(s, n)`, `to_json(val)`, `from_json(s)`, etc. See `plugins/` for bundled examples. |
| 91 |
|
| 92 |
## Thin Tauri Commands |
| 93 |
|
| 94 |
Commands in `src-tauri/src/commands/` are thin wrappers. They extract parameters, call orchestrator or repository methods, and return serialized response types: |
| 95 |
|
| 96 |
```rust |
| 97 |
#[tauri::command] |
| 98 |
#[instrument(skip_all)] |
| 99 |
pub async fn list_items( |
| 100 |
state: State<'_, Arc<AppState>>, |
| 101 |
filter: ItemFilter, |
| 102 |
) -> Result<Vec<ItemSnapshot>, ApiError> { |
| 103 |
let db = state.orchestrator.database(); |
| 104 |
let items = db.items().list(filter).await?; |
| 105 |
Ok(items.into_iter().map(ItemSnapshot::from).collect()) |
| 106 |
} |
| 107 |
``` |
| 108 |
|
| 109 |
**Rules:** |
| 110 |
- Every command: `#[instrument(skip_all)]`, returns `Result<T, ApiError>` |
| 111 |
- No business logic in commands — that belongs in library crates |
| 112 |
- Response types use `#[serde(rename_all = "camelCase")]` for JS consumption |
| 113 |
|
| 114 |
## Error Types |
| 115 |
|
| 116 |
All commands return `Result<T, ApiError>`. The error has typed codes: |
| 117 |
|
| 118 |
```rust |
| 119 |
pub enum ApiErrorCode { |
| 120 |
BadRequest, // User-caused errors |
| 121 |
NotFound, // Missing resource |
| 122 |
Database, // DB errors |
| 123 |
Internal, // Unexpected errors |
| 124 |
Plugin, // Rhai plugin errors |
| 125 |
} |
| 126 |
|
| 127 |
pub struct ApiError { |
| 128 |
pub code: ApiErrorCode, |
| 129 |
pub message: String, |
| 130 |
} |
| 131 |
``` |
| 132 |
|
| 133 |
`ApiError` implements `From` for `sqlx::Error`, `FeedError`, and `OrchestratorError`. The frontend receives `{code: "BAD_REQUEST", message: "..."}`. |
| 134 |
|
| 135 |
## Circuit Breaker |
| 136 |
|
| 137 |
Feeds are auto-disabled after 10 consecutive fetch failures: |
| 138 |
|
| 139 |
- `consecutive_failures` counter increments on each failure, resets to 0 on success. |
| 140 |
- When the counter reaches 10, `circuit_broken` is set to true. |
| 141 |
- Circuit-broken feeds skip auto-fetch scheduling. |
| 142 |
- Users can manually reset via `reset_circuit_breaker` command. |
| 143 |
|
| 144 |
## Secret Encryption |
| 145 |
|
| 146 |
Plugin configs with `Secret`-type fields are encrypted at rest using AES-256-GCM: |
| 147 |
|
| 148 |
- Format: `bb_enc:v1:<base64(nonce[12] || ciphertext || tag[16])>` |
| 149 |
- Key stored in OS keychain (via `keyring` crate), with file fallback at `~/.config/balanced-breakfast/encryption.key` |
| 150 |
- Backward compatible: unencrypted values are read as plaintext, encrypted on next save |
| 151 |
- `encrypt_config_secrets()` / `decrypt_config_secrets()` operate on JSON values in-place, using the config schema to identify Secret fields |
| 152 |
|
| 153 |
## JavaScript Architecture |
| 154 |
|
| 155 |
### BB.* Namespace |
| 156 |
|
| 157 |
All JavaScript lives under the `BB` global namespace (defined in `bb.js`, loaded first): |
| 158 |
|
| 159 |
```javascript |
| 160 |
window.BB = { |
| 161 |
api: {}, // Tauri IPC wrappers (thin invoke calls) |
| 162 |
state: {}, // Centralized reactive state (Proxy-based) |
| 163 |
ui: {}, // Toast, modal, form builder, confirm dialog |
| 164 |
utils: {}, // escapeHtml, escapeAttr, sanitizeHtml, debounce |
| 165 |
sources: {}, // Source list, tag filters, feed CRUD |
| 166 |
items: {}, // Item list, read/star toggle, pagination |
| 167 |
detail: {}, // Detail panel, reader view |
| 168 |
feeds: {}, // Feed management, OPML import/export |
| 169 |
themes: {}, // Theme loading |
| 170 |
sync: {}, // Cloud sync settings |
| 171 |
app: {}, // Bootstrap, keyboard shortcuts |
| 172 |
}; |
| 173 |
``` |
| 174 |
|
| 175 |
### Module Pattern |
| 176 |
|
| 177 |
Every JS file is a strict-mode IIFE: |
| 178 |
|
| 179 |
```javascript |
| 180 |
(function() { |
| 181 |
'use strict'; |
| 182 |
const { escapeHtml, getErrorMessage } = BB.utils; |
| 183 |
|
| 184 |
async function load() { /* ... */ } |
| 185 |
function render(sources) { /* ... */ } |
| 186 |
|
| 187 |
BB.sources = { load, render }; |
| 188 |
})(); |
| 189 |
``` |
| 190 |
|
| 191 |
### State Management |
| 192 |
|
| 193 |
`BB.state` is a Proxy over a data object with pub/sub: |
| 194 |
|
| 195 |
```javascript |
| 196 |
BB.state.set('sources', data); // Set + notify |
| 197 |
BB.state.sources; // Direct read |
| 198 |
BB.state.subscribe('items', (newVal, oldVal) => {}); // Subscribe |
| 199 |
``` |
| 200 |
|
| 201 |
### XSS Prevention |
| 202 |
|
| 203 |
- `BB.utils.escapeHtml()` for text content |
| 204 |
- `BB.utils.escapeAttr()` for HTML attributes |
| 205 |
- `BB.utils.sanitizeHtml()` for feed body content (strips script, iframe, on* handlers, javascript: URLs) |
| 206 |
|
| 207 |
All user-provided content and feed content must be escaped or sanitized before insertion into the DOM. |
| 208 |
|
| 209 |
## Testing |
| 210 |
|
| 211 |
### Rust Integration Tests |
| 212 |
|
| 213 |
Integration tests in `src-tauri/tests/` use in-memory SQLite and bypass Tauri `State<>` wrappers: |
| 214 |
|
| 215 |
```rust |
| 216 |
// Common setup |
| 217 |
pub async fn setup(suffix: &str) -> Orchestrator { |
| 218 |
let config = OrchestratorConfig { |
| 219 |
database_url: "sqlite::memory:".to_string(), |
| 220 |
plugins_dir: temp_dir.to_string(), |
| 221 |
fetch_interval_secs: 300, |
| 222 |
}; |
| 223 |
let orchestrator = Orchestrator::new(config).await.unwrap(); |
| 224 |
orchestrator.migrate().await.unwrap(); |
| 225 |
orchestrator |
| 226 |
} |
| 227 |
``` |
| 228 |
|
| 229 |
Tests exercise command-layer code paths without the Tauri runtime. Test helpers in `tests/common/mod.rs` provide setup, fixture creation, and teardown. |
| 230 |
|
| 231 |
### JS Tests |
| 232 |
|
| 233 |
Node.js test runner at `src-tauri/frontend/js/tests/run.js` with browser API mocks. Run with `node src-tauri/frontend/js/tests/run.js`. |
| 234 |
|
| 235 |
### Full Pipeline |
| 236 |
|
| 237 |
Always verify: plugin fetch → DB upsert → command response → JS render. A bug in any layer can appear as a symptom in another. |
| 238 |
|
| 239 |
## When Adding a New Source Type |
| 240 |
|
| 241 |
1. Create a `.rhai` file in `plugins/` implementing the 4 required functions |
| 242 |
2. Test it manually: add a feed with the new busser, trigger a fetch |
| 243 |
3. The orchestrator auto-discovers plugins on startup — no Rust changes needed |
| 244 |
|
| 245 |
## When Adding Features |
| 246 |
|
| 247 |
1. **New data type:** Add to `bb-interface` (types), `bb-db` (persistence), then commands |
| 248 |
2. **New command:** Add in `src-tauri/src/commands/`, register in `main.rs` |
| 249 |
3. **New JS module:** Create IIFE file, attach to `BB.moduleName`, add to `index.html` |
| 250 |
|