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