Skip to main content

vector_core/
traits.rs

1//! Abstraction traits that decouple vector-core from any specific UI framework.
2//!
3//! Tauri, CLI, SDK, or any other frontend implements these traits to integrate
4//! with vector-core. The core library never imports `tauri` directly.
5
6use std::sync::OnceLock;
7
8/// Emits events to the UI layer (Tauri frontend, CLI output, SDK callbacks).
9///
10/// Tauri: wraps `AppHandle::emit(event, payload)`
11/// CLI: logs to stdout or pushes to a channel
12/// SDK: invokes user-provided callbacks
13pub trait EventEmitter: Send + Sync + 'static {
14    fn emit(&self, event: &str, payload: serde_json::Value);
15
16    /// Emit already-serialised JSON. The default parses it for [`EventEmitter::emit`];
17    /// emitters that forward JSON as-is override this and [`EventEmitter::prefers_json`].
18    fn emit_json(&self, event: &str, payload: &serde_json::value::RawValue) {
19        if let Ok(value) = serde_json::from_str(payload.get()) {
20            self.emit(event, value);
21        }
22    }
23
24    /// Whether [`EventEmitter::emit_json`] is cheaper than [`EventEmitter::emit`] here.
25    fn prefers_json(&self) -> bool {
26        false
27    }
28}
29
30/// A no-op emitter for headless/test contexts.
31pub struct NoOpEmitter;
32
33impl EventEmitter for NoOpEmitter {
34    fn emit(&self, _event: &str, _payload: serde_json::Value) {}
35}
36
37/// Global event emitter — set once by the integrator during initialization.
38static EVENT_EMITTER: OnceLock<Box<dyn EventEmitter>> = OnceLock::new();
39
40/// Register the global event emitter. Call once during app startup.
41pub fn set_event_emitter(emitter: Box<dyn EventEmitter>) {
42    let _ = EVENT_EMITTER.set(emitter);
43}
44
45/// Emit an event to the UI layer. No-op if no emitter is registered.
46pub fn emit_event<T: serde::Serialize>(event: &str, payload: &T) {
47    if !crate::db::session_is_live() {
48        return;
49    }
50    if let Some(emitter) = EVENT_EMITTER.get() {
51        // One pass straight to JSON where the emitter forwards it; a Value tree otherwise.
52        if emitter.prefers_json() {
53            if let Ok(json) = serde_json::value::to_raw_value(payload) {
54                emitter.emit_json(event, &json);
55            }
56        } else if let Ok(value) = serde_json::to_value(payload) {
57            emitter.emit(event, value);
58        }
59    }
60}
61
62#[derive(serde::Serialize)]
63struct MessagePayload<'a> {
64    #[serde(skip_serializing_if = "Option::is_none")]
65    old_id: Option<&'a str>,
66    message: &'a crate::types::Message,
67    chat_id: &'a str,
68}
69
70/// Emit `message_new`: a message joined `chat_id`.
71pub fn emit_message_new(chat_id: &str, message: &crate::types::Message) {
72    emit_event("message_new", &MessagePayload { old_id: None, message, chat_id });
73}
74
75/// Emit `message_update` for a message whose reply context is already resolved.
76pub fn emit_message_replaced(chat_id: &str, old_id: &str, message: &crate::types::Message) {
77    emit_event("message_update", &MessagePayload { old_id: Some(old_id), message, chat_id });
78}
79
80/// Emit a raw JSON value event to the UI layer.
81pub fn emit_event_json(event: &str, payload: serde_json::Value) {
82    if !crate::db::session_is_live() {
83        return;
84    }
85    if let Some(emitter) = EVENT_EMITTER.get() {
86        emitter.emit(event, payload);
87    }
88}
89
90/// Emit `message_update` with the quoted-reply context re-resolved first.
91///
92/// STATE holds messages in compact form, which keeps only the *has an
93/// attachment* bool — `replied_to_attachment_extension` is dropped on the way
94/// back out. Every update path re-reads its message from STATE, so emitting one
95/// straight from RAM downgrades the quoted attachment's label ("GIF Animation"
96/// becomes a generic "Attachment") and the renderer never retries.
97pub async fn emit_message_update(chat_id: &str, old_id: &str, message: &mut crate::types::Message) {
98    let _ = crate::db::events::populate_reply_context(message).await;
99    emit_message_replaced(chat_id, old_id, message);
100}
101
102/// Check if an event emitter is registered.
103pub fn has_event_emitter() -> bool {
104    EVENT_EMITTER.get().is_some()
105}
106
107/// Refreshes the integration layer's live channel subscription set.
108///
109/// vector-core mutates the local "channels I'm in" set when joining,
110/// leaving, or being removed. The integration layer (Tauri) keeps a relay
111/// subscription whose filter list mirrors that set; this hook tears down
112/// subscriptions for channels we no longer belong to.
113///
114/// Implementations typically spawn the async refresh on the host runtime;
115/// the trait method is sync so vector-core can call it from anywhere.
116pub trait SubscriptionRefresher: Send + Sync + 'static {
117    fn refresh(&self);
118}
119
120pub struct NoOpSubscriptionRefresher;
121impl SubscriptionRefresher for NoOpSubscriptionRefresher {
122    fn refresh(&self) {}
123}
124
125static SUBSCRIPTION_REFRESHER: OnceLock<Box<dyn SubscriptionRefresher>> = OnceLock::new();
126
127pub fn set_subscription_refresher(refresher: Box<dyn SubscriptionRefresher>) {
128    let _ = SUBSCRIPTION_REFRESHER.set(refresher);
129}
130
131pub fn refresh_subscriptions() {
132    if let Some(r) = SUBSCRIPTION_REFRESHER.get() {
133        r.refresh();
134    }
135}
136
137/// Trait for reporting download/upload progress.
138pub trait ProgressReporter: Send + Sync {
139    fn report_progress(&self, percentage: Option<u8>, bytes: Option<u64>, bytes_per_sec: Option<f64>) -> Result<(), &'static str>;
140    fn report_complete(&self) -> Result<(), &'static str>;
141}
142
143/// A no-op progress reporter.
144pub struct NoOpProgressReporter;
145
146impl ProgressReporter for NoOpProgressReporter {
147    fn report_progress(&self, _: Option<u8>, _: Option<u64>, _: Option<f64>) -> Result<(), &'static str> { Ok(()) }
148    fn report_complete(&self) -> Result<(), &'static str> { Ok(()) }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use std::sync::atomic::{AtomicUsize, Ordering};
155
156    // ========================================================================
157    // SubscriptionRefresher
158    // ========================================================================
159
160    /// Counter shared across all SubscriptionRefresher tests. We can't unset
161    /// SUBSCRIPTION_REFRESHER (OnceLock) so all tests share the registered
162    /// CountingRefresher; a test-serializer mutex + per-test counter reset
163    /// gives us reliable observation of `refresh()` calls.
164    static REFRESH_CALLS: AtomicUsize = AtomicUsize::new(0);
165    static REFRESH_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
166
167    struct CountingRefresher;
168    impl SubscriptionRefresher for CountingRefresher {
169        fn refresh(&self) {
170            REFRESH_CALLS.fetch_add(1, Ordering::Relaxed);
171        }
172    }
173
174    /// Register the CountingRefresher exactly once across all tests, then
175    /// take the serial lock and zero the counter. Subsequent
176    /// set_subscription_refresher calls are no-ops (OnceLock semantics) so
177    /// CountingRefresher remains the live impl for every test in this
178    /// process — that's deliberate and tested below.
179    fn refresh_test_setup() -> std::sync::MutexGuard<'static, ()> {
180        let guard = REFRESH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
181        set_subscription_refresher(Box::new(CountingRefresher));
182        REFRESH_CALLS.store(0, Ordering::Relaxed);
183        guard
184    }
185
186    #[test]
187    fn refresh_subscriptions_invokes_registered_impl_once() {
188        let _g = refresh_test_setup();
189        assert_eq!(REFRESH_CALLS.load(Ordering::Relaxed), 0);
190
191        refresh_subscriptions();
192
193        assert_eq!(REFRESH_CALLS.load(Ordering::Relaxed), 1);
194    }
195
196    #[test]
197    fn refresh_subscriptions_invokes_registered_impl_per_call() {
198        // Each call MUST translate to one refresh invocation — the eviction
199        // and accept_invite hooks both call refresh_subscriptions(), so any
200        // accidental coalescing or guard-skip would break post-rejoin pushes.
201        let _g = refresh_test_setup();
202
203        for _ in 0..5 {
204            refresh_subscriptions();
205        }
206
207        assert_eq!(REFRESH_CALLS.load(Ordering::Relaxed), 5);
208    }
209
210    #[test]
211    fn set_subscription_refresher_is_idempotent() {
212        // OnceLock semantics: only the first set wins. We register a SECOND
213        // refresher (a panicking one) and verify the still-registered counter
214        // is what gets called. Without OnceLock-set semantics, a runtime
215        // re-registration could swap the live refresher out from under code
216        // that's mid-flight.
217        let _g = refresh_test_setup();
218
219        struct PanicRefresher;
220        impl SubscriptionRefresher for PanicRefresher {
221            fn refresh(&self) {
222                panic!("PanicRefresher::refresh() should never be reached — OnceLock should have ignored the second set_subscription_refresher call");
223            }
224        }
225        set_subscription_refresher(Box::new(PanicRefresher));
226
227        // The panic-refresher would crash the test if it had taken over.
228        // Since CountingRefresher is still live, this just increments and
229        // returns cleanly.
230        refresh_subscriptions();
231
232        assert_eq!(REFRESH_CALLS.load(Ordering::Relaxed), 1);
233    }
234
235    #[test]
236    fn refresh_subscriptions_is_thread_safe() {
237        // The hook is called from cleanup_evicted_group which can run on any
238        // thread (sync_blocking → block_on flow). Multiple refresh calls
239        // racing must not lose any.
240        let _g = refresh_test_setup();
241        let handles: Vec<_> = (0..10).map(|_| {
242            std::thread::spawn(|| {
243                for _ in 0..100 {
244                    refresh_subscriptions();
245                }
246            })
247        }).collect();
248        for h in handles { h.join().unwrap(); }
249
250        assert_eq!(REFRESH_CALLS.load(Ordering::Relaxed), 1_000);
251    }
252
253    #[test]
254    fn no_op_subscription_refresher_returns_quietly() {
255        // Direct call to NoOpSubscriptionRefresher — must not panic, must not
256        // increment the global counter (since it doesn't go through the hook).
257        let _g = refresh_test_setup();
258        let initial = REFRESH_CALLS.load(Ordering::Relaxed);
259        let r = NoOpSubscriptionRefresher;
260        r.refresh();
261        r.refresh();
262        assert_eq!(REFRESH_CALLS.load(Ordering::Relaxed), initial);
263    }
264
265    // ========================================================================
266    // EventEmitter (existing pattern — light coverage for symmetry)
267    // ========================================================================
268
269    #[test]
270    fn no_op_emitter_does_not_panic() {
271        let e = NoOpEmitter;
272        e.emit("test_event", serde_json::json!({"k": "v"}));
273        // No assert — the contract is "doesn't panic, doesn't error".
274    }
275
276    #[test]
277    fn emit_event_when_unregistered_is_silent() {
278        // emit_event on a never-registered emitter must not panic. We can't
279        // test the unregistered case after src-tauri's TauriEventEmitter has
280        // been registered (OnceLock), but in pure-vector-core test runs no
281        // emitter is registered, and the `if let Some(...)` guard handles it.
282        // This test verifies the function call itself doesn't panic regardless.
283        crate::traits::emit_event("test_event", &serde_json::json!({"k": "v"}));
284    }
285
286    #[test]
287    fn emit_event_json_when_unregistered_is_silent() {
288        crate::traits::emit_event_json("test_event", serde_json::json!({"k": "v"}));
289    }
290
291    #[test]
292    fn no_op_progress_reporter_returns_ok() {
293        let r = NoOpProgressReporter;
294        assert!(r.report_progress(Some(50), Some(1024), Some(100.0)).is_ok());
295        assert!(r.report_complete().is_ok());
296    }
297}