gglib_core/ports/
download_event_emitter.rs

1//! Download event emitter port.
2//!
3//! This port abstracts download event emission, allowing the download manager
4//! to emit events without coupling to transport details (SSE, Tauri, etc.).
5
6use std::sync::Arc;
7
8use crate::download::DownloadEvent;
9use crate::events::AppEvent;
10
11use super::AppEventEmitter;
12
13/// Port for emitting download events.
14///
15/// This trait abstracts away the transport mechanism for download events.
16/// Implementations handle the actual event delivery (channels, SSE, Tauri events).
17///
18/// # Example
19///
20/// ```ignore
21/// // In download manager
22/// fn on_progress(&self, emitter: &dyn DownloadEventEmitterPort) {
23///     emitter.emit(DownloadEvent::DownloadProgress { ... });
24/// }
25/// ```
26pub trait DownloadEventEmitterPort: Send + Sync {
27    /// Emit a download event.
28    ///
29    /// Implementations should handle the event asynchronously or buffer it.
30    /// This method should not block.
31    fn emit(&self, event: DownloadEvent);
32
33    /// Clone this emitter into a boxed trait object.
34    ///
35    /// This enables cloning of `Arc<dyn DownloadEventEmitterPort>` without
36    /// requiring the underlying type to implement Clone.
37    fn clone_box(&self) -> Box<dyn DownloadEventEmitterPort>;
38}
39
40/// A no-op download event emitter for tests and CLI contexts.
41///
42/// This implementation discards all events, making it suitable for:
43/// - Unit tests that don't need to verify event emission
44/// - CLI applications that handle progress differently
45/// - Contexts where event emission is optional
46#[derive(Debug, Clone, Default)]
47pub struct NoopDownloadEmitter;
48
49impl NoopDownloadEmitter {
50    /// Create a new no-op download emitter.
51    #[must_use]
52    pub const fn new() -> Self {
53        Self
54    }
55}
56
57impl DownloadEventEmitterPort for NoopDownloadEmitter {
58    fn emit(&self, _event: DownloadEvent) {
59        // Intentionally do nothing
60    }
61
62    fn clone_box(&self) -> Box<dyn DownloadEventEmitterPort> {
63        Box::new(self.clone())
64    }
65}
66
67/// Bridge adapter that converts `DownloadEvent` to `AppEvent` and forwards to `AppEventEmitter`.
68///
69/// This provides a single wiring path: download manager emits `DownloadEvent`,
70/// which gets mapped to the appropriate `AppEvent` variant and sent through
71/// the shared event infrastructure.
72///
73/// # Example
74///
75/// ```ignore
76/// let app_emitter: Arc<dyn AppEventEmitter> = /* Tauri or SSE emitter */;
77/// let download_emitter = AppEventBridge::new(app_emitter);
78///
79/// // Pass to download manager
80/// let manager = build_download_manager(DownloadManagerDeps {
81///     event_emitter: Arc::new(download_emitter),
82///     ...
83/// });
84/// ```
85#[derive(Clone)]
86pub struct AppEventBridge {
87    inner: Arc<dyn AppEventEmitter>,
88}
89
90impl AppEventBridge {
91    /// Create a new bridge wrapping an `AppEventEmitter`.
92    pub fn new(emitter: Arc<dyn AppEventEmitter>) -> Self {
93        Self { inner: emitter }
94    }
95
96    /// Wrap a `DownloadEvent` in an `AppEvent`.
97    ///
98    /// Preserves all download event details including shard progress information.
99    const fn map_event(event: DownloadEvent) -> AppEvent {
100        AppEvent::Download { event }
101    }
102}
103
104impl DownloadEventEmitterPort for AppEventBridge {
105    fn emit(&self, event: DownloadEvent) {
106        let app_event = Self::map_event(event);
107        self.inner.emit(app_event);
108    }
109
110    fn clone_box(&self) -> Box<dyn DownloadEventEmitterPort> {
111        Box::new(self.clone())
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn test_noop_emitter() {
121        let emitter = NoopDownloadEmitter::new();
122
123        // Should not panic
124        emitter.emit(DownloadEvent::DownloadStarted {
125            id: "test".to_string(),
126        });
127    }
128
129    #[test]
130    fn test_noop_emitter_clone_box() {
131        let emitter = NoopDownloadEmitter::new();
132        let _boxed: Box<dyn DownloadEventEmitterPort> = emitter.clone_box();
133    }
134
135    #[test]
136    fn test_arc_emitter() {
137        let emitter: Arc<dyn DownloadEventEmitterPort> = Arc::new(NoopDownloadEmitter::new());
138        emitter.emit(DownloadEvent::DownloadStarted {
139            id: "test".to_string(),
140        });
141    }
142
143    /// Regression test: `ShardProgress` must be preserved through `AppEventBridge`.
144    ///
145    /// This prevents the bug where shard detail was collapsed to generic `DownloadProgress`,
146    /// causing the UI to lose per-shard and aggregate progress information.
147    #[test]
148    fn test_shard_progress_preserved_in_bridge() {
149        use std::sync::Mutex;
150
151        // Mock AppEventEmitter that captures emitted events
152        #[derive(Clone)]
153        struct MockEmitter {
154            captured: Arc<Mutex<Vec<AppEvent>>>,
155        }
156
157        impl AppEventEmitter for MockEmitter {
158            fn emit(&self, event: AppEvent) {
159                self.captured.lock().unwrap().push(event);
160            }
161
162            fn clone_box(&self) -> Box<dyn AppEventEmitter> {
163                Box::new(self.clone())
164            }
165        }
166
167        let captured = Arc::new(Mutex::new(Vec::new()));
168        let mock = Arc::new(MockEmitter {
169            captured: captured.clone(),
170        });
171        let bridge = AppEventBridge::new(mock);
172
173        // Emit a ShardProgress event
174        let shard_event = DownloadEvent::shard_progress(
175            "model:Q4_K_M",
176            0,
177            4,
178            "model-00001-of-00004.gguf",
179            500_000,
180            1_000_000,
181            500_000,
182            4_000_000,
183            1024.0,
184        );
185
186        bridge.emit(shard_event);
187
188        // Verify it's wrapped, not collapsed
189        let events = captured.lock().unwrap();
190        assert_eq!(events.len(), 1, "Should emit exactly one AppEvent");
191
192        match &events[0] {
193            AppEvent::Download { event } => match event {
194                DownloadEvent::ShardProgress {
195                    shard_index,
196                    total_shards,
197                    shard_filename,
198                    shard_downloaded,
199                    shard_total,
200                    aggregate_downloaded,
201                    aggregate_total,
202                    ..
203                } => {
204                    assert_eq!(*shard_index, 0);
205                    assert_eq!(*total_shards, 4);
206                    assert_eq!(shard_filename, "model-00001-of-00004.gguf");
207                    assert_eq!(*shard_downloaded, 500_000);
208                    assert_eq!(*shard_total, 1_000_000);
209                    assert_eq!(*aggregate_downloaded, 500_000);
210                    assert_eq!(*aggregate_total, 4_000_000);
211                }
212                _ => panic!("Expected ShardProgress to be preserved, got {event:?}"),
213            },
214            other => panic!("Expected AppEvent::Download wrapper, got {other:?}"),
215        }
216    }
217}