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