Skip to main content

cli_speedtest/
lib.rs

1// src/lib.rs
2
3pub mod client;
4pub mod cooldown;
5pub mod menu;
6pub mod models;
7pub mod theme;
8pub mod utils;
9
10use chrono::Utc;
11use models::{AppConfig, RunArgs, Server, SpeedTestResult};
12use reqwest::Client;
13use std::sync::Arc;
14use utils::{NonRetryableError, WARMUP_SECS};
15
16const DEFAULT_SERVER_URL: &str = "https://speed.cloudflare.com";
17
18async fn run_with_fallback_concurrency<F, Fut>(
19    initial_conns: usize,
20    config: Arc<AppConfig>,
21    test_name: &str,
22    mut f: F,
23) -> anyhow::Result<f64>
24where
25    F: FnMut(usize) -> Fut,
26    Fut: std::future::Future<Output = anyhow::Result<f64>>,
27{
28    match f(initial_conns).await {
29        Ok(speed) => Ok(speed),
30        Err(e) => {
31            let is_rate_limit = e.downcast_ref::<NonRetryableError>().is_some();
32            if is_rate_limit && initial_conns > 1 {
33                if !config.quiet {
34                    eprintln!(
35                        "{} rate limited at {} connections - retrying \
36                         with 1 connection...",
37                        test_name, initial_conns
38                    );
39                }
40                f(1).await
41            } else {
42                Err(e)
43            }
44        }
45    }
46}
47
48/// Core application logic - fully decoupled from clap so integration tests can
49/// call it directly with a mockito server URL via `RunArgs::server_url`.
50pub async fn run(
51    args: RunArgs,
52    config: Arc<AppConfig>,
53    client: Client,
54) -> anyhow::Result<SpeedTestResult> {
55    if args.duration_secs <= WARMUP_SECS as u64 {
56        anyhow::bail!(
57            "Duration must be greater than {} seconds (warm-up period). Got: {}s",
58            WARMUP_SECS,
59            args.duration_secs
60        );
61    }
62
63    if args.ping_count == 0 {
64        anyhow::bail!("--ping-count must be at least 1");
65    }
66
67    // Derive display name: if the URL is the default, label it "Cloudflare";
68    // otherwise show the URL itself so users know which custom server was used.
69    let server = Server {
70        name: if args.server_url == DEFAULT_SERVER_URL {
71            "Cloudflare".into()
72        } else {
73            args.server_url.clone()
74        },
75        base_url: args.server_url.clone(),
76    };
77
78    if !config.quiet {
79        println!("Using server: {}\n", server.name);
80    }
81
82    // --- Ping / Jitter / Packet Loss ---
83    let ping_stats = client::test_ping_stats(
84        &client,
85        &server.base_url,
86        args.ping_count,
87        Arc::clone(&config),
88    )
89    .await?;
90
91    // --- Download (skipped if --no-download) ---
92    let down_speed: Option<f64> = if args.no_download {
93        if !config.quiet {
94            println!("Download: skipped\n");
95        }
96        None
97    } else {
98        let conns = args.connections.unwrap_or(4);
99        let config_clone = Arc::clone(&config);
100        let speed = run_with_fallback_concurrency(conns, config_clone, "Download", |c| {
101            client::test_download(
102                &client,
103                &server.base_url,
104                args.duration_secs,
105                c,
106                Arc::clone(&config),
107            )
108        })
109        .await?;
110        if !config.quiet {
111            println!("Download Speed: {:.2} Mbps\n", speed);
112        }
113        Some(speed)
114    };
115
116    // --- Upload (skipped if --no-upload) ---
117    let up_speed: Option<f64> = if args.no_upload {
118        if !config.quiet {
119            println!("Upload: skipped\n");
120        }
121        None
122    } else {
123        let conns = args.connections.unwrap_or(2);
124        let config_clone = Arc::clone(&config);
125        let speed = run_with_fallback_concurrency(conns, config_clone, "Upload", |c| {
126            client::test_upload(
127                &client,
128                &server.base_url,
129                args.duration_secs,
130                c,
131                Arc::clone(&config),
132            )
133        })
134        .await?;
135        if !config.quiet {
136            println!("Upload Speed: {:.2} Mbps\n", speed);
137        }
138        Some(speed)
139    };
140
141    // --- Summary box ---
142    if !config.quiet {
143        let term_cols = console::Term::stdout().size().1 as usize;
144        let box_width = term_cols.saturating_sub(4).clamp(44, 60);
145        let inner_width = box_width - 2;
146
147        println!("╔{}╗", "═".repeat(inner_width));
148        println!("║{:^width$}║", "Test Summary", width = inner_width);
149        println!("╠{}╣", "═".repeat(inner_width));
150
151        // Server Row
152        let server_label = "  Server     : ";
153        let server_val_width = inner_width - server_label.len() - 1;
154        let truncated_server = theme::truncate_to(&server.name, server_val_width);
155        println!(
156            "║{}{} ║",
157            server_label,
158            theme::pad_to(&truncated_server, server_val_width),
159        );
160
161        println!("╠{}╣", "═".repeat(inner_width));
162
163        // Ping Stats Rows
164        let labels = [
165            (
166                "  Ping       : ",
167                theme::color_ping(ping_stats.avg_ms, &config),
168            ),
169            (
170                "  Jitter     : ",
171                theme::color_jitter(ping_stats.jitter_ms, &config),
172            ),
173            ("  Min Ping   : ", format!("{} ms", ping_stats.min_ms)),
174            ("  Max Ping   : ", format!("{} ms", ping_stats.max_ms)),
175            (
176                "  Packet Loss: ",
177                theme::color_loss(ping_stats.packet_loss_pct, &config),
178            ),
179        ];
180
181        for (label, val) in labels {
182            let val_width = inner_width - label.len() - 1;
183            println!("║{}{} ║", label, theme::pad_to(&val, val_width));
184        }
185
186        println!("╠{}╣", "═".repeat(inner_width));
187
188        // Download Row
189        match down_speed {
190            Some(s) => {
191                let label = "  Download   : ";
192                let speed_str = theme::color_speed(s, &config);
193                let rating = theme::speed_rating(s, &config);
194                let combined = format!("{}  {}", speed_str, rating);
195                let val_width = inner_width - label.len() - 1;
196                println!("║{}{} ║", label, theme::pad_to(&combined, val_width));
197            }
198            None => {
199                let label = "  Download   : ";
200                let val_width = inner_width - label.len() - 1;
201                println!("║{}{} ║", label, theme::pad_to("skipped", val_width));
202            }
203        }
204
205        // Upload Row
206        match up_speed {
207            Some(s) => {
208                let label = "  Upload     : ";
209                let speed_str = theme::color_speed(s, &config);
210                let rating = theme::speed_rating(s, &config);
211                let combined = format!("{}  {}", speed_str, rating);
212                let val_width = inner_width - label.len() - 1;
213                println!("║{}{} ║", label, theme::pad_to(&combined, val_width));
214            }
215            None => {
216                let label = "  Upload     : ";
217                let val_width = inner_width - label.len() - 1;
218                println!("║{}{} ║", label, theme::pad_to("skipped", val_width));
219            }
220        }
221
222        println!("╚{}╝", "═".repeat(inner_width));
223    }
224
225    Ok(SpeedTestResult {
226        timestamp: Utc::now().to_rfc3339(),
227        version: env!("CARGO_PKG_VERSION").to_string(),
228        server_name: server.name,
229        ping: ping_stats,
230        download_mbps: down_speed,
231        upload_mbps: up_speed,
232    })
233}