1use serde::{Deserialize, Serialize};
8use std::fmt;
9use uuid::Uuid;
10
11use super::types::DownloadId;
12
13#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
28#[serde(tag = "kind", rename_all = "snake_case")]
29pub enum CompletionKey {
30 HfFile {
32 repo_id: String,
34 revision: String,
38 filename_canon: String,
41 #[cfg_attr(feature = "ts-bindings", ts(optional))]
44 #[serde(skip_serializing_if = "Option::is_none")]
45 quantization: Option<String>,
46 },
47
48 UrlFile {
50 url: String,
52 filename: String,
54 },
55
56 LocalFile {
58 path: String,
60 },
61}
62
63impl fmt::Display for CompletionKey {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 match self {
66 Self::HfFile {
67 repo_id,
68 quantization,
69 ..
70 } => {
71 if let Some(quant) = quantization {
72 write!(f, "{repo_id} ({quant})")
73 } else {
74 write!(f, "{repo_id}")
75 }
76 }
77 Self::UrlFile { filename, .. } => write!(f, "{filename}"),
78 Self::LocalFile { path } => {
79 if let Some(name) = path.rsplit('/').next() {
81 write!(f, "{name}")
82 } else {
83 write!(f, "{path}")
84 }
85 }
86 }
87 }
88}
89
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
92#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
93#[serde(rename_all = "snake_case")]
94pub enum CompletionKind {
95 Downloaded,
97 Failed,
99 Cancelled,
101 AlreadyPresent,
103}
104
105#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
107#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
108pub struct AttemptCounts {
109 pub downloaded: u32,
111 pub failed: u32,
113 pub cancelled: u32,
115}
116
117impl AttemptCounts {
118 #[must_use]
120 pub const fn from_kind(kind: CompletionKind) -> Self {
121 match kind {
122 CompletionKind::Downloaded => Self {
123 downloaded: 1,
124 failed: 0,
125 cancelled: 0,
126 },
127 CompletionKind::Failed => Self {
128 downloaded: 0,
129 failed: 1,
130 cancelled: 0,
131 },
132 CompletionKind::Cancelled => Self {
133 downloaded: 0,
134 failed: 0,
135 cancelled: 1,
136 },
137 CompletionKind::AlreadyPresent => Self {
138 downloaded: 0,
139 failed: 0,
140 cancelled: 0,
141 },
142 }
143 }
144
145 pub const fn increment(&mut self, kind: CompletionKind) {
147 match kind {
148 CompletionKind::Downloaded => self.downloaded += 1,
149 CompletionKind::Failed => self.failed += 1,
150 CompletionKind::Cancelled => self.cancelled += 1,
151 CompletionKind::AlreadyPresent => {
152 }
155 }
156 }
157
158 #[cfg(test)]
165 #[must_use]
166 pub(crate) const fn total(&self) -> u32 {
167 self.downloaded + self.failed + self.cancelled
168 }
169
170 #[cfg(test)]
175 #[must_use]
176 pub(crate) const fn has_retries(&self) -> bool {
177 self.total() > 1
178 }
179}
180
181#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
183#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
184pub struct CompletionDetail {
185 pub key: CompletionKey,
187 pub display_name: String,
189 pub last_result: CompletionKind,
191 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
193 pub last_completed_at_ms: u64,
194 pub download_ids: Vec<DownloadId>,
197 pub attempt_counts: AttemptCounts,
199}
200
201#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
206#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
207pub struct QueueRunSummary {
208 #[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
214 pub run_id: Uuid,
215 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
217 pub started_at_ms: u64,
218 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
220 pub completed_at_ms: u64,
221
222 pub total_attempts_downloaded: u32,
225 pub total_attempts_failed: u32,
227 pub total_attempts_cancelled: u32,
229
230 pub unique_models_downloaded: u32,
233 pub unique_models_failed: u32,
235 pub unique_models_cancelled: u32,
237
238 pub truncated: bool,
240
241 pub items: Vec<CompletionDetail>,
244}
245
246impl QueueRunSummary {
247 #[must_use]
249 pub const fn total_attempts(&self) -> u32 {
250 self.total_attempts_downloaded + self.total_attempts_failed + self.total_attempts_cancelled
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::download::types::DownloadId;
258
259 #[test]
260 fn test_completion_key_display() {
261 let key = CompletionKey::HfFile {
262 repo_id: "unsloth/Llama-3-GGUF".to_string(),
263 revision: "main".to_string(),
264 filename_canon: "model.gguf".to_string(),
265 quantization: Some("Q4_K_M".to_string()),
266 };
267 assert_eq!(key.to_string(), "unsloth/Llama-3-GGUF (Q4_K_M)");
268
269 let key_no_quant = CompletionKey::HfFile {
270 repo_id: "unsloth/Llama-3-GGUF".to_string(),
271 revision: "main".to_string(),
272 filename_canon: "model.gguf".to_string(),
273 quantization: None,
274 };
275 assert_eq!(key_no_quant.to_string(), "unsloth/Llama-3-GGUF");
276 }
277
278 #[test]
279 fn test_completion_key_display_variants() {
280 let url_key = CompletionKey::UrlFile {
282 url: "https://example.com/model.gguf".to_string(),
283 filename: "model.gguf".to_string(),
284 };
285 assert_eq!(url_key.to_string(), "model.gguf");
286
287 let local_key = CompletionKey::LocalFile {
289 path: "/home/user/models/llama-3.Q4_K_M.gguf".to_string(),
290 };
291 assert_eq!(local_key.to_string(), "llama-3.Q4_K_M.gguf");
292
293 let local_no_slash = CompletionKey::LocalFile {
295 path: "model.gguf".to_string(),
296 };
297 assert_eq!(local_no_slash.to_string(), "model.gguf");
298 }
299
300 #[test]
301 fn test_completion_key_serde_roundtrip() {
302 let hf_with_quant = CompletionKey::HfFile {
304 repo_id: "unsloth/Llama-3-GGUF".to_string(),
305 revision: "main".to_string(),
306 filename_canon: "model.gguf".to_string(),
307 quantization: Some("Q4_K_M".to_string()),
308 };
309 let json = serde_json::to_string(&hf_with_quant).unwrap();
310 let parsed: CompletionKey = serde_json::from_str(&json).unwrap();
311 assert_eq!(parsed, hf_with_quant);
312
313 let hf_no_quant = CompletionKey::HfFile {
315 repo_id: "unsloth/Llama-3-GGUF".to_string(),
316 revision: "main".to_string(),
317 filename_canon: "model.gguf".to_string(),
318 quantization: None,
319 };
320 let json_no_quant = serde_json::to_string(&hf_no_quant).unwrap();
321 let parsed_no_quant: CompletionKey = serde_json::from_str(&json_no_quant).unwrap();
322 assert_eq!(parsed_no_quant, hf_no_quant);
323
324 let value: serde_json::Value = serde_json::from_str(&json_no_quant).unwrap();
326 assert!(
327 value.get("quantization").is_none(),
328 "quantization key should be absent when None, not present as null"
329 );
330
331 let value_with: serde_json::Value = serde_json::from_str(&json).unwrap();
333 assert!(
334 value_with.get("quantization").is_some(),
335 "quantization key should be present when Some"
336 );
337
338 let url_key = CompletionKey::UrlFile {
340 url: "https://example.com/model.gguf".to_string(),
341 filename: "model.gguf".to_string(),
342 };
343 let json_url = serde_json::to_string(&url_key).unwrap();
344 let parsed_url: CompletionKey = serde_json::from_str(&json_url).unwrap();
345 assert_eq!(parsed_url, url_key);
346
347 let local_key = CompletionKey::LocalFile {
349 path: "/home/user/models/llama.gguf".to_string(),
350 };
351 let json_local = serde_json::to_string(&local_key).unwrap();
352 let parsed_local: CompletionKey = serde_json::from_str(&json_local).unwrap();
353 assert_eq!(parsed_local, local_key);
354
355 for (kind, expected_wire) in [
357 (CompletionKind::Downloaded, "downloaded"),
358 (CompletionKind::Failed, "failed"),
359 (CompletionKind::Cancelled, "cancelled"),
360 (CompletionKind::AlreadyPresent, "already_present"),
361 ] {
362 let json = serde_json::to_string(&kind).unwrap();
363 assert!(
364 json.contains(expected_wire),
365 "Expected CompletionKind {kind:?} to serialize to snake_case '{expected_wire}', got: {json}"
366 );
367 let parsed: CompletionKind = serde_json::from_str(&json).unwrap();
368 assert_eq!(parsed, kind);
369 }
370 }
371
372 #[test]
373 fn test_completion_key_hash_dedup() {
374 use std::collections::HashSet;
375 use std::hash::{Hash, Hasher};
376
377 let key1 = CompletionKey::HfFile {
378 repo_id: "unsloth/Llama-3-GGUF".to_string(),
379 revision: "main".to_string(),
380 filename_canon: "model.gguf".to_string(),
381 quantization: Some("Q4_K_M".to_string()),
382 };
383 let key2 = CompletionKey::HfFile {
384 repo_id: "unsloth/Llama-3-GGUF".to_string(),
385 revision: "main".to_string(),
386 filename_canon: "model.gguf".to_string(),
387 quantization: Some("Q4_K_M".to_string()),
388 };
389
390 assert_eq!(key1, key2);
392
393 let mut h1 = std::collections::hash_map::DefaultHasher::new();
395 let mut h2 = std::collections::hash_map::DefaultHasher::new();
396 key1.hash(&mut h1);
397 key2.hash(&mut h2);
398 assert_eq!(h1.finish(), h2.finish());
399
400 let mut set = HashSet::new();
402 set.insert(key1);
403 set.insert(key2.clone());
404 assert_eq!(set.len(), 1);
405
406 let key_diff_revision = CompletionKey::HfFile {
408 repo_id: "unsloth/Llama-3-GGUF".to_string(),
409 revision: "v2.0".to_string(),
410 filename_canon: "model.gguf".to_string(),
411 quantization: Some("Q4_K_M".to_string()),
412 };
413 assert_ne!(key2, key_diff_revision);
414
415 let key_diff_filename = CompletionKey::HfFile {
417 repo_id: "unsloth/Llama-3-GGUF".to_string(),
418 revision: "main".to_string(),
419 filename_canon: "model-q8.gguf".to_string(),
420 quantization: Some("Q4_K_M".to_string()),
421 };
422 assert_ne!(key2, key_diff_filename);
423 }
424
425 #[test]
426 fn test_attempt_counts() {
427 let mut counts = AttemptCounts::from_kind(CompletionKind::Downloaded);
428 assert_eq!(counts.downloaded, 1);
429 assert_eq!(counts.total(), 1);
430 assert!(!counts.has_retries());
431
432 counts.increment(CompletionKind::Failed);
433 assert_eq!(counts.failed, 1);
434 assert_eq!(counts.total(), 2);
435 assert!(counts.has_retries());
436
437 counts.increment(CompletionKind::Downloaded);
438 assert_eq!(counts.downloaded, 2);
439 assert_eq!(counts.total(), 3);
440 }
441
442 #[test]
443 fn test_attempt_counts_all_kinds() {
444 let failed = AttemptCounts::from_kind(CompletionKind::Failed);
446 assert_eq!(failed.failed, 1);
447 assert_eq!(failed.downloaded, 0);
448 assert_eq!(failed.cancelled, 0);
449 assert_eq!(failed.total(), 1);
450
451 let cancelled = AttemptCounts::from_kind(CompletionKind::Cancelled);
453 assert_eq!(cancelled.cancelled, 1);
454 assert_eq!(cancelled.downloaded, 0);
455 assert_eq!(cancelled.failed, 0);
456 assert_eq!(cancelled.total(), 1);
457
458 let already = AttemptCounts::from_kind(CompletionKind::AlreadyPresent);
460 let default_counts = AttemptCounts::default();
461 assert_eq!(already, default_counts);
462 assert_eq!(already.downloaded, 0);
463 assert_eq!(already.failed, 0);
464 assert_eq!(already.cancelled, 0);
465 assert_eq!(already.total(), 0);
466
467 let mut counts = AttemptCounts::default();
469 counts.increment(CompletionKind::Cancelled);
470 assert_eq!(counts.cancelled, 1);
471 assert_eq!(counts.total(), 1);
472
473 let before = AttemptCounts {
475 downloaded: 2,
476 failed: 1,
477 cancelled: 0,
478 };
479 let mut counts = before;
480 counts.increment(CompletionKind::AlreadyPresent);
481 assert_eq!(
482 counts, before,
483 "increment(AlreadyPresent) should be a no-op"
484 );
485 }
486
487 #[test]
488 fn test_completion_detail_serde_roundtrip() {
489 let id1 = DownloadId::from_model("llama-3");
492 let id2 = DownloadId::new("unsloth/llama-3-gguf", Some("Q4_K_M"));
493
494 let key = CompletionKey::HfFile {
495 repo_id: "unsloth/llama-3-gguf".to_string(),
496 revision: "main".to_string(),
497 filename_canon: "model.gguf".to_string(),
498 quantization: Some("Q4_K_M".to_string()),
499 };
500
501 let mut counts = AttemptCounts::default();
502 counts.increment(CompletionKind::Failed);
503 counts.increment(CompletionKind::Downloaded);
504
505 let detail = CompletionDetail {
506 key,
507 display_name: "unsloth/llama-3-gguf (Q4_K_M)".to_string(),
508 last_result: CompletionKind::Downloaded,
509 last_completed_at_ms: 1_700_000_000_000,
510 download_ids: vec![id1.clone(), id2.clone()],
511 attempt_counts: counts,
512 };
513
514 let json = serde_json::to_string(&detail).expect("should serialize");
516
517 let restored: CompletionDetail = serde_json::from_str(&json).expect("should deserialize");
519
520 assert_eq!(detail, restored);
522
523 assert_eq!(restored.download_ids.len(), 2);
525 assert_eq!(restored.download_ids[0], id1);
526 assert_eq!(restored.download_ids[1], id2);
527 assert_eq!(restored.attempt_counts.failed, 1);
528 assert_eq!(restored.attempt_counts.downloaded, 1);
529 assert_eq!(restored.attempt_counts.total(), 2);
530
531 let value: serde_json::Value = serde_json::from_str(&json).expect("should parse as Value");
533 assert!(value.get("download_ids").is_some());
534 assert!(value.get("attempt_counts").is_some());
535 assert!(value.get("key").is_some());
536 assert!(value.get("display_name").is_some());
537 }
538
539 #[test]
540 fn test_queue_run_summary_totals() {
541 let summary = QueueRunSummary {
542 run_id: Uuid::nil(),
543 started_at_ms: 0,
544 completed_at_ms: 100_000,
545 total_attempts_downloaded: 5,
546 total_attempts_failed: 1,
547 total_attempts_cancelled: 0,
548 unique_models_downloaded: 3,
549 unique_models_failed: 1,
550 unique_models_cancelled: 0,
551 items: vec![],
552 truncated: false,
553 };
554
555 assert_eq!(summary.total_attempts(), 6);
556 }
557}