Skip to main content

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    /// Bytes are on disk; verifying / collecting metadata before registration.
38    Finalizing,
39    /// Registering the completed download in the model database.
40    Registering,
41    /// Completed successfully.
42    Completed,
43    /// Failed with an error.
44    Failed,
45    /// Cancelled by user.
46    Cancelled,
47}
48
49impl DownloadStatus {
50    /// Convert to string representation for database storage.
51    #[must_use]
52    pub const fn as_str(&self) -> &'static str {
53        match self {
54            Self::Queued => "queued",
55            Self::Downloading => "downloading",
56            Self::Finalizing => "finalizing",
57            Self::Registering => "registering",
58            Self::Completed => "completed",
59            Self::Failed => "failed",
60            Self::Cancelled => "cancelled",
61        }
62    }
63
64    /// Parse from string representation.
65    #[must_use]
66    pub fn parse(s: &str) -> Self {
67        match s {
68            "downloading" => Self::Downloading,
69            "finalizing" => Self::Finalizing,
70            "registering" => Self::Registering,
71            "completed" => Self::Completed,
72            "failed" => Self::Failed,
73            "cancelled" => Self::Cancelled,
74            // "queued" or unknown values default to Queued
75            _ => Self::Queued,
76        }
77    }
78
79    /// Human-readable label for UI display.
80    #[must_use]
81    pub const fn label(&self) -> &'static str {
82        match self {
83            Self::Queued => "Queued",
84            Self::Downloading => "Downloading",
85            Self::Finalizing => "Finalizing",
86            Self::Registering => "Registering",
87            Self::Completed => "Completed",
88            Self::Failed => "Failed",
89            Self::Cancelled => "Cancelled",
90        }
91    }
92}
93
94/// Single discriminated union for all download events.
95///
96/// The frontend handles this as a TypeScript discriminated union:
97///
98/// ```typescript
99/// type DownloadEvent =
100///   | { type: "queue_snapshot"; items: DownloadSummary[]; max_size: number }
101///   | { type: "download_started"; id: string; shard_index?: number; total_shards?: number }
102///   | { type: "download_progress"; id: string; downloaded: number; total: number;
103///       speed_bps?: number; eta_seconds?: number; percentage: number }
104///   | { type: "shard_progress"; id: string; shard_index: number;
105///       speed_bps?: number; eta_seconds?: number; ... }
106///   | { type: "download_completed"; id: string }
107///   | { type: "download_failed"; id: string; error: string }
108///   | { type: "download_cancelled"; id: string }
109///   | { type: "download_notice"; id: string; message: string };
110/// ```
111///
112/// `speed_bps` and `eta_seconds` are **optional and omitted when unknown** — a
113/// download that has just started has no meaningful rate yet. Renderers must
114/// show a placeholder for the absent case rather than substituting `0`, and
115/// must never compute a rate of their own from successive `downloaded` values;
116/// the manager's `RateEstimator` is the only source. The mirrored TypeScript
117/// declaration lives in `src/services/transport/types/events.ts`.
118#[derive(Clone, Debug, Serialize, Deserialize)]
119#[serde(tag = "type", rename_all = "snake_case")]
120pub enum DownloadEvent {
121    /// Snapshot of the entire queue state.
122    QueueSnapshot {
123        /// All items currently in the queue.
124        items: Vec<DownloadSummary>,
125        /// Maximum queue capacity.
126        max_size: u32,
127    },
128
129    /// A download has started.
130    DownloadStarted {
131        /// Canonical ID of the download.
132        id: String,
133        /// Current shard index (0-based), present only for sharded downloads.
134        #[serde(skip_serializing_if = "Option::is_none")]
135        shard_index: Option<u32>,
136        /// Total number of shards, present only for sharded downloads.
137        #[serde(skip_serializing_if = "Option::is_none")]
138        total_shards: Option<u32>,
139    },
140
141    /// Progress update for a non-sharded download.
142    DownloadProgress {
143        /// Canonical ID of the download.
144        id: String,
145        /// Bytes downloaded so far.
146        downloaded: u64,
147        /// Total bytes to download.
148        total: u64,
149        /// Current download speed in bytes per second.
150        ///
151        /// Absent until the estimator has warmed up. This is deliberately not
152        /// `0.0`: zero is a real reading meaning "stalled", and conflating the
153        /// two is what rendered `ETA: 0s` on a healthy download.
154        #[serde(skip_serializing_if = "Option::is_none")]
155        speed_bps: Option<f64>,
156        /// Estimated time remaining in seconds; absent when not yet known.
157        #[serde(skip_serializing_if = "Option::is_none")]
158        eta_seconds: Option<f64>,
159        /// Progress percentage (0.0 - 100.0).
160        percentage: f64,
161    },
162
163    /// Progress update for a sharded download.
164    ShardProgress {
165        /// Canonical ID of the download (group ID).
166        id: String,
167        /// Current shard index (0-based).
168        shard_index: u32,
169        /// Total number of shards.
170        total_shards: u32,
171        /// Filename of the current shard.
172        shard_filename: String,
173        /// Bytes downloaded for current shard.
174        shard_downloaded: u64,
175        /// Total bytes for current shard.
176        shard_total: u64,
177        /// Aggregate bytes downloaded across all shards.
178        aggregate_downloaded: u64,
179        /// Aggregate total bytes across all shards.
180        aggregate_total: u64,
181        /// Current download speed in bytes per second; absent until known.
182        ///
183        /// Measured across the whole shard group, not reset per shard.
184        #[serde(skip_serializing_if = "Option::is_none")]
185        speed_bps: Option<f64>,
186        /// Estimated time remaining in seconds; absent when not yet known.
187        #[serde(skip_serializing_if = "Option::is_none")]
188        eta_seconds: Option<f64>,
189        /// Aggregate progress percentage (0.0 - 100.0).
190        percentage: f64,
191    },
192
193    /// Download completed successfully.
194    DownloadCompleted {
195        /// Canonical ID of the download.
196        id: String,
197        /// Optional success message.
198        #[serde(skip_serializing_if = "Option::is_none")]
199        message: Option<String>,
200    },
201
202    /// Download failed with an error.
203    DownloadFailed {
204        /// Canonical ID of the download.
205        id: String,
206        /// Error message describing what went wrong.
207        error: String,
208    },
209
210    /// Download was cancelled by the user.
211    DownloadCancelled {
212        /// Canonical ID of the download.
213        id: String,
214    },
215
216    /// Lifecycle status transition for a download (e.g.
217    /// `Downloading` → `Finalizing` → `Registering`).
218    ///
219    /// Emitted at the boundaries between phases so transports can render a
220    /// non-frozen state while the manager is verifying bytes and writing the
221    /// model row to the database. Terminal states (`Completed`, `Failed`,
222    /// `Cancelled`) keep their dedicated event variants.
223    DownloadStatusChanged {
224        /// Canonical ID of the download.
225        id: String,
226        /// New status of the download.
227        status: DownloadStatus,
228    },
229
230    /// A transient, human-readable note about work happening for this
231    /// download that produces no byte progress of its own — e.g. building
232    /// the first-run Python environment for the fast downloader.
233    ///
234    /// Unlike [`Self::DownloadStatusChanged`] this carries free-form text
235    /// rather than a fixed [`DownloadStatus`] and is not persisted; it exists
236    /// purely so the renderer has something to show instead of looking
237    /// frozen while setup work happens before the first progress event.
238    DownloadNotice {
239        /// Canonical ID of the download.
240        id: String,
241        /// Human-readable note to display in place of progress.
242        message: String,
243    },
244
245    /// Queue run completed (all downloads in the queue finished).
246    ///
247    /// Emitted when the download queue transitions from busy → idle,
248    /// providing a complete summary of all artifacts that were processed
249    /// during the run.
250    QueueRunComplete {
251        /// Complete summary of the queue run.
252        summary: QueueRunSummary,
253    },
254}
255
256impl DownloadEvent {
257    /// Create a queue snapshot event.
258    #[must_use]
259    pub const fn queue_snapshot(items: Vec<DownloadSummary>, max_size: u32) -> Self {
260        Self::QueueSnapshot { items, max_size }
261    }
262
263    /// Create a download started event.
264    pub fn started(id: impl Into<String>) -> Self {
265        Self::DownloadStarted {
266            id: id.into(),
267            shard_index: None,
268            total_shards: None,
269        }
270    }
271
272    /// Create a download started event with shard information.
273    pub fn started_shard(id: impl Into<String>, shard_index: u32, total_shards: u32) -> Self {
274        Self::DownloadStarted {
275            id: id.into(),
276            shard_index: Some(shard_index),
277            total_shards: Some(total_shards),
278        }
279    }
280
281    /// Percentage complete, clamped to 0-100.
282    #[allow(clippy::cast_precision_loss)]
283    fn percent_of(downloaded: u64, total: u64) -> f64 {
284        if total == 0 {
285            return 0.0;
286        }
287        ((downloaded as f64 / total as f64) * 100.0).clamp(0.0, 100.0)
288    }
289
290    /// Create a non-sharded progress event.
291    ///
292    /// `speed_bps` and `eta_seconds` come from the manager's
293    /// [`RateEstimator`](crate::download::RateEstimator) — this constructor
294    /// deliberately does not derive an ETA of its own. Two estimators for one
295    /// number is how the CLI and the GUI ended up disagreeing.
296    pub fn progress(
297        id: impl Into<String>,
298        downloaded: u64,
299        total: u64,
300        speed_bps: Option<f64>,
301        eta_seconds: Option<f64>,
302    ) -> Self {
303        Self::DownloadProgress {
304            id: id.into(),
305            downloaded,
306            total,
307            speed_bps,
308            eta_seconds,
309            percentage: Self::percent_of(downloaded, total),
310        }
311    }
312
313    /// Create a sharded progress event.
314    ///
315    /// See [`progress`](Self::progress) on where the rate values come from.
316    #[allow(clippy::too_many_arguments)]
317    pub fn shard_progress(
318        id: impl Into<String>,
319        shard_index: u32,
320        total_shards: u32,
321        shard_filename: impl Into<String>,
322        shard_downloaded: u64,
323        shard_total: u64,
324        aggregate_downloaded: u64,
325        aggregate_total: u64,
326        speed_bps: Option<f64>,
327        eta_seconds: Option<f64>,
328    ) -> Self {
329        Self::ShardProgress {
330            id: id.into(),
331            shard_index,
332            total_shards,
333            shard_filename: shard_filename.into(),
334            shard_downloaded,
335            shard_total,
336            aggregate_downloaded,
337            aggregate_total,
338            speed_bps,
339            eta_seconds,
340            percentage: Self::percent_of(aggregate_downloaded, aggregate_total),
341        }
342    }
343
344    /// Create a download completed event.
345    pub fn completed(id: impl Into<String>, message: Option<impl Into<String>>) -> Self {
346        Self::DownloadCompleted {
347            id: id.into(),
348            message: message.map(Into::into),
349        }
350    }
351
352    /// Create a download failed event.
353    pub fn failed(id: impl Into<String>, error: impl Into<String>) -> Self {
354        Self::DownloadFailed {
355            id: id.into(),
356            error: error.into(),
357        }
358    }
359
360    /// Create a download cancelled event.
361    pub fn cancelled(id: impl Into<String>) -> Self {
362        Self::DownloadCancelled { id: id.into() }
363    }
364
365    /// Create a status-changed event for non-terminal lifecycle transitions.
366    pub fn status_changed(id: impl Into<String>, status: DownloadStatus) -> Self {
367        Self::DownloadStatusChanged {
368            id: id.into(),
369            status,
370        }
371    }
372
373    /// Create a queue run complete event.
374    pub const fn queue_run_complete(summary: QueueRunSummary) -> Self {
375        Self::QueueRunComplete { summary }
376    }
377
378    /// Get the download ID from any event type.
379    #[must_use]
380    pub fn id(&self) -> Option<&str> {
381        match self {
382            Self::QueueSnapshot { .. } | Self::QueueRunComplete { .. } => None,
383            Self::DownloadStarted { id, .. }
384            | Self::DownloadProgress { id, .. }
385            | Self::ShardProgress { id, .. }
386            | Self::DownloadCompleted { id, .. }
387            | Self::DownloadFailed { id, .. }
388            | Self::DownloadCancelled { id }
389            | Self::DownloadStatusChanged { id, .. }
390            | Self::DownloadNotice { id, .. } => Some(id),
391        }
392    }
393
394    /// Get the event name for wire protocols.
395    ///
396    /// This provides consistent event naming for Tauri and SSE transports.
397    /// Note: Both `ShardProgress` and `DownloadProgress` use "download:progress"
398    /// as the channel name; differentiation happens via the type discriminator.
399    #[must_use]
400    pub const fn event_name(&self) -> &'static str {
401        match self {
402            Self::QueueSnapshot { .. } => "download:queue_snapshot",
403            Self::DownloadStarted { .. } => "download:started",
404            Self::DownloadProgress { .. } | Self::ShardProgress { .. } => "download:progress",
405            Self::DownloadCompleted { .. } => "download:completed",
406            Self::DownloadFailed { .. } => "download:failed",
407            Self::DownloadCancelled { .. } => "download:cancelled",
408            Self::DownloadStatusChanged { .. } => "download:status_changed",
409            Self::DownloadNotice { .. } => "download:notice",
410            Self::QueueRunComplete { .. } => "download:queue_run_complete",
411        }
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn test_progress_event_calculations() {
421        let event = DownloadEvent::progress("id", 500, 1000, Some(100.0), Some(5.0));
422        match event {
423            DownloadEvent::DownloadProgress {
424                percentage,
425                eta_seconds,
426                speed_bps,
427                ..
428            } => {
429                assert!((percentage - 50.0).abs() < 0.01);
430                assert_eq!(eta_seconds, Some(5.0), "ETA is passed through, not derived");
431                assert_eq!(speed_bps, Some(100.0));
432            }
433            _ => panic!("Expected DownloadProgress"),
434        }
435    }
436
437    #[test]
438    fn unknown_rate_is_omitted_from_the_wire() {
439        let event = DownloadEvent::progress("id", 500, 1000, None, None);
440        let json = serde_json::to_string(&event).expect("serializes");
441        assert!(
442            !json.contains("speed_bps") && !json.contains("eta_seconds"),
443            "an unknown rate must be absent, never 0: {json}"
444        );
445    }
446
447    #[test]
448    fn percentage_is_clamped_and_safe_at_zero_total() {
449        let over = DownloadEvent::progress("id", 1500, 1000, None, None);
450        let unknown = DownloadEvent::progress("id", 500, 0, None, None);
451        for (event, expected) in [(over, 100.0), (unknown, 0.0)] {
452            match event {
453                DownloadEvent::DownloadProgress { percentage, .. } => {
454                    assert!((percentage - expected).abs() < f64::EPSILON);
455                }
456                _ => panic!("Expected DownloadProgress"),
457            }
458        }
459    }
460
461    #[test]
462    fn test_event_id_extraction() {
463        assert_eq!(DownloadEvent::started("test").id(), Some("test"));
464        assert_eq!(DownloadEvent::cancelled("test").id(), Some("test"));
465        assert!(DownloadEvent::queue_snapshot(vec![], 10).id().is_none());
466    }
467}