1pub 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
48pub 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 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 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 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 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 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 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 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 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 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}