1use super::events::DownloadStatus;
8use super::format::{format_duration, format_rate};
9use super::types::{Quantization, ShardInfo};
10use serde::{Deserialize, Serialize};
11
12#[derive(Clone, Debug, Default, Serialize, Deserialize)]
14pub struct QueueSnapshot {
15 pub items: Vec<QueuedDownload>,
17 pub max_size: u32,
19 pub active_count: u32,
21 pub pending_count: u32,
23 pub recent_failures: Vec<FailedDownload>,
25}
26
27impl QueueSnapshot {
28 #[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 #[must_use]
42 pub const fn is_empty(&self) -> bool {
43 self.items.is_empty()
44 }
45
46 #[must_use]
48 pub const fn is_full(&self) -> bool {
49 self.items.len() >= self.max_size as usize
50 }
51
52 #[must_use]
54 pub const fn len(&self) -> usize {
55 self.items.len()
56 }
57
58 pub fn get(&self, id: &str) -> Option<&QueuedDownload> {
60 self.items.iter().find(|item| item.id == id)
61 }
62}
63
64#[derive(Clone, Debug, Serialize, Deserialize)]
66pub struct QueuedDownload {
67 pub id: String,
69
70 pub model_id: String,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub quantization: Option<Quantization>,
76
77 pub display_name: String,
79
80 pub status: DownloadStatus,
82
83 pub position: u32,
85
86 pub downloaded_bytes: u64,
88
89 pub total_bytes: u64,
91
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub speed_bps: Option<f64>,
95
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub eta_seconds: Option<f64>,
99
100 pub progress_percent: f64,
102
103 pub queued_at: u64,
105
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub started_at: Option<u64>,
109
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub group_id: Option<String>,
113
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub shard_info: Option<ShardInfo>,
117}
118
119impl QueuedDownload {
120 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 #[must_use]
149 pub const fn with_quantization(mut self, quant: Quantization) -> Self {
150 self.quantization = Some(quant);
151 self
152 }
153
154 #[must_use]
156 pub const fn with_status(mut self, status: DownloadStatus) -> Self {
157 self.status = status;
158 self
159 }
160
161 #[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 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 pub fn is_active(&self) -> bool {
199 self.status == DownloadStatus::Downloading
200 }
201
202 #[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 #[must_use]
213 pub fn speed_display(&self) -> String {
214 format_rate(self.speed_bps)
215 }
216
217 #[must_use]
219 pub fn eta_display(&self) -> String {
220 format_duration(self.eta_seconds)
221 }
222}
223
224#[derive(Clone, Debug, Serialize, Deserialize)]
226pub struct FailedDownload {
227 pub id: String,
229
230 pub display_name: String,
232
233 pub error: String,
235
236 pub failed_at: u64,
238
239 pub recoverable: bool,
241
242 pub downloaded_bytes: u64,
244}
245
246impl FailedDownload {
247 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 #[must_use]
266 pub const fn with_recoverable(mut self, recoverable: bool) -> Self {
267 self.recoverable = recoverable;
268 self
269 }
270
271 #[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 assert!((download.eta_seconds.unwrap() - 5.0).abs() < 0.01);
307 }
308
309 #[test]
310 fn test_speed_display() {
311 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 #[test]
344 fn test_status_classification_all_variants() {
345 let base = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
346
347 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 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 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 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 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 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 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]
395 fn test_update_progress_downloaded_exceeds_total() {
396 let mut download = QueuedDownload::new("test-id", "test-model", "test-display", 1, 0);
397
398 download.update_progress(1500, 1000, Some(100.0), None);
400
401 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]
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 download.update_progress(500, 0, Some(100.0), None);
426
427 assert_eq!(
429 download.progress_percent, 0.0,
430 "Progress should be 0.0 when total is 0"
431 );
432
433 assert!(
435 download.eta_seconds.is_none(),
436 "ETA should be None when total is 0"
437 );
438
439 assert_eq!(download.downloaded_bytes, 500);
441 assert!((download.speed_bps.unwrap() - 100.0).abs() < 0.01);
442 }
443
444 #[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 download.update_progress(500, 1000, Some(0.0), None);
452
453 assert_eq!(download.progress_percent, 50.0, "Progress should be 50.0%");
455
456 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]
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 download.update_progress(1000, 1000, Some(100.0), None);
475
476 assert_eq!(
478 download.progress_percent, 100.0,
479 "Progress should be 100.0%"
480 );
481
482 assert!(
484 download.eta_seconds.is_none(),
485 "ETA should be None when download is complete"
486 );
487
488 assert_eq!(download.downloaded_bytes, 1000);
490 assert!((download.speed_bps.unwrap() - 100.0).abs() < 0.01);
491 }
492
493 #[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 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; download.update_progress(downloaded, total, Some(speed_bps), Some(50_000.0));
504
505 assert!(
507 (download.progress_percent - 50.0).abs() < 0.1,
508 "Progress should be ~50%, got {}",
509 download.progress_percent
510 );
511
512 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 assert_eq!(download.downloaded_bytes, downloaded);
521 assert!((download.speed_bps.unwrap() - speed_bps).abs() < 0.01);
522 }
523
524 #[test]
526 fn test_failed_download_builders() {
527 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 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 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]
558 fn test_queue_snapshot_default() {
559 let default_snapshot = QueueSnapshot::default();
560 let zero_snapshot = QueueSnapshot::new(0);
561
562 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 assert!(default_snapshot.items.is_empty());
569 assert_eq!(default_snapshot.items.len(), zero_snapshot.items.len());
570
571 assert_eq!(default_snapshot.active_count, 0);
573 assert_eq!(default_snapshot.pending_count, 0);
574
575 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}