Skip to main content

netspeed_cli/
servers.rs

1#![allow(
2    clippy::cast_precision_loss,
3    clippy::cast_possible_truncation,
4    clippy::cast_sign_loss
5)]
6
7use crate::endpoints::ServerEndpoints;
8use crate::error::Error;
9use crate::test_config::TestConfig;
10use crate::types::Server;
11use quick_xml::de::from_str;
12use reqwest::Client;
13use serde::Deserialize;
14use std::sync::Arc;
15use std::sync::atomic::{AtomicBool, Ordering};
16
17/// Root element for the Speedtest.net servers XML response
18/// XML structure: <settings><servers><server .../></servers></settings>
19#[derive(Debug, Clone, Deserialize)]
20#[serde(rename = "settings")]
21struct ServerConfig {
22    #[serde(rename = "servers")]
23    servers_wrapper: ServersWrapper,
24}
25
26/// Wrapper for the list of servers (maps to <servers> element)
27#[derive(Debug, Clone, Deserialize)]
28struct ServersWrapper {
29    #[serde(rename = "server", default)]
30    servers: Vec<Server>,
31}
32
33const SPEEDTEST_SERVERS_URL: &str = "https://www.speedtest.net/speedtest-servers-static.php";
34const SPEEDTEST_CONFIG_URL: &str = "https://www.speedtest.net/api/ios-config.php";
35
36/// Calculate distance between two geographic points using Haversine formula.
37///
38/// # Examples
39///
40/// ```
41/// # use netspeed_cli::servers::calculate_distance;
42/// let dist = calculate_distance(40.7128, -74.0060, 34.0522, -118.2437);
43/// assert!((dist - 3944.0).abs() < 200.0); // ~3944 km, NYC to LA
44/// ```
45#[must_use]
46pub fn calculate_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
47    const EARTH_RADIUS_KM: f64 = 6371.0;
48
49    let lat1_rad = lat1.to_radians();
50    let lat2_rad = lat2.to_radians();
51    let delta_lat = (lat2 - lat1).to_radians();
52    let delta_lon = (lon2 - lon1).to_radians();
53
54    let a = (delta_lat / 2.0).sin().powi(2)
55        + lat1_rad.cos() * lat2_rad.cos() * (delta_lon / 2.0).sin().powi(2);
56    let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
57
58    EARTH_RADIUS_KM * c
59}
60
61/// Client location data from the speedtest.net config API.
62///
63/// This struct mirrors the client element from the speedtest.net iOS config API.
64/// It contains geographic coordinates and optional location metadata.
65#[derive(Debug, Clone, Deserialize, Default)]
66struct ClientConfig {
67    #[serde(rename = "client", default)]
68    client: ClientInfo,
69}
70
71/// Client information from the speedtest.net config API.
72#[derive(Debug, Clone, Deserialize, Default)]
73struct ClientInfo {
74    #[serde(rename = "@lat", default)]
75    lat: Option<f64>,
76    #[serde(rename = "@lon", default)]
77    lon: Option<f64>,
78    #[serde(rename = "@city", default)]
79    city: Option<String>,
80    #[serde(rename = "@country", default)]
81    country: Option<String>,
82}
83
84/// Fetch client location from speedtest.net config API.
85///
86/// Returns a [`crate::types::ClientLocation`] with coordinates and optional
87/// city/country information from the speedtest.net iOS config endpoint.
88///
89/// # Errors
90///
91/// Returns [`Error::Context`] if the response cannot be parsed or coordinates
92/// are missing.
93pub async fn fetch_client_location(client: &Client) -> Result<crate::types::ClientLocation, Error> {
94    let response = client
95        .get(SPEEDTEST_CONFIG_URL)
96        .send()
97        .await?
98        .text()
99        .await?;
100
101    let config: ClientConfig = from_str(&response)?;
102
103    match (config.client.lat, config.client.lon) {
104        (Some(lat), Some(lon)) => Ok(crate::types::ClientLocation {
105            lat,
106            lon,
107            city: config.client.city,
108            country: config.client.country,
109        }),
110        _ => Err(Error::Context {
111            msg: "Could not parse client location from config".to_string(),
112            source: None,
113        }),
114    }
115}
116
117/// Fetch the list of available speedtest servers, sorted by distance.
118///
119/// Also returns the client location if available, so callers don't need
120/// to make a separate API call.
121///
122/// # Errors
123///
124/// Returns [`Error::NetworkError`] if fetching the server list fails.
125/// Returns [`Error::DeserializeXml`] if the XML response cannot be parsed.
126pub async fn fetch(
127    client: &Client,
128) -> Result<(Vec<Server>, Option<crate::types::ClientLocation>), Error> {
129    let client_location = match fetch_client_location(client).await {
130        Ok(loc) => Some(loc),
131        Err(ref e) => {
132            eprintln!(
133                "Warning: could not determine client location ({e}), using default (equator)"
134            );
135            None
136        }
137    };
138
139    // Use 0,0 as default if location fetch failed
140    let (client_lat, client_lon) = client_location
141        .as_ref()
142        .map_or((0.0, 0.0), |loc| (loc.lat, loc.lon));
143
144    let response = client
145        .get(SPEEDTEST_SERVERS_URL)
146        .send()
147        .await?
148        .text()
149        .await?;
150
151    let server_config: ServerConfig = from_str(&response)?;
152
153    let mut servers = server_config.servers_wrapper.servers;
154    for server in &mut servers {
155        server.distance = calculate_distance(client_lat, client_lon, server.lat, server.lon);
156    }
157
158    // Sort by distance so closest servers are first
159    servers.sort_by(|a, b| {
160        a.distance
161            .partial_cmp(&b.distance)
162            .unwrap_or(std::cmp::Ordering::Equal)
163    });
164
165    Ok((servers, client_location))
166}
167
168/// Select the best server from a list, preferring the closest by distance.
169///
170/// # Errors
171///
172/// Returns [`Error::ServerNotFound`] if the server list is empty.
173pub fn select_best_server(servers: &[Server]) -> Result<Server, Error> {
174    if servers.is_empty() {
175        return Err(Error::ServerNotFound("No servers available".to_string()));
176    }
177
178    // Select server with lowest distance (closest)
179    let best = servers
180        .iter()
181        .min_by(|a, b| {
182            a.distance
183                .partial_cmp(&b.distance)
184                .unwrap_or(std::cmp::Ordering::Equal)
185        })
186        .cloned()
187        .ok_or_else(|| Error::ServerNotFound("No servers available".to_string()))?;
188
189    Ok(best)
190}
191
192/// Run a ping test against the given server, returning (average latency, jitter, `packet_loss`%, `individual_samples`).
193///
194/// # Errors
195///
196/// Returns [`Error::NetworkError`] if all ping attempts fail.
197pub async fn ping_test(
198    client: &Client,
199    server: &Server,
200) -> Result<(f64, f64, f64, Vec<f64>), Error> {
201    let config = TestConfig::default();
202    let ping_attempts = config.ping_attempts;
203    let mut latencies = Vec::new();
204
205    // Perform multiple ping measurements
206    for _ in 0..ping_attempts {
207        let start = std::time::Instant::now();
208
209        let response = client
210            .get(ServerEndpoints::from_server_url(&server.url).latency())
211            .send()
212            .await;
213
214        let elapsed = start.elapsed().as_secs_f64() * 1000.0; // Convert to ms
215        if let Ok(resp) = response {
216            if resp.status().is_success() {
217                latencies.push(elapsed);
218            }
219        }
220    }
221
222    // Calculate average latency
223    if latencies.is_empty() {
224        return Err(Error::Context {
225            msg: "All ping attempts failed".to_string(),
226            source: None,
227        });
228    }
229
230    // Safe: len() is at most PING_ATTEMPTS (8), well under 2^53.
231    let avg = latencies.iter().sum::<f64>() / latencies.len() as f64;
232
233    // Calculate jitter (average of absolute differences between consecutive latencies)
234    let jitter = if latencies.len() > 1 {
235        let mut jitter_sum = 0.0;
236        for i in 1..latencies.len() {
237            jitter_sum += (latencies[i] - latencies[i - 1]).abs();
238        }
239        // Safe: len() is at most PING_ATTEMPTS (8).
240        jitter_sum / (latencies.len() - 1) as f64
241    } else {
242        0.0
243    };
244
245    // Calculate packet loss percentage
246    // Safe: both operands are at most PING_ATTEMPTS (8), tiny values.
247    let packet_loss = ((ping_attempts - latencies.len()) as f64 / ping_attempts as f64) * 100.0;
248
249    Ok((avg, jitter, packet_loss, latencies))
250}
251
252pub async fn measure_latency_under_load(
253    client: Client,
254    server_url: String,
255    samples: Arc<std::sync::Mutex<Vec<f64>>>,
256    stop: Arc<AtomicBool>,
257) {
258    let config = TestConfig::default();
259    let poll_interval = config.latency_poll_interval_ms;
260
261    while !stop.load(Ordering::Acquire) {
262        let start = std::time::Instant::now();
263        let response = client
264            .get(ServerEndpoints::from_server_url(&server_url).latency())
265            .send()
266            .await;
267
268        if let Ok(resp) = response {
269            if resp.status().is_success() {
270                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
271                if let Ok(mut lock) = samples.lock() {
272                    lock.push(elapsed);
273                }
274            }
275        }
276
277        tokio::time::sleep(std::time::Duration::from_millis(poll_interval)).await;
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_select_best_server() {
287        let servers = vec![
288            Server {
289                id: "1".to_string(),
290                url: "http://server1.com".to_string(),
291                name: "Far Server".to_string(),
292                sponsor: "ISP 1".to_string(),
293                country: "US".to_string(),
294                lat: 40.0,
295                lon: -74.0,
296                distance: 5000.0,
297            },
298            Server {
299                id: "2".to_string(),
300                url: "http://server2.com".to_string(),
301                name: "Close Server".to_string(),
302                sponsor: "ISP 2".to_string(),
303                country: "US".to_string(),
304                lat: 41.0,
305                lon: -73.0,
306                distance: 100.0,
307            },
308        ];
309
310        let best = select_best_server(&servers).unwrap();
311        assert_eq!(best.id, "2");
312        assert!((best.distance - 100.0).abs() < f64::EPSILON);
313    }
314
315    #[test]
316    fn test_select_best_server_empty() {
317        let servers: Vec<Server> = vec![];
318        let result = select_best_server(&servers);
319        assert!(result.is_err());
320        assert!(matches!(result.unwrap_err(), Error::ServerNotFound(_)));
321    }
322
323    #[test]
324    fn test_select_best_server_single() {
325        let servers = vec![Server {
326            id: "1".to_string(),
327            url: "http://server1.com".to_string(),
328            name: "Only Server".to_string(),
329            sponsor: "ISP".to_string(),
330            country: "US".to_string(),
331            lat: 40.0,
332            lon: -74.0,
333            distance: 500.0,
334        }];
335
336        let best = select_best_server(&servers).unwrap();
337        assert_eq!(best.id, "1");
338    }
339
340    #[test]
341    fn test_server_distance_comparison() {
342        let servers = vec![
343            Server {
344                id: "1".to_string(),
345                url: "http://server1.com".to_string(),
346                name: "Server 1".to_string(),
347                sponsor: "ISP".to_string(),
348                country: "US".to_string(),
349                lat: 40.0,
350                lon: -74.0,
351                distance: 300.0,
352            },
353            Server {
354                id: "2".to_string(),
355                url: "http://server2.com".to_string(),
356                name: "Server 2".to_string(),
357                sponsor: "ISP".to_string(),
358                country: "US".to_string(),
359                lat: 41.0,
360                lon: -73.0,
361                distance: 200.0,
362            },
363            Server {
364                id: "3".to_string(),
365                url: "http://server3.com".to_string(),
366                name: "Server 3".to_string(),
367                sponsor: "ISP".to_string(),
368                country: "US".to_string(),
369                lat: 42.0,
370                lon: -72.0,
371                distance: 100.0,
372            },
373        ];
374
375        let best = select_best_server(&servers).unwrap();
376        assert_eq!(best.id, "3");
377    }
378
379    #[test]
380    fn test_server_with_equal_distances() {
381        let servers = vec![
382            Server {
383                id: "1".to_string(),
384                url: "http://server1.com".to_string(),
385                name: "Server 1".to_string(),
386                sponsor: "ISP".to_string(),
387                country: "US".to_string(),
388                lat: 40.0,
389                lon: -74.0,
390                distance: 100.0,
391            },
392            Server {
393                id: "2".to_string(),
394                url: "http://server2.com".to_string(),
395                name: "Server 2".to_string(),
396                sponsor: "ISP".to_string(),
397                country: "US".to_string(),
398                lat: 41.0,
399                lon: -73.0,
400                distance: 100.0,
401            },
402        ];
403
404        let best = select_best_server(&servers).unwrap();
405        // Should return one of the servers with equal distance
406        assert!(best.id == "1" || best.id == "2");
407    }
408
409    #[test]
410    fn test_ping_test_average_calculation() {
411        let latencies = [10.0, 20.0, 15.0, 25.0];
412        let avg = latencies.iter().sum::<f64>() / latencies.len() as f64;
413        assert!((avg - 17.5).abs() < f64::EPSILON);
414    }
415
416    #[test]
417    fn test_ping_test_empty_handling() {
418        let latencies: Vec<f64> = vec![];
419        assert!(latencies.is_empty());
420    }
421
422    #[test]
423    fn test_calculate_distance_same_location() {
424        let dist = calculate_distance(40.7128, -74.0060, 40.7128, -74.0060);
425        assert!(dist < 0.01);
426    }
427
428    #[test]
429    fn test_calculate_distance_nyc_la() {
430        let dist = calculate_distance(40.7128, -74.0060, 34.0522, -118.2437);
431        assert!((dist - 3944.0).abs() < 200.0);
432    }
433
434    #[test]
435    fn test_calculate_distance_nyc_london() {
436        let dist = calculate_distance(40.7128, -74.0060, 51.5074, -0.1278);
437        assert!((dist - 5570.0).abs() < 300.0);
438    }
439
440    #[test]
441    fn test_client_config_deserialization() {
442        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
443<settings>
444    <client lat="40.7128" lon="-74.0060" ip="192.168.1.1" />
445</settings>"#;
446        let config: ClientConfig = from_str(xml).unwrap();
447        assert_eq!(config.client.lat, Some(40.7128));
448        assert_eq!(config.client.lon, Some(-74.0060));
449    }
450
451    #[test]
452    fn test_client_config_missing_coords() {
453        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
454<settings>
455    <client ip="192.168.1.1" />
456</settings>"#;
457        let config: ClientConfig = from_str(xml).unwrap();
458        assert!(config.client.lat.is_none());
459        assert!(config.client.lon.is_none());
460    }
461
462    #[test]
463    fn test_calculate_distance_sydney_tokyo() {
464        let dist = calculate_distance(-33.8688, 151.2093, 35.6762, 139.6503);
465        assert!((dist - 7823.0).abs() < 300.0);
466    }
467
468    #[test]
469    fn test_calculate_distance_opposite_sides() {
470        // NYC to Sydney (roughly opposite sides of Earth)
471        let dist = calculate_distance(40.7128, -74.0060, -33.8688, 151.2093);
472        assert!(dist > 15_000.0); // Should be a very long distance
473    }
474
475    #[test]
476    fn test_calculate_distance_equator() {
477        // Points on the equator
478        let dist = calculate_distance(0.0, 0.0, 0.0, 10.0);
479        assert!((dist - 1111.0).abs() < 100.0); // ~1111 km per 10 degrees at equator
480    }
481
482    #[test]
483    fn test_server_config_deserialization() {
484        let xml = r#"<?xml version="1.0"?>
485<settings>
486    <servers>
487        <server url="http://server1.com/speedtest/upload.php" name="Server 1" sponsor="ISP 1" country="US" id="1" lat="40.0" lon="-74.0" />
488        <server url="http://server2.com/speedtest/upload.php" name="Server 2" sponsor="ISP 2" country="CA" id="2" lat="43.0" lon="-79.0" />
489    </servers>
490</settings>"#;
491        let config: ServerConfig = from_str(xml).unwrap();
492        assert_eq!(config.servers_wrapper.servers.len(), 2);
493        assert_eq!(config.servers_wrapper.servers[0].id, "1");
494        assert_eq!(config.servers_wrapper.servers[1].country, "CA");
495    }
496
497    #[test]
498    fn test_server_distance_comparison_with_negative_coords() {
499        // Test with servers in different hemispheres
500        let servers = vec![
501            Server {
502                id: "1".to_string(),
503                url: "http://server1.com".to_string(),
504                name: "Southern".to_string(),
505                sponsor: "ISP".to_string(),
506                country: "AU".to_string(),
507                lat: -33.8688,
508                lon: 151.2093,
509                distance: 15_000.0,
510            },
511            Server {
512                id: "2".to_string(),
513                url: "http://server2.com".to_string(),
514                name: "Northern".to_string(),
515                sponsor: "ISP".to_string(),
516                country: "US".to_string(),
517                lat: 40.7128,
518                lon: -74.0060,
519                distance: 100.0,
520            },
521        ];
522
523        let best = select_best_server(&servers).unwrap();
524        assert_eq!(best.id, "2"); // Northern is closer
525    }
526
527    #[test]
528    fn test_servers_wrapper_empty_deserialization() {
529        let xml = r#"<?xml version="1.0"?>
530<settings>
531    <servers>
532    </servers>
533</settings>"#;
534        let config: ServerConfig = from_str(xml).unwrap();
535        assert!(config.servers_wrapper.servers.is_empty());
536    }
537}