Skip to main content

netspeed_cli/
download.rs

1//! Multi-stream download bandwidth measurement.
2//!
3//! This module handles downloading test files from speedtest.net servers
4//! to measure download bandwidth. It supports:
5//! - Multi-stream concurrent downloads (4 streams by default, 1 with `--single`)
6//! - Dynamic test URL construction from server base URL
7//! - Real-time progress tracking with speed calculation
8//! - Peak speed detection through periodic sampling
9
10use crate::bandwidth_loop::run_concurrent_streams;
11use crate::endpoints::ServerEndpoints;
12use crate::error::Error;
13use crate::progress::Tracker;
14use crate::test_config::TestConfig;
15use crate::types::Server;
16use reqwest::Client;
17use std::sync::Arc;
18
19/// Extract base URL from server URL (strip /upload.php suffix)
20#[must_use]
21pub fn extract_base_url(url: &str) -> String {
22    ServerEndpoints::from_server_url(url).base().to_string()
23}
24
25/// Build test file URL using Speedtest.net standard naming
26#[must_use]
27pub fn build_test_url(server_url: &str, file_index: usize) -> String {
28    let sizes = ["2000x2000", "3000x3000", "3500x3500", "4000x4000"];
29    let size = sizes[file_index % sizes.len()];
30    ServerEndpoints::from_server_url(server_url).download_asset(&format!("random{size}.jpg"))
31}
32
33use futures_util::StreamExt;
34
35/// Run download bandwidth test against the given server.
36///
37/// Returns `(avg_speed_bps, peak_speed_bps, total_bytes_downloaded, speed_samples)`.
38///
39/// # Errors
40///
41/// Returns [`Error::NetworkError`] if all download streams fail.
42/// Returns [`Error::Context`] if the server URL is invalid.
43pub async fn run(
44    client: &Client,
45    server: &Server,
46    single: bool,
47    progress: Arc<Tracker>,
48) -> Result<(f64, f64, u64, Vec<f64>), Error> {
49    let config = TestConfig::default();
50    let stream_count = TestConfig::stream_count_for(single);
51
52    let result = run_concurrent_streams(
53        config.estimated_download_bytes,
54        stream_count,
55        progress,
56        "download",
57        |_, state, sample_interval| {
58            let client = client.clone();
59            let server_url = Arc::new(server.url.clone());
60            tokio::spawn(async move {
61                for j in 0..config.download_rounds {
62                    let test_url = build_test_url(&server_url, j);
63
64                    let response = client
65                        .get(&test_url)
66                        .send()
67                        .await
68                        .map_err(Error::DownloadTest)?;
69
70                    if !response.status().is_success() {
71                        return Err(Error::DownloadFailure(format!(
72                            "server returned {} for {test_url}",
73                            response.status()
74                        )));
75                    }
76
77                    let mut stream = response.bytes_stream();
78                    while let Some(item) = stream.next().await {
79                        let chunk = item.map_err(Error::DownloadTest)?;
80                        let len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
81                        if len > 0 {
82                            state.record_bytes(len, sample_interval);
83                        }
84                    }
85                }
86                Ok(())
87            })
88        },
89    )
90    .await?;
91
92    Ok((
93        result.avg_bps,
94        result.peak_bps,
95        result.total_bytes,
96        result.speed_samples,
97    ))
98}
99
100#[cfg(test)]
101mod tests {
102    use crate::common;
103    use crate::test_config::TestConfig;
104
105    use super::*;
106
107    #[test]
108    fn test_download_bandwidth_calculation() {
109        let result = common::calculate_bandwidth(10_000_000, 2.0);
110        assert!((result - 40_000_000.0).abs() < f64::EPSILON);
111    }
112
113    #[test]
114    fn test_download_bandwidth_zero_elapsed() {
115        let result = common::calculate_bandwidth(10_000_000, 0.0);
116        assert!(result.abs() < f64::EPSILON);
117    }
118
119    #[test]
120    fn test_download_concurrent_streams_single() {
121        assert_eq!(TestConfig::stream_count_for(true), 1);
122    }
123
124    #[test]
125    fn test_download_concurrent_streams_multiple() {
126        assert_eq!(TestConfig::stream_count_for(false), 4);
127    }
128
129    #[test]
130    fn test_download_url_generation() {
131        let server_url = "http://server.example.com/speedtest/upload.php";
132        let test_url = build_test_url(server_url, 0);
133        assert_eq!(
134            test_url,
135            "http://server.example.com/speedtest/random2000x2000.jpg"
136        );
137    }
138
139    #[test]
140    fn test_download_url_generation_cycles() {
141        let server_url = "http://server.example.com/speedtest/upload.php";
142        let url_0 = build_test_url(server_url, 0);
143        let url_4 = build_test_url(server_url, 4);
144        assert_eq!(url_0, url_4);
145    }
146
147    #[test]
148    fn test_download_url_generation_all_sizes() {
149        let server_url = "http://server.example.com/speedtest/upload.php";
150        let expected = [
151            "http://server.example.com/speedtest/random2000x2000.jpg",
152            "http://server.example.com/speedtest/random3000x3000.jpg",
153            "http://server.example.com/speedtest/random3500x3500.jpg",
154            "http://server.example.com/speedtest/random4000x4000.jpg",
155        ];
156
157        for (i, expected_url) in expected.iter().enumerate() {
158            assert_eq!(build_test_url(server_url, i), *expected_url);
159        }
160    }
161
162    #[test]
163    fn test_extract_base_url() {
164        let url = "http://server.example.com:8080/speedtest/upload.php";
165        assert_eq!(
166            extract_base_url(url),
167            "http://server.example.com:8080/speedtest"
168        );
169    }
170
171    #[test]
172    fn test_extract_base_url_no_suffix() {
173        let url = "http://server.example.com/speedtest";
174        assert_eq!(extract_base_url(url), "http://server.example.com/speedtest");
175    }
176
177    #[test]
178    fn test_extract_base_url_different_path() {
179        let url = "https://cdn.speedtest.net/upload.php";
180        assert_eq!(extract_base_url(url), "https://cdn.speedtest.net");
181    }
182
183    #[test]
184    fn test_estimated_download_bytes_from_config() {
185        // Verify the config value is reasonable (around 15 MB)
186        let config = TestConfig::default();
187        assert!(config.estimated_download_bytes > 10_000_000);
188        assert!(config.estimated_download_bytes < 20_000_000);
189    }
190
191    #[test]
192    fn test_sample_interval_constant() {
193        // Verify sample interval is 50ms (20 Hz) — now defined in LoopState
194        const _: () = assert!(crate::bandwidth_loop::SAMPLE_INTERVAL_MS == 50);
195    }
196}