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