gglib_core/download/
events.rs

1//! Download events - discriminated union for all download state changes.
2
3use super::completion::QueueRunSummary;
4use super::types::ShardInfo;
5use serde::{Deserialize, Serialize};
6
7/// A summary of a download in the queue (for snapshots and API responses).
8#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
9pub struct DownloadSummary {
10    /// Canonical ID string (`model_id:quantization` or just `model_id`).
11    pub id: String,
12    /// Human-readable display name.
13    pub display_name: String,
14    /// Current status of this download.
15    pub status: DownloadStatus,
16    /// Position in queue (1 = currently downloading, 2+ = waiting).
17    pub position: u32,
18    /// Error message if status is Failed.
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub error: Option<String>,
21    /// Group ID for sharded downloads (all shards share the same `group_id`).
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub group_id: Option<String>,
24    /// Shard information if this is part of a sharded model.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub shard_info: Option<ShardInfo>,
27}
28
29/// Status of a download.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum DownloadStatus {
33    /// Waiting in the queue.
34    Queued,
35    /// Currently being downloaded.
36    Downloading,
37    /// Completed successfully.
38    Completed,
39    /// Failed with an error.
40    Failed,
41    /// Cancelled by user.
42    Cancelled,
43}
44
45impl DownloadStatus {
46    /// Convert to string representation for database storage.
47    #[must_use]
48    pub const fn as_str(&self) -> &'static str {
49        match self {
50            Self::Queued => "queued",
51            Self::Downloading => "downloading",
52            Self::Completed => "completed",
53            Self::Failed => "failed",
54            Self::Cancelled => "cancelled",
55        }
56    }
57
58    /// Parse from string representation.
59    #[must_use]
60    pub fn parse(s: &str) -> Self {
61        match s {
62            "downloading" => Self::Downloading,
63            "completed" => Self::Completed,
64            "failed" => Self::Failed,
65            "cancelled" => Self::Cancelled,
66            // "queued" or unknown values default to Queued
67            _ => Self::Queued,
68        }
69    }
70}
71
72/// Single discriminated union for all download events.
73///
74/// The frontend handles this as a TypeScript discriminated union:
75///
76/// ```typescript
77/// type DownloadEvent =
78///   | { type: "queue_snapshot"; items: DownloadSummary[]; max_size: number }
79///   | { type: "download_started"; id: string }
80///   | { type: "download_progress"; id: string; downloaded: number; total: number; ... }
81///   | { type: "shard_progress"; id: string; shard_index: number; ... }
82///   | { type: "download_completed"; id: string }
83///   | { type: "download_failed"; id: string; error: string }
84///   | { type: "download_cancelled"; id: string };
85/// ```
86#[derive(Clone, Debug, Serialize, Deserialize)]
87#[serde(tag = "type", rename_all = "snake_case")]
88pub enum DownloadEvent {
89    /// Snapshot of the entire queue state.
90    QueueSnapshot {
91        /// All items currently in the queue.
92        items: Vec<DownloadSummary>,
93        /// Maximum queue capacity.
94        max_size: u32,
95    },
96
97    /// A download has started.
98    DownloadStarted {
99        /// Canonical ID of the download.
100        id: String,
101    },
102
103    /// Progress update for a non-sharded download.
104    DownloadProgress {
105        /// Canonical ID of the download.
106        id: String,
107        /// Bytes downloaded so far.
108        downloaded: u64,
109        /// Total bytes to download.
110        total: u64,
111        /// Current download speed in bytes per second.
112        speed_bps: f64,
113        /// Estimated time remaining in seconds.
114        eta_seconds: f64,
115        /// Progress percentage (0.0 - 100.0).
116        percentage: f64,
117    },
118
119    /// Progress update for a sharded download.
120    ShardProgress {
121        /// Canonical ID of the download (group ID).
122        id: String,
123        /// Current shard index (0-based).
124        shard_index: u32,
125        /// Total number of shards.
126        total_shards: u32,
127        /// Filename of the current shard.
128        shard_filename: String,
129        /// Bytes downloaded for current shard.
130        shard_downloaded: u64,
131        /// Total bytes for current shard.
132        shard_total: u64,
133        /// Aggregate bytes downloaded across all shards.
134        aggregate_downloaded: u64,
135        /// Aggregate total bytes across all shards.
136        aggregate_total: u64,
137        /// Current download speed in bytes per second.
138        speed_bps: f64,
139        /// Estimated time remaining in seconds.
140        eta_seconds: f64,
141        /// Aggregate progress percentage (0.0 - 100.0).
142        percentage: f64,
143    },
144
145    /// Download completed successfully.
146    DownloadCompleted {
147        /// Canonical ID of the download.
148        id: String,
149        /// Optional success message.
150        #[serde(skip_serializing_if = "Option::is_none")]
151        message: Option<String>,
152    },
153
154    /// Download failed with an error.
155    DownloadFailed {
156        /// Canonical ID of the download.
157        id: String,
158        /// Error message describing what went wrong.
159        error: String,
160    },
161
162    /// Download was cancelled by the user.
163    DownloadCancelled {
164        /// Canonical ID of the download.
165        id: String,
166    },
167
168    /// Queue run completed (all downloads in the queue finished).
169    ///
170    /// Emitted when the download queue transitions from busy → idle,
171    /// providing a complete summary of all artifacts that were processed
172    /// during the run.
173    QueueRunComplete {
174        /// Complete summary of the queue run.
175        summary: QueueRunSummary,
176    },
177}
178
179impl DownloadEvent {
180    /// Create a queue snapshot event.
181    #[must_use]
182    pub const fn queue_snapshot(items: Vec<DownloadSummary>, max_size: u32) -> Self {
183        Self::QueueSnapshot { items, max_size }
184    }
185
186    /// Create a download started event.
187    pub fn started(id: impl Into<String>) -> Self {
188        Self::DownloadStarted { id: id.into() }
189    }
190
191    /// Create a non-sharded progress event.
192    #[allow(clippy::cast_precision_loss)]
193    pub fn progress(id: impl Into<String>, downloaded: u64, total: u64, speed_bps: f64) -> Self {
194        let percentage = if total > 0 {
195            (downloaded as f64 / total as f64) * 100.0
196        } else {
197            0.0
198        };
199
200        let eta_seconds = if speed_bps > 0.0 && total > downloaded {
201            (total - downloaded) as f64 / speed_bps
202        } else {
203            0.0
204        };
205
206        Self::DownloadProgress {
207            id: id.into(),
208            downloaded,
209            total,
210            speed_bps,
211            eta_seconds,
212            percentage,
213        }
214    }
215
216    /// Create a sharded progress event.
217    #[allow(clippy::too_many_arguments, clippy::cast_precision_loss)]
218    pub fn shard_progress(
219        id: impl Into<String>,
220        shard_index: u32,
221        total_shards: u32,
222        shard_filename: impl Into<String>,
223        shard_downloaded: u64,
224        shard_total: u64,
225        aggregate_downloaded: u64,
226        aggregate_total: u64,
227        speed_bps: f64,
228    ) -> Self {
229        let percentage = if aggregate_total > 0 {
230            (aggregate_downloaded as f64 / aggregate_total as f64) * 100.0
231        } else {
232            0.0
233        };
234
235        let eta_seconds = if speed_bps > 0.0 && aggregate_total > aggregate_downloaded {
236            (aggregate_total - aggregate_downloaded) as f64 / speed_bps
237        } else {
238            0.0
239        };
240
241        Self::ShardProgress {
242            id: id.into(),
243            shard_index,
244            total_shards,
245            shard_filename: shard_filename.into(),
246            shard_downloaded,
247            shard_total,
248            aggregate_downloaded,
249            aggregate_total,
250            speed_bps,
251            eta_seconds,
252            percentage,
253        }
254    }
255
256    /// Create a download completed event.
257    pub fn completed(id: impl Into<String>, message: Option<impl Into<String>>) -> Self {
258        Self::DownloadCompleted {
259            id: id.into(),
260            message: message.map(Into::into),
261        }
262    }
263
264    /// Create a download failed event.
265    pub fn failed(id: impl Into<String>, error: impl Into<String>) -> Self {
266        Self::DownloadFailed {
267            id: id.into(),
268            error: error.into(),
269        }
270    }
271
272    /// Create a download cancelled event.
273    pub fn cancelled(id: impl Into<String>) -> Self {
274        Self::DownloadCancelled { id: id.into() }
275    }
276
277    /// Create a queue run complete event.
278    pub const fn queue_run_complete(summary: QueueRunSummary) -> Self {
279        Self::QueueRunComplete { summary }
280    }
281
282    /// Get the download ID from any event type.
283    #[must_use]
284    pub fn id(&self) -> Option<&str> {
285        match self {
286            Self::QueueSnapshot { .. } | Self::QueueRunComplete { .. } => None,
287            Self::DownloadStarted { id }
288            | Self::DownloadProgress { id, .. }
289            | Self::ShardProgress { id, .. }
290            | Self::DownloadCompleted { id, .. }
291            | Self::DownloadFailed { id, .. }
292            | Self::DownloadCancelled { id } => Some(id),
293        }
294    }
295
296    /// Get the event name for wire protocols.
297    ///
298    /// This provides consistent event naming for Tauri and SSE transports.
299    /// Note: Both `ShardProgress` and `DownloadProgress` use "download:progress"
300    /// as the channel name; differentiation happens via the type discriminator.
301    #[must_use]
302    pub const fn event_name(&self) -> &'static str {
303        match self {
304            Self::QueueSnapshot { .. } => "download:queue_snapshot",
305            Self::DownloadStarted { .. } => "download:started",
306            Self::DownloadProgress { .. } | Self::ShardProgress { .. } => "download:progress",
307            Self::DownloadCompleted { .. } => "download:completed",
308            Self::DownloadFailed { .. } => "download:failed",
309            Self::DownloadCancelled { .. } => "download:cancelled",
310            Self::QueueRunComplete { .. } => "download:queue_run_complete",
311        }
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn test_progress_event_calculations() {
321        let event = DownloadEvent::progress("id", 500, 1000, 100.0);
322        match event {
323            DownloadEvent::DownloadProgress {
324                percentage,
325                eta_seconds,
326                ..
327            } => {
328                assert!((percentage - 50.0).abs() < 0.01);
329                assert!((eta_seconds - 5.0).abs() < 0.01);
330            }
331            _ => panic!("Expected DownloadProgress"),
332        }
333    }
334
335    #[test]
336    fn test_event_id_extraction() {
337        assert_eq!(DownloadEvent::started("test").id(), Some("test"));
338        assert_eq!(DownloadEvent::cancelled("test").id(), Some("test"));
339        assert!(DownloadEvent::queue_snapshot(vec![], 10).id().is_none());
340    }
341}