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 constructs a NoopEventSink internally and builds RssManager / UploadSinkManager with it as a placeholder 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; keys are validated against path traversal and writes are atomic (temp file + rename) |
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 FileStorage {
/// Reject keys that could escape the storage directory. Keys map to
/// `<dir>/<key>.json`, so a key like `../foo` or `a/b` must not traverse out.
fn safe_path(&self, key: &str) -> Result<PathBuf, String> {
if key.is_empty()
|| key == "."
|| key == ".."
|| key.contains('/')
|| key.contains('\\')
|| key.contains('\0')
|| std::path::Path::new(key)
.components()
.any(|c| !matches!(c, std::path::Component::Normal(_)))
{
return Err(format!("invalid storage key: {key:?}"));
}
Ok(self.dir.join(format!("{key}.json")))
}
}
impl StorageBackend for FileStorage {
fn load(&self, key: &str) -> Result<Option<Value>, String> {
let path = self.safe_path(key)?;
// Read directly and treat NotFound as "no value" to avoid a TOCTOU gap
// between an exists() check and the read
let data = match std::fs::read_to_string(&path) {
Ok(data) => data,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(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> {
let path = self.safe_path(key)?;
std::fs::create_dir_all(&self.dir)
.map_err(|e| format!("Failed to create dir {}: {e}", self.dir.display()))?;
let data =
serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize: {e}"))?;
// Atomic write: temp file + rename so a crash mid-write can't corrupt
// the existing persisted JSON
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, data)
.map_err(|e| format!("Failed to write {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
format!("Failed to persist {}: {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
}
}