Skip to main content

gglib_core/download/
queue.rs

1//! Queue DTOs for API responses and snapshots.
2//!
3//! These types are "UI safe" - Clone + Debug + Serialize + Deserialize with no
4//! infrastructure dependencies. They're used for transmitting queue state to
5//! frontends via SSE, Tauri events, or CLI output.
6
7use super::events::DownloadStatus;
8use super::format::{format_duration, format_rate};
9use super::types::{Quantization, ShardInfo};
10use serde::{Deserialize, Serialize};
11
12/// Snapshot of the entire download queue for API responses.
13#[derive(Clone, Debug, Default, Serialize, Deserialize)]
14pub struct QueueSnapshot {
15    /// Items currently in the queue.
16    pub items: Vec<QueuedDownload>,
17    /// Maximum queue capacity.
18    pub max_size: u32,
19    /// Number of active downloads (currently downloading).
20    pub active_count: u32,
21    /// Number of pending downloads (queued, waiting).
22    pub pending_count: u32,
23    /// Recent failures (kept for UI display).
24    pub recent_failures: Vec<FailedDownload>,
25}
26
27impl QueueSnapshot {
28    /// Create a new empty snapshot.
29    #[must_use]
30    pub const fn new(max_size: u32) -> Self {
31        Self {
32            items: Vec::new(),
33            max_size,
34            active_count: 0,
35            pending_count: 0,
36            recent_failures: Vec::new(),
37        }
38    }
39
40    /// Check if the queue is empty.
41    #[must_use]
42    pub const fn is_empty(&self) -> bool {
43        self.items.is_empty()
44    }
45
46    /// Check if the queue is full.
47    #[must_use]
48    pub const fn is_full(&self) -> bool {
49        self.items.len() >= self.max_size as usize
50    }
51
52    /// Get the total number of items.
53    #[must_use]
54    pub const fn len(&self) -> usize {
55        self.items.len()
56    }
57
58    /// Get an item by its ID.
59    pub fn get(&self, id: &str) -> Option<&QueuedDownload> {
60        self.items.iter().find(|item| item.id == id)
61    }
62}
63
64/// A single download in the queue.
65#[derive(Clone, Debug, Serialize, Deserialize)]
66pub struct QueuedDownload {
67    /// Canonical ID (`model_id:quantization` or `model_id`).
68    pub id: String,
69
70    /// Full model ID (e.g., "TheBloke/Llama-2-7B-GGUF").
71    pub model_id: String,
72
73    /// Resolved quantization (if specified).
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub quantization: Option<Quantization>,
76
77    /// Human-readable display name.
78    pub display_name: String,
79
80    /// Current status.
81    pub status: DownloadStatus,
82
83    /// Position in queue (1-based; 1 = active, 2+ = waiting).
84    pub position: u32,
85
86    /// Bytes downloaded so far.
87    pub downloaded_bytes: u64,
88
89    /// Total bytes to download.
90    pub total_bytes: u64,
91
92    /// Download speed in bytes per second; absent until the estimator warms up.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub speed_bps: Option<f64>,
95
96    /// Estimated time remaining.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub eta_seconds: Option<f64>,
99
100    /// Progress as percentage (0.0 - 100.0).
101    pub progress_percent: f64,
102
103    /// Timestamp when download was queued (Unix epoch seconds).
104    pub queued_at: u64,
105
106    /// Timestamp when download started (Unix epoch seconds).
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub started_at: Option<u64>,
109
110    /// Group ID for sharded downloads.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub group_id: Option<String>,
113
114    /// Shard information if this is part of a sharded download.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub shard_info: Option<ShardInfo>,
117}
118
119impl QueuedDownload {
120    /// Create a new queued download in initial state.
121    pub fn new(
122        id: impl Into<String>,
123        model_id: impl Into<String>,
124        display_name: impl Into<String>,
125        position: u32,
126        queued_at: u64,
127    ) -> Self {
128        Self {
129            id: id.into(),
130            model_id: model_id.into(),
131            quantization: None,
132            display_name: display_name.into(),
133            status: DownloadStatus::Queued,
134            position,
135            downloaded_bytes: 0,
136            total_bytes: 0,
137            speed_bps: None,
138            eta_seconds: None,
139            progress_percent: 0.0,
140            queued_at,
141            started_at: None,
142            group_id: None,
143            shard_info: None,
144        }
145    }
146
147    /// Set the quantization.
148    #[must_use]
149    pub const fn with_quantization(mut self, quant: Quantization) -> Self {
150        self.quantization = Some(quant);
151        self
152    }
153
154    /// Set the download status.
155    #[must_use]
156    pub const fn with_status(mut self, status: DownloadStatus) -> Self {
157        self.status = status;
158        self
159    }
160
161    /// Set shard information.
162    #[must_use]
163    pub fn with_shard_info(mut self, group_id: String, shard_info: ShardInfo) -> Self {
164        self.group_id = Some(group_id);
165        self.shard_info = Some(shard_info);
166        self
167    }
168
169    /// Update progress from bytes downloaded.
170    ///
171    /// `speed_bps` and `eta_seconds` come from the download manager's
172    /// `RateEstimator`; this type does not derive a rate or an ETA of its own.
173    pub fn update_progress(
174        &mut self,
175        downloaded: u64,
176        total: u64,
177        speed_bps: Option<f64>,
178        eta_seconds: Option<f64>,
179    ) {
180        self.downloaded_bytes = downloaded;
181        self.total_bytes = total;
182        self.speed_bps = speed_bps;
183        self.eta_seconds = eta_seconds;
184
185        self.progress_percent = if total > 0 {
186            #[expect(
187                clippy::cast_precision_loss,
188                reason = "precision loss acceptable for progress percentage"
189            )]
190            let progress = (downloaded as f64 / total as f64) * 100.0;
191            progress.clamp(0.0, 100.0)
192        } else {
193            0.0
194        };
195    }
196
197    /// Check if this download is currently active.
198    pub fn is_active(&self) -> bool {
199        self.status == DownloadStatus::Downloading
200    }
201
202    /// Check if this download is complete.
203    #[must_use]
204    pub const fn is_complete(&self) -> bool {
205        matches!(
206            self.status,
207            DownloadStatus::Completed | DownloadStatus::Cancelled | DownloadStatus::Failed
208        )
209    }
210
211    /// Get formatted speed string (e.g., `5.2 MB/s`, or a placeholder).
212    #[must_use]
213    pub fn speed_display(&self) -> String {
214        format_rate(self.speed_bps)
215    }
216
217    /// Get formatted ETA string (e.g., `2m 30s`, or a placeholder).
218    #[must_use]
219    pub fn eta_display(&self) -> String {
220        format_duration(self.eta_seconds)
221    }
222}
223
224/// A failed download kept for display purposes.
225#[derive(Clone, Debug, Serialize, Deserialize)]
226pub struct FailedDownload {
227    /// Canonical ID of the failed download.
228    pub id: String,
229
230    /// Display name.
231    pub display_name: String,
232
233    /// Error message.
234    pub error: String,
235
236    /// Timestamp when the failure occurred (Unix epoch seconds).
237    pub failed_at: u64,
238
239    /// Whether the failure is recoverable (can retry).
240    pub recoverable: bool,
241
242    /// Bytes downloaded before failure.
243    pub downloaded_bytes: u64,
244}
245
246impl FailedDownload {
247    /// Create a new failed download record.
248    pub fn new(
249        id: impl Into<String>,
250        display_name: impl Into<String>,
251        error: impl Into<String>,
252        failed_at: u64,
253    ) -> Self {
254        Self {
255            id: id.into(),
256            display_name: display_name.into(),
257            error: error.into(),
258            failed_at,
259            recoverable: false,
260            downloaded_bytes: 0,
261        }
262    }
263
264    /// Mark as recoverable.
265    #[must_use]
266    pub const fn with_recoverable(mut self, recoverable: bool) -> Self {
267        self.recoverable = recoverable;
268        self
269    }
270
271    /// Set bytes downloaded before failure.
272    #[must_use]
273    pub const fn with_downloaded_bytes(mut self, bytes: u64) -> Self {
274        self.downloaded_bytes = bytes;
275        self
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_queue_snapshot_operations() {
285        let mut snapshot = QueueSnapshot::new(10);
286        assert!(snapshot.is_empty());
287        assert!(!snapshot.is_full());
288
289        snapshot
290            .items
291            .push(QueuedDownload::new("id1", "model", "Display", 1, 0));
292        assert!(!snapshot.is_empty());
293        assert_eq!(snapshot.len(), 1);
294        assert!(snapshot.get("id1").is_some());
295        assert!(snapshot.get("nonexistent").is_none());
296    }
297
298    #[test]
299    fn test_queued_download_progress() {
300        let mut download = QueuedDownload::new("id", "model", "Display", 1, 0);
301        download.update_progress(500, 1000, Some(100.0), Some(5.0));
302
303        assert_eq!(download.downloaded_bytes, 500);
304        assert!((download.progress_percent - 50.0).abs() < 0.01);
305        // Stored, not derived — the manager's estimator owns this number.
306        assert!((download.eta_seconds.unwrap() - 5.0).abs() < 0.01);
307    }
308
309    #[test]
310    fn test_speed_display() {
311        // Unit selection and thresholds are covered in `super::format`; this
312        // only checks that the DTO delegates there rather than formatting
313        // its own way.
314        let mut download = QueuedDownload::new("id", "model", "Display", 1, 0);
315
316        download.speed_bps = Some(5_000_000.0);
317        assert_eq!(download.speed_display(), "5.0 MB/s");
318
319        download.speed_bps = Some(1_500_000_000.0);
320        assert_eq!(download.speed_display(), "1.50 GB/s");
321    }
322
323    #[test]
324    fn display_helpers_show_a_placeholder_when_unknown() {
325        let download = QueuedDownload::new("id", "model", "Display", 1, 0);
326        assert_eq!(download.speed_display(), crate::download::format::UNKNOWN);
327        assert_eq!(download.eta_display(), crate::download::format::UNKNOWN);
328    }
329
330    #[test]
331    fn test_serialization_roundtrip() {
332        let download = QueuedDownload::new("id", "model", "Display", 1, 1_234_567_890)
333            .with_quantization(Quantization::Q4KM);
334
335        let json = serde_json::to_string(&download).unwrap();
336        let parsed: QueuedDownload = serde_json::from_str(&json).unwrap();
337
338        assert_eq!(parsed.id, "id");
339        assert_eq!(parsed.quantization, Some(Quantization::Q4KM));
340    }
341
342    /// Comprehensive test: verify `is_active()` and `is_complete()` for all 7 `DownloadStatus` variants.
343    #[test]
344    fn test_status_classification_all_variants() {
345        let base = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
346
347        // Queued: not active, not complete
348        let queued = base.clone().with_status(DownloadStatus::Queued);
349        assert!(!queued.is_active(), "Queued should not be active");
350        assert!(!queued.is_complete(), "Queued should not be complete");
351
352        // Downloading: active, not complete
353        let downloading = base.clone().with_status(DownloadStatus::Downloading);
354        assert!(downloading.is_active(), "Downloading should be active");
355        assert!(
356            !downloading.is_complete(),
357            "Downloading should not be complete"
358        );
359
360        // Finalizing: not active, not complete
361        let finalizing = base.clone().with_status(DownloadStatus::Finalizing);
362        assert!(!finalizing.is_active(), "Finalizing should not be active");
363        assert!(
364            !finalizing.is_complete(),
365            "Finalizing should not be complete"
366        );
367
368        // Registering: not active, not complete
369        let registering = base.clone().with_status(DownloadStatus::Registering);
370        assert!(!registering.is_active(), "Registering should not be active");
371        assert!(
372            !registering.is_complete(),
373            "Registering should not be complete"
374        );
375
376        // Completed: not active, complete
377        let completed = base.clone().with_status(DownloadStatus::Completed);
378        assert!(!completed.is_active(), "Completed should not be active");
379        assert!(completed.is_complete(), "Completed should be complete");
380
381        // Failed: not active, complete
382        let failed = base.clone().with_status(DownloadStatus::Failed);
383        assert!(!failed.is_active(), "Failed should not be active");
384        assert!(failed.is_complete(), "Failed should be complete");
385
386        // Cancelled: not active, complete
387        let cancelled = base.with_status(DownloadStatus::Cancelled);
388        assert!(!cancelled.is_active(), "Cancelled should not be active");
389        assert!(cancelled.is_complete(), "Cancelled should be complete");
390    }
391
392    /// Test `update_progress` when downloaded bytes exceed total bytes.
393    /// This documents the current behavior: `progress_percent` can exceed 100%, and `eta_seconds` becomes None.
394    #[test]
395    fn test_update_progress_downloaded_exceeds_total() {
396        let mut download = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
397
398        // Call update_progress with downloaded > total
399        download.update_progress(1500, 1000, Some(100.0), None);
400
401        // Progress percent exceeds 100% (no clamping)
402        // Clamped: a bar cannot be more than full, and an overshoot here means
403        // the byte counter is double-counting, not that 150% of the file exists.
404        assert!(
405            (download.progress_percent - 100.0).abs() < 0.01,
406            "Progress should clamp to 100.0%"
407        );
408
409        assert!(
410            download.eta_seconds.is_none(),
411            "ETA should be None when downloaded >= total"
412        );
413
414        assert_eq!(download.downloaded_bytes, 1500);
415        assert!((download.speed_bps.unwrap() - 100.0).abs() < 0.01);
416    }
417
418    /// Test `update_progress` with zero total bytes (division-by-zero guard).
419    #[test]
420    #[allow(clippy::float_cmp)]
421    fn test_update_progress_zero_total_bytes() {
422        let mut download = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
423
424        // Call update_progress with total = 0
425        download.update_progress(500, 0, Some(100.0), None);
426
427        // Progress percent is 0.0 (the `if total > 0` guard prevents division by zero)
428        assert_eq!(
429            download.progress_percent, 0.0,
430            "Progress should be 0.0 when total is 0"
431        );
432
433        // ETA is None because `total > downloaded` is false when total is 0
434        assert!(
435            download.eta_seconds.is_none(),
436            "ETA should be None when total is 0"
437        );
438
439        // Downloaded bytes and speed are still updated
440        assert_eq!(download.downloaded_bytes, 500);
441        assert!((download.speed_bps.unwrap() - 100.0).abs() < 0.01);
442    }
443
444    /// Test `update_progress` with zero speed (division-by-zero guard for ETA).
445    #[test]
446    #[allow(clippy::float_cmp)]
447    fn test_update_progress_zero_speed() {
448        let mut download = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
449
450        // Call update_progress with speed = 0.0
451        download.update_progress(500, 1000, Some(0.0), None);
452
453        // Progress percent is still calculated correctly (50%)
454        assert_eq!(download.progress_percent, 50.0, "Progress should be 50.0%");
455
456        // A zero rate means "stalled". The estimator reports no ETA for it,
457        // and the DTO stores that absence rather than inventing a 0.
458        assert!(
459            download.eta_seconds.is_none(),
460            "ETA should be None when speed is 0"
461        );
462
463        assert_eq!(download.downloaded_bytes, 500);
464        assert_eq!(download.speed_bps, Some(0.0));
465    }
466
467    /// Test `update_progress` when download is complete (downloaded == total).
468    #[test]
469    #[allow(clippy::float_cmp)]
470    fn test_update_progress_complete_download() {
471        let mut download = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
472
473        // Call update_progress with downloaded equal to total
474        download.update_progress(1000, 1000, Some(100.0), None);
475
476        // Progress percent should be 100%
477        assert_eq!(
478            download.progress_percent, 100.0,
479            "Progress should be 100.0%"
480        );
481
482        // ETA is None because `total > downloaded` guard is false when equal
483        assert!(
484            download.eta_seconds.is_none(),
485            "ETA should be None when download is complete"
486        );
487
488        // Downloaded bytes and speed are updated normally
489        assert_eq!(download.downloaded_bytes, 1000);
490        assert!((download.speed_bps.unwrap() - 100.0).abs() < 0.01);
491    }
492
493    /// Test `update_progress` with large u64 values — verifies no overflow/panic and results are in the right ballpark despite f64 precision loss.
494    #[test]
495    fn test_update_progress_large_u64_values() {
496        let mut download = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
497
498        // 50 TB downloaded out of 100 TB total, at 1 GB/s
499        let downloaded: u64 = 50_000_000_000_000;
500        let total: u64 = 100_000_000_000_000;
501        let speed_bps: f64 = 1_000_000_000.0; // 1 GB/s
502
503        download.update_progress(downloaded, total, Some(speed_bps), Some(50_000.0));
504
505        // Progress should be approximately 50% (within tolerance for f64 precision loss)
506        assert!(
507            (download.progress_percent - 50.0).abs() < 0.1,
508            "Progress should be ~50%, got {}",
509            download.progress_percent
510        );
511
512        // ETA is stored verbatim from the estimator.
513        let eta = download.eta_seconds.expect("ETA should be Some");
514        assert!(
515            (eta - 50_000.0).abs() < 1.0,
516            "ETA should be ~50,000 seconds, got {eta}"
517        );
518
519        // Verify downloaded_bytes and speed were updated
520        assert_eq!(download.downloaded_bytes, downloaded);
521        assert!((download.speed_bps.unwrap() - speed_bps).abs() < 0.01);
522    }
523
524    /// Test `FailedDownload` builder pattern — defaults and chained setters.
525    #[test]
526    fn test_failed_download_builders() {
527        // Create with new() and verify defaults
528        let failed = FailedDownload::new("id", "Display", "network error", 1_234_567_890);
529
530        assert_eq!(failed.id, "id");
531        assert_eq!(failed.display_name, "Display");
532        assert_eq!(failed.error, "network error");
533        assert_eq!(failed.failed_at, 1_234_567_890);
534        // Defaults
535        assert!(!failed.recoverable, "recoverable should default to false");
536        assert_eq!(
537            failed.downloaded_bytes, 0,
538            "downloaded_bytes should default to 0"
539        );
540
541        // Chain builders and verify values are set
542        let failed2 = FailedDownload::new("id2", "Display2", "timeout", 0)
543            .with_recoverable(true)
544            .with_downloaded_bytes(500_000);
545
546        assert!(
547            failed2.recoverable,
548            "recoverable should be true after with_recoverable(true)"
549        );
550        assert_eq!(
551            failed2.downloaded_bytes, 500_000,
552            "downloaded_bytes should be 500_000"
553        );
554    }
555
556    /// Test `QueueSnapshot::default()` produces the same state as `QueueSnapshot::new(0)`.
557    #[test]
558    fn test_queue_snapshot_default() {
559        let default_snapshot = QueueSnapshot::default();
560        let zero_snapshot = QueueSnapshot::new(0);
561
562        // max_size should be 0 for both
563        assert_eq!(default_snapshot.max_size, 0);
564        assert_eq!(zero_snapshot.max_size, 0);
565        assert_eq!(default_snapshot.max_size, zero_snapshot.max_size);
566
567        // items should be empty
568        assert!(default_snapshot.items.is_empty());
569        assert_eq!(default_snapshot.items.len(), zero_snapshot.items.len());
570
571        // counts should be zero
572        assert_eq!(default_snapshot.active_count, 0);
573        assert_eq!(default_snapshot.pending_count, 0);
574
575        // recent_failures should be empty
576        assert!(default_snapshot.recent_failures.is_empty());
577        assert_eq!(
578            default_snapshot.recent_failures.len(),
579            zero_snapshot.recent_failures.len()
580        );
581    }
582}