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