Skip to main content

uls_download/
progress.rs

1//! Progress tracking and callbacks for downloads.
2
3use std::sync::Arc;
4
5/// Information about download progress.
6#[derive(Debug, Clone)]
7pub struct DownloadProgress {
8    /// Total bytes to download (if known).
9    pub total_bytes: Option<u64>,
10
11    /// Bytes downloaded so far.
12    pub downloaded_bytes: u64,
13
14    /// Current download speed in bytes per second.
15    pub speed_bps: u64,
16
17    /// Estimated time remaining in seconds.
18    pub eta_seconds: Option<u64>,
19
20    /// Name of the file being downloaded.
21    pub filename: String,
22}
23
24impl DownloadProgress {
25    /// Create a new progress tracker.
26    pub fn new(filename: impl Into<String>, total_bytes: Option<u64>) -> Self {
27        Self {
28            total_bytes,
29            downloaded_bytes: 0,
30            speed_bps: 0,
31            eta_seconds: None,
32            filename: filename.into(),
33        }
34    }
35
36    /// Get the completion percentage (0.0 to 1.0).
37    pub fn fraction(&self) -> Option<f64> {
38        self.total_bytes.map(|total| {
39            if total == 0 {
40                1.0
41            } else {
42                self.downloaded_bytes as f64 / total as f64
43            }
44        })
45    }
46
47    /// Get the completion percentage as an integer (0 to 100).
48    pub fn percent(&self) -> Option<u8> {
49        self.fraction().map(|f| (f * 100.0).min(100.0) as u8)
50    }
51
52    /// Format the download speed as a human-readable string.
53    pub fn speed_string(&self) -> String {
54        format_bytes_per_second(self.speed_bps)
55    }
56
57    /// Format the downloaded/total as a human-readable string.
58    pub fn size_string(&self) -> String {
59        match self.total_bytes {
60            Some(total) => format!(
61                "{} / {}",
62                format_bytes(self.downloaded_bytes),
63                format_bytes(total)
64            ),
65            None => format_bytes(self.downloaded_bytes),
66        }
67    }
68}
69
70/// Callback function type for progress updates.
71pub type ProgressCallback = Arc<dyn Fn(&DownloadProgress) + Send + Sync>;
72
73/// Create a no-op progress callback.
74pub fn no_progress() -> ProgressCallback {
75    Arc::new(|_| {})
76}
77
78/// Format bytes as a human-readable string.
79fn format_bytes(bytes: u64) -> String {
80    const KB: u64 = 1024;
81    const MB: u64 = KB * 1024;
82    const GB: u64 = MB * 1024;
83
84    if bytes >= GB {
85        format!("{:.2} GB", bytes as f64 / GB as f64)
86    } else if bytes >= MB {
87        format!("{:.2} MB", bytes as f64 / MB as f64)
88    } else if bytes >= KB {
89        format!("{:.1} KB", bytes as f64 / KB as f64)
90    } else {
91        format!("{} B", bytes)
92    }
93}
94
95/// Format bytes per second as a human-readable string.
96fn format_bytes_per_second(bps: u64) -> String {
97    format!("{}/s", format_bytes(bps))
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn test_progress_fraction() {
106        let mut progress = DownloadProgress::new("test.zip", Some(1000));
107        progress.downloaded_bytes = 500;
108
109        assert_eq!(progress.fraction(), Some(0.5));
110        assert_eq!(progress.percent(), Some(50));
111    }
112
113    #[test]
114    fn test_progress_unknown_total() {
115        let progress = DownloadProgress::new("test.zip", None);
116        assert_eq!(progress.fraction(), None);
117        assert_eq!(progress.percent(), None);
118    }
119
120    #[test]
121    fn test_format_bytes() {
122        assert_eq!(format_bytes(500), "500 B");
123        assert_eq!(format_bytes(1024), "1.0 KB");
124        assert_eq!(format_bytes(1024 * 1024), "1.00 MB");
125        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
126    }
127
128    #[test]
129    fn test_size_string() {
130        let mut progress = DownloadProgress::new("test.zip", Some(1024 * 1024));
131        progress.downloaded_bytes = 512 * 1024;
132
133        assert_eq!(progress.size_string(), "512.0 KB / 1.00 MB");
134    }
135
136    #[test]
137    fn test_size_string_unknown_total() {
138        let mut progress = DownloadProgress::new("test.zip", None);
139        progress.downloaded_bytes = 2048;
140        assert_eq!(progress.size_string(), "2.0 KB");
141    }
142
143    #[test]
144    fn test_fraction_zero_total_is_complete() {
145        // A zero-length file is treated as fully downloaded.
146        let progress = DownloadProgress::new("empty.zip", Some(0));
147        assert_eq!(progress.fraction(), Some(1.0));
148        assert_eq!(progress.percent(), Some(100));
149    }
150
151    #[test]
152    fn test_percent_clamped_to_100() {
153        // Overshooting the reported total still caps at 100 percent.
154        let mut progress = DownloadProgress::new("test.zip", Some(1000));
155        progress.downloaded_bytes = 1500;
156        assert_eq!(progress.percent(), Some(100));
157    }
158
159    #[test]
160    fn test_speed_string() {
161        let mut progress = DownloadProgress::new("test.zip", None);
162        progress.speed_bps = 1024 * 1024;
163        assert_eq!(progress.speed_string(), "1.00 MB/s");
164
165        progress.speed_bps = 500;
166        assert_eq!(progress.speed_string(), "500 B/s");
167    }
168
169    #[test]
170    fn test_format_bytes_per_second() {
171        assert_eq!(format_bytes_per_second(0), "0 B/s");
172        assert_eq!(format_bytes_per_second(1024), "1.0 KB/s");
173        assert_eq!(format_bytes_per_second(1024 * 1024 * 1024), "1.00 GB/s");
174    }
175
176    #[test]
177    fn test_new_initializes_fields() {
178        let progress = DownloadProgress::new("file.zip", Some(99));
179        assert_eq!(progress.filename, "file.zip");
180        assert_eq!(progress.total_bytes, Some(99));
181        assert_eq!(progress.downloaded_bytes, 0);
182        assert_eq!(progress.speed_bps, 0);
183        assert_eq!(progress.eta_seconds, None);
184    }
185}