Skip to main content

gglib_core/download/
completion.rs

1//! Queue run completion tracking types.
2//!
3//! These types represent the completion of an entire queue run, distinct from
4//! individual download completion events. A queue run accumulates all downloads
5//! that complete between idle→busy and busy→idle transitions.
6
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use uuid::Uuid;
10
11use super::types::DownloadId;
12
13/// Stable artifact identity for completion tracking.
14///
15/// This key is computed at enqueue time (before download starts) and remains
16/// stable across retries, failures, and sharded downloads. It represents "what
17/// the user thinks they downloaded" from an artifact perspective, not a request
18/// perspective.
19///
20/// # Identity Semantics
21///
22/// - Same artifact downloaded twice → same key (deduplication)
23/// - All shards in a group → same key (one entry)
24/// - Failures before metadata available → key still valid
25/// - Survives cancellations and retries
26#[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    /// `HuggingFace` model file.
31    HfFile {
32        /// Repository ID (e.g., "unsloth/Llama-3-GGUF").
33        repo_id: String,
34        /// Git revision (branch, tag, or commit SHA).
35        /// Stores exactly what the user requested (e.g., "main", "v1.0", or a SHA).
36        /// Use "unspecified" if no revision was provided.
37        revision: String,
38        /// Canonical filename (normalized for sharded models).
39        /// Shard suffixes are stripped: "model-00001-of-00008.gguf" → "model.gguf"
40        filename_canon: String,
41        /// Quantization type (e.g., "`Q4_K_M`").
42        /// Optional since some downloads may not have a meaningful quantization.
43        #[cfg_attr(feature = "ts-bindings", ts(optional))]
44        #[serde(skip_serializing_if = "Option::is_none")]
45        quantization: Option<String>,
46    },
47
48    /// File downloaded from URL.
49    UrlFile {
50        /// Source URL.
51        url: String,
52        /// Target filename.
53        filename: String,
54    },
55
56    /// Local file operation.
57    LocalFile {
58        /// Absolute path to the file.
59        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                // Show only filename for local files
80                if let Some(name) = path.rsplit('/').next() {
81                    write!(f, "{name}")
82                } else {
83                    write!(f, "{path}")
84                }
85            }
86        }
87    }
88}
89
90/// Result kind for a completion attempt.
91#[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    /// Successfully downloaded and registered.
96    Downloaded,
97    /// Download failed.
98    Failed,
99    /// Download was cancelled by user.
100    Cancelled,
101    /// File already existed and was validated (not re-downloaded).
102    AlreadyPresent,
103}
104
105/// Counts of attempts by result kind.
106#[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    /// Number of successful downloads.
110    pub downloaded: u32,
111    /// Number of failed attempts.
112    pub failed: u32,
113    /// Number of cancelled attempts.
114    pub cancelled: u32,
115}
116
117impl AttemptCounts {
118    /// Create counts with a single attempt of the given kind.
119    #[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    /// Increment the count for the given kind.
146    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                // AlreadyPresent doesn't increment attempt counts
153                // (it's informational, not a retry)
154            }
155        }
156    }
157
158    /// Total number of attempts across all kinds.
159    ///
160    /// Test-only: where production wants these summed it reads the three
161    /// fields directly — `gglib-download`'s `calculate_total_attempts` folds
162    /// them into `QueueRunSummary`'s own `total_attempts_*` fields. Gated so
163    /// `dead_code` keeps telling the truth about production reach.
164    #[cfg(test)]
165    #[must_use]
166    pub(crate) const fn total(&self) -> u32 {
167        self.downloaded + self.failed + self.cancelled
168    }
169
170    /// Check if there were any retry attempts (more than one total attempt).
171    ///
172    /// Test-only, and gated for the same reason as [`Self::total`]: nothing in
173    /// production asks an `AttemptCounts` whether it retried.
174    #[cfg(test)]
175    #[must_use]
176    pub(crate) const fn has_retries(&self) -> bool {
177        self.total() > 1
178    }
179}
180
181/// Details for a single completed artifact in a queue run.
182#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
183#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
184pub struct CompletionDetail {
185    /// Stable artifact identity key.
186    pub key: CompletionKey,
187    /// Human-readable display name for UI.
188    pub display_name: String,
189    /// Most recent result for this artifact.
190    pub last_result: CompletionKind,
191    /// Unix timestamp (milliseconds since epoch) of last completion.
192    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
193    pub last_completed_at_ms: u64,
194    /// All download IDs that contributed to this completion.
195    /// Multiple IDs indicate retries or re-queues.
196    pub download_ids: Vec<DownloadId>,
197    /// Breakdown of attempts by result kind.
198    pub attempt_counts: AttemptCounts,
199}
200
201/// Summary of an entire queue run from start to drain.
202///
203/// Emitted when the queue transitions from busy → idle, capturing all
204/// completions that occurred during the run regardless of timing.
205#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
206#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
207pub struct QueueRunSummary {
208    /// Unique identifier for this queue run.
209    ///
210    /// `Uuid` serializes as its hyphenated string form. ts-rs is built here
211    /// without `uuid-impl`, so the substitute is spelled out — a primitive,
212    /// which needs no import and so takes `type` rather than `as`.
213    #[cfg_attr(feature = "ts-bindings", ts(type = "string"))]
214    pub run_id: Uuid,
215    /// Unix timestamp (milliseconds since epoch) when the run started.
216    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
217    pub started_at_ms: u64,
218    /// Unix timestamp (milliseconds since epoch) when the run completed.
219    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
220    pub completed_at_ms: u64,
221
222    // Attempt-based totals (diagnostics)
223    /// Total download attempts that succeeded.
224    pub total_attempts_downloaded: u32,
225    /// Total download attempts that failed.
226    pub total_attempts_failed: u32,
227    /// Total download attempts that were cancelled.
228    pub total_attempts_cancelled: u32,
229
230    // Unique key-based totals (UX)
231    /// Number of unique models successfully downloaded.
232    pub unique_models_downloaded: u32,
233    /// Number of unique models that failed.
234    pub unique_models_failed: u32,
235    /// Number of unique models that were cancelled.
236    pub unique_models_cancelled: u32,
237
238    /// True if there are more items than shown in `items`.
239    pub truncated: bool,
240
241    /// Detailed completion records, sorted by `last_completed_at_ms` (newest first).
242    /// Capped at 20 items for payload size management.
243    pub items: Vec<CompletionDetail>,
244}
245
246impl QueueRunSummary {
247    /// Total number of attempts across all result kinds.
248    #[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        // UrlFile displays the filename
281        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        // LocalFile with slash — shows only the basename
288        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        // LocalFile without slash — falls back to full path
294        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        // HfFile with quantization — round-trip
303        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        // HfFile without quantization — round-trip AND verify skip_serializing_if
314        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        // Verify "quantization" key is ABSENT when None (not null)
325        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        // Verify "quantization" key IS present when Some
332        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        // UrlFile round-trip
339        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        // LocalFile round-trip
348        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        // CompletionKind snake_case wire format
356        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        // Identical keys must be equal
391        assert_eq!(key1, key2);
392
393        // Identical keys must produce the same hash
394        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        // Insert duplicates into HashSet — should collapse to 1
401        let mut set = HashSet::new();
402        set.insert(key1);
403        set.insert(key2.clone());
404        assert_eq!(set.len(), 1);
405
406        // Keys differing in revision are NOT equal (different revisions = distinct artifacts)
407        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        // Keys differing in filename_canon are NOT equal (different files = distinct)
416        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        // from_kind(Failed) — single failed attempt
445        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        // from_kind(Cancelled) — single cancelled attempt
452        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        // from_kind(AlreadyPresent) — produces all zeros, identical to Default
459        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        // increment(Cancelled) — properly increments the cancelled counter
468        let mut counts = AttemptCounts::default();
469        counts.increment(CompletionKind::Cancelled);
470        assert_eq!(counts.cancelled, 1);
471        assert_eq!(counts.total(), 1);
472
473        // increment(AlreadyPresent) — no-op: counts before == counts after
474        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        // Construct a retry scenario: same model downloaded via two different DownloadIds
490        // (first attempt failed, second succeeded).
491        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        // Serialize to JSON string
515        let json = serde_json::to_string(&detail).expect("should serialize");
516
517        // Deserialize back
518        let restored: CompletionDetail = serde_json::from_str(&json).expect("should deserialize");
519
520        // Full equality — all fields must match exactly
521        assert_eq!(detail, restored);
522
523        // Verify the retry scenario is preserved through the round-trip
524        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        // Verify the JSON contains expected keys (wire-shape sanity)
532        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}