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            shard_index: None,
127            total_shards: None,
128        });
129    }
130
131    #[test]
132    fn test_noop_emitter_clone_box() {
133        let emitter = NoopDownloadEmitter::new();
134        let _boxed: Box<dyn DownloadEventEmitterPort> = emitter.clone_box();
135    }
136
137    #[test]
138    fn test_arc_emitter() {
139        let emitter: Arc<dyn DownloadEventEmitterPort> = Arc::new(NoopDownloadEmitter::new());
140        emitter.emit(DownloadEvent::DownloadStarted {
141            id: "test".to_string(),
142            shard_index: None,
143            total_shards: None,
144        });
145    }
146
147    /// Regression test: `ShardProgress` must be preserved through `AppEventBridge`.
148    ///
149    /// This prevents the bug where shard detail was collapsed to generic `DownloadProgress`,
150    /// causing the UI to lose per-shard and aggregate progress information.
151    #[test]
152    fn test_shard_progress_preserved_in_bridge() {
153        use std::sync::Mutex;
154
155        // Mock AppEventEmitter that captures emitted events
156        #[derive(Clone)]
157        struct MockEmitter {
158            captured: Arc<Mutex<Vec<AppEvent>>>,
159        }
160
161        impl AppEventEmitter for MockEmitter {
162            fn emit(&self, event: AppEvent) {
163                self.captured.lock().unwrap().push(event);
164            }
165
166            fn clone_box(&self) -> Box<dyn AppEventEmitter> {
167                Box::new(self.clone())
168            }
169        }
170
171        let captured = Arc::new(Mutex::new(Vec::new()));
172        let mock = Arc::new(MockEmitter {
173            captured: captured.clone(),
174        });
175        let bridge = AppEventBridge::new(mock);
176
177        // Emit a ShardProgress event
178        let shard_event = DownloadEvent::shard_progress(
179            "model:Q4_K_M",
180            0,
181            4,
182            "model-00001-of-00004.gguf",
183            500_000,
184            1_000_000,
185            500_000,
186            4_000_000,
187            1024.0,
188        );
189
190        bridge.emit(shard_event);
191
192        // Verify it's wrapped, not collapsed
193        let events = captured.lock().unwrap();
194        assert_eq!(events.len(), 1, "Should emit exactly one AppEvent");
195
196        match &events[0] {
197            AppEvent::Download { event } => match event {
198                DownloadEvent::ShardProgress {
199                    shard_index,
200                    total_shards,
201                    shard_filename,
202                    shard_downloaded,
203                    shard_total,
204                    aggregate_downloaded,
205                    aggregate_total,
206                    ..
207                } => {
208                    assert_eq!(*shard_index, 0);
209                    assert_eq!(*total_shards, 4);
210                    assert_eq!(shard_filename, "model-00001-of-00004.gguf");
211                    assert_eq!(*shard_downloaded, 500_000);
212                    assert_eq!(*shard_total, 1_000_000);
213                    assert_eq!(*aggregate_downloaded, 500_000);
214                    assert_eq!(*aggregate_total, 4_000_000);
215                }
216                _ => panic!("Expected ShardProgress to be preserved, got {event:?}"),
217            },
218            other => panic!("Expected AppEvent::Download wrapper, got {other:?}"),
219        }
220    }
221}