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