Trait System
How Risuko decouples the engine from runtime environments using Rust traits.
The engine uses three traits (defined in risuko-engine/src/traits.rs) to abstract away environment-specific behavior. The desktop app provides Tauri-backed implementations in src-tauri/src/bridge.rs; the CLI and Node.js bindings bypass the traits and drive TaskManager and EventBroadcaster directly.
ConfigDirProvider
Controls where configuration and data files are stored.
pub trait ConfigDirProvider: Send + Sync {
fn config_dir(&self) -> PathBuf;
}The consumer is ConfigManager::new(&dyn ConfigDirProvider). ConfigManager::with_dir(PathBuf) also exists for an explicit path.
Implementations
| Implementation | Used By | Behavior |
|---|---|---|
TauriConfigDir | Desktop App (src/bridge.rs) | AppHandle::path().app_config_dir(), resolved once at construction |
Default directory (CLI, NAPI)
The CLI and Node.js bindings do not implement the trait. Each resolves the directory with a plain helper and loads system.json / user.json from it directly:
fn get_config_dir() -> PathBuf {
dirs::config_dir()
.map(|d| d.join("dev.risuko.app"))
.unwrap_or_else(|| PathBuf::from("."))
}Resolves to:
- macOS:
~/Library/Application Support/dev.risuko.app - Linux:
~/.config/dev.risuko.app - Windows:
C:\Users\<user>\AppData\Roaming\dev.risuko.app
EventSink
Receives engine events and forwards them to the host environment.
pub trait EventSink: Send + Sync {
fn emit(&self, event: &str, payload: Value);
}Implementations
| Implementation | Used By | Behavior |
|---|---|---|
NoopEventSink | Desktop app bootstrap (AppState::new builds RssManager / UploadSinkManager with a placeholder sink; it takes only a StorageBackend, not an event sink), engine tests | Discards events silently |
TauriEventSink | Desktop App (src/bridge.rs) | Calls AppHandle::emit() to webview |
After AppState is built, the desktop app swaps the upload manager's bootstrap sink for the live TauriEventSink via UploadSinkManager::set_event_sink. The Node.js bindings and the standalone CLI use the EventBroadcaster instead of constructing an EventSink: the NAPI on_event calls events.subscribe() and forwards each EngineEvent to a JS callback through a ThreadsafeFunction, while the CLI hands the broadcaster to its RpcServer, whose WebSocket handler subscribes and relays events to RPC clients.
NoopEventSink
pub struct NoopEventSink;
impl EventSink for NoopEventSink {
fn emit(&self, _event: &str, _payload: Value) {}
}StorageBackend
Persistent key-value storage for RSS feeds, upload-sink configuration, and other state.
pub trait StorageBackend: Send + Sync {
fn load(&self, key: &str) -> Result<Option<Value>, String>;
fn save(&self, key: &str, value: &Value) -> Result<(), String>;
}Implementations
| Implementation | Used By | Behavior |
|---|---|---|
FileStorage | Engine tests; available to embedders via FileStorage::new(dir) | Pretty-printed {key}.json files in a directory |
TauriStorage | Desktop App (src/bridge.rs) | Wraps tauri_plugin_store; each key is a store file with the value under its "data" entry |
The CLI and Node.js bindings do not construct RssManager or UploadSinkManager, so they never use a StorageBackend.
FileStorage
pub struct FileStorage {
dir: PathBuf,
}
impl StorageBackend for FileStorage {
fn load(&self, key: &str) -> Result<Option<Value>, String> {
let path = self.dir.join(format!("{key}.json"));
if !path.exists() {
return Ok(None);
}
let data = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
let value: Value = serde_json::from_str(&data)
.map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
Ok(Some(value))
}
fn save(&self, key: &str, value: &Value) -> Result<(), String> {
std::fs::create_dir_all(&self.dir)
.map_err(|e| format!("Failed to create dir {}: {e}", self.dir.display()))?;
let path = self.dir.join(format!("{key}.json"));
let data =
serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize: {e}"))?;
std::fs::write(&path, data)
.map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
Ok(())
}
}How the Engine Uses Traits
Both RssManager and UploadSinkManager are constructed from dyn trait objects today; ConfigManager takes a &dyn ConfigDirProvider; TaskManager takes a concrete EventBroadcaster and a &Path for the config directory:
// Desktop app setup (simplified, src-tauri/src/lib.rs)
let config_dir_provider = bridge::TauriConfigDir::new(handle);
let event_sink: Arc<dyn EventSink> = Arc::new(bridge::TauriEventSink::new(handle));
let storage: Arc<dyn StorageBackend> = Arc::new(bridge::TauriStorage::new(handle));
let config = ConfigManager::new(&config_dir_provider)?;
let rss = RssManager::new(storage.clone(), event_sink.clone());
let upload_sinks = UploadSinkManager::new(storage, event_sink.clone());
start_engine(&config, event_sink, Some(upload_sinks)).await?;Inside start_engine (engine/mod.rs), an event bridge subscribes to the EventBroadcaster and forwards each EngineEvent to the EventSink as engine:* events. Consumers that bypass start_engine wire the EventBroadcaster themselves — NAPI subscribes via events.subscribe() and forwards each EngineEvent to a JS callback, while the CLI passes the broadcaster to its RpcServer, which subscribes and relays events to RPC clients; the trait surface only matters for ConfigManager, RssManager, UploadSinkManager, and for start_engine in engine/mod.rs.
Custom Implementations
You can implement these traits to integrate Risuko into other environments:
use risuko_engine::{ConfigDirProvider, EventSink, StorageBackend};
struct MyConfigDir;
impl ConfigDirProvider for MyConfigDir {
fn config_dir(&self) -> PathBuf {
PathBuf::from("/my/app/config")
}
}
struct MyEventSink { /* your state */ }
impl EventSink for MyEventSink {
fn emit(&self, event: &str, payload: Value) {
// Send to your event system
}
}