1use crate::common;
4use crate::progress::no_color;
5use crate::types::{Server, TestResult};
6use owo_colors::OwoColorize;
7
8use super::ratings::{
9 bufferbloat_colorized, bufferbloat_grade, colorize_rating, degradation_str, format_duration,
10 format_speed_colored, format_speed_plain, ping_rating, speed_rating_mbps,
11};
12
13fn build_skipped_line(label: &str, nc: bool) -> String {
14 if nc {
15 format!(" {:>14}: — (skipped)", label)
16 } else {
17 format!(
18 " {:>14}: {}",
19 label.dimmed(),
20 "— (skipped)".bright_black()
21 )
22 }
23}
24
25fn build_speed_section(label: &str, speed_bps: f64, bytes: bool, nc: bool) -> String {
26 let speed = if nc {
27 format_speed_plain(speed_bps, bytes)
28 } else {
29 format_speed_colored(speed_bps, bytes)
30 };
31 let rating = colorize_rating(speed_rating_mbps(speed_bps / 1_000_000.0), nc);
32 let bar = crate::common::bar_chart(speed_bps / 1_000_000.0, 1000.0, 28);
33 let bar_display = if nc {
34 bar
35 } else {
36 let fill_pct = (speed_bps / 1_000_000.0 / 1000.0).clamp(0.0, 1.0) * 100.0;
37 if fill_pct >= 70.0 {
38 bar.green().to_string()
39 } else if fill_pct >= 40.0 {
40 bar.yellow().to_string()
41 } else {
42 bar.red().to_string()
43 }
44 };
45 if nc {
46 format!(" {label:>14}: {speed} {bar_display}")
47 } else {
48 format!(
49 " {:>14}: {speed} {bar_display} {rating}",
50 label.dimmed()
51 )
52 }
53}
54
55fn build_peak_line(peak_bps: f64, bytes: bool, nc: bool) -> String {
56 let peak = if nc {
57 format_speed_plain(peak_bps, bytes)
58 } else {
59 format_speed_colored(peak_bps, bytes)
60 };
61 if nc {
62 format!(" {:>14}: {peak}", "Peak (1s avg)")
63 } else {
64 format!(" {:>14}: {peak}", "Peak (1s avg)".dimmed())
65 }
66}
67
68fn build_latency_load_line(lat_load: f64, idle_ping: Option<f64>, nc: bool) -> String {
69 let degradation = degradation_str(lat_load, idle_ping, nc);
70 if nc {
71 format!(
72 " {:>14}: {:>8.1} ms{degradation}",
73 "Latency (load)", lat_load
74 )
75 } else {
76 format!(
77 " {:>14}: {}{degradation}",
78 "Latency (load)".dimmed(),
79 format!("{lat_load:.1} ms").yellow(),
80 )
81 }
82}
83
84pub fn build_latency_section(result: &TestResult, nc: bool) -> String {
85 let Some(ping) = result.ping else {
86 return String::new();
87 };
88
89 let mut lines = Vec::new();
90
91 let rating_str = colorize_rating(ping_rating(ping), nc);
92 if nc {
93 lines.push(format!(
94 " {:>14}: {:>8.1} ms ({rating_str})",
95 "Latency", ping
96 ));
97 } else {
98 lines.push(format!(
99 " {:>14}: {} {rating_str}",
100 "Latency".dimmed(),
101 format!("{ping:.1} ms").cyan().bold(),
102 ));
103 }
104
105 if let Some(jitter) = result.jitter {
106 if nc {
107 lines.push(format!(" {:>14}: {:>8.1} ms", "Jitter", jitter));
108 } else {
109 lines.push(format!(
110 " {:>14}: {}",
111 "Jitter".dimmed(),
112 format!("{jitter:.1} ms").cyan()
113 ));
114 }
115 }
116
117 if let Some(loss) = result.packet_loss {
118 let loss_color = if loss == 0.0 {
119 "green"
120 } else if loss < 1.0 {
121 "yellow"
122 } else {
123 "red"
124 };
125 let loss_str = format!("{loss:.1}%");
126 if nc {
127 lines.push(format!(" {:>14}: {:>8}", "Packet Loss", loss_str));
128 } else {
129 let display = if loss == 0.0 {
130 format!("{} {}", loss_str.green(), "✓".green())
131 } else {
132 match loss_color {
133 "green" => loss_str.green().to_string(),
134 "yellow" => loss_str.yellow().to_string(),
135 "red" => loss_str.red().bold().to_string(),
136 _ => loss_str.dimmed().to_string(),
137 }
138 };
139 lines.push(format!(" {:>14}: {display}", "Packet Loss".dimmed()));
140 }
141 }
142
143 if let (Some(lat_dl), Some(lat_ul)) = (result.latency_download, result.latency_upload) {
145 let max_load = lat_dl.max(lat_ul);
146 let (grade, added) = bufferbloat_grade(max_load, result.ping.unwrap_or(0.0));
147 let display = bufferbloat_colorized(grade, added, nc);
148 if nc {
149 lines.push(format!(" {:>14}: {:>12}", "Bufferbloat", display));
150 } else {
151 lines.push(format!(" {:>14}: {display}", "Bufferbloat".dimmed()));
152 }
153 }
154
155 lines.join("\n")
156}
157
158pub fn format_latency_section(result: &TestResult, nc: bool) {
159 let output = build_latency_section(result, nc);
160 if !output.is_empty() {
161 eprintln!("{output}");
162 }
163}
164
165pub fn build_download_section(result: &TestResult, bytes: bool, nc: bool, skipped: bool) -> String {
166 let Some(dl) = result.download else {
167 if skipped {
168 return build_skipped_line("Download", nc);
169 }
170 return String::new();
171 };
172
173 let mut lines = Vec::new();
174 lines.push(build_speed_section("Download", dl, bytes, nc));
175
176 if let Some(peak) = result.download_peak {
177 lines.push(build_peak_line(peak, bytes, nc));
178 }
179
180 if let Some(lat_dl) = result.latency_download {
181 lines.push(build_latency_load_line(lat_dl, result.ping, nc));
182 }
183
184 lines.join("\n")
185}
186
187pub fn format_download_section(result: &TestResult, bytes: bool, nc: bool, skipped: bool) {
188 let output = build_download_section(result, bytes, nc, skipped);
189 if !output.is_empty() {
190 eprintln!("{output}");
191 }
192}
193
194pub fn build_upload_section(result: &TestResult, bytes: bool, nc: bool, skipped: bool) -> String {
195 let Some(ul) = result.upload else {
196 if skipped {
197 return build_skipped_line("Upload", nc);
198 }
199 return String::new();
200 };
201
202 let mut lines = Vec::new();
203 lines.push(build_speed_section("Upload", ul, bytes, nc));
204
205 if let Some(peak) = result.upload_peak {
206 lines.push(build_peak_line(peak, bytes, nc));
207 }
208
209 if let Some(lat_ul) = result.latency_upload {
210 lines.push(build_latency_load_line(lat_ul, result.ping, nc));
211 }
212
213 if let (Some(dl), Some(ul)) = (result.download, result.upload) {
215 let ratio = if ul > 0.0 { dl / ul } else { f64::INFINITY };
216 let ratio_str = if nc {
217 format!("{ratio:.2}x")
218 } else {
219 let (color, label) = if ratio > 1.5 {
220 ("yellow", "download-heavy")
221 } else if ratio < 0.67 {
222 ("cyan", "upload-favored")
223 } else {
224 ("green", "balanced")
225 };
226 match color {
227 "green" => format!("{ratio:.2}x {label}").green().to_string(),
228 "yellow" => format!("{ratio:.2}x {label}").yellow().to_string(),
229 "cyan" => format!("{ratio:.2}x {label}").cyan().to_string(),
230 _ => format!("{ratio:.2}x {label}"),
231 }
232 };
233 if nc {
234 lines.push(format!(" {:>14}: {ratio_str}", "UL/DL Ratio"));
235 } else {
236 lines.push(format!(" {:>14}: {ratio_str}", "UL/DL Ratio".dimmed()));
237 }
238 }
239
240 lines.join("\n")
241}
242
243pub fn format_upload_section(result: &TestResult, bytes: bool, nc: bool, skipped: bool) {
244 let output = build_upload_section(result, bytes, nc, skipped);
245 if !output.is_empty() {
246 eprintln!("{output}");
247 }
248}
249
250pub fn build_connection_info(result: &TestResult, nc: bool) -> String {
251 let dist = common::format_distance(result.server.distance);
252 let mut lines = Vec::new();
253
254 if nc {
255 lines.push(String::from("\n CONNECTION INFO"));
256 } else {
257 lines.push(format!("\n {}", "CONNECTION INFO".bold().underline()));
258 }
259
260 if nc {
261 lines.push(format!(
262 " {:>16}: {} ({})",
263 "Server", result.server.sponsor, result.server.name
264 ));
265 } else {
266 lines.push(format!(
267 " {:>16}: {} ({})",
268 "Server".dimmed(),
269 result.server.sponsor.white().bold(),
270 result.server.name
271 ));
272 }
273
274 if nc {
275 lines.push(format!(
276 " {:>16}: {} ({dist})",
277 "Location", result.server.country
278 ));
279 } else {
280 lines.push(format!(
281 " {:>16}: {} ({dist})",
282 "Location".dimmed(),
283 result.server.country,
284 ));
285 }
286
287 if let Some(ip) = &result.client_ip {
288 if nc {
289 lines.push(format!(" {:>16}: {ip}", "Client IP"));
290 } else {
291 lines.push(format!(" {:>16}: {ip}", "Client IP".dimmed()));
292 }
293 }
294
295 lines.join("\n")
296}
297
298pub fn format_connection_info(result: &TestResult, nc: bool) {
299 eprintln!("{}", build_connection_info(result, nc));
300}
301
302pub fn build_test_summary(
303 dl_bytes: u64,
304 ul_bytes: u64,
305 dl_duration: f64,
306 ul_duration: f64,
307 nc: bool,
308) -> String {
309 let mut lines = Vec::new();
310
311 if nc {
312 lines.push(String::from("\n TEST SUMMARY"));
313 } else {
314 lines.push(format!("\n {}", "TEST SUMMARY".bold().underline()));
315 }
316
317 if dl_bytes > 0 {
318 lines.push(format!(
319 " {:>14}: {} in {}",
320 "Download",
321 common::format_data_size(dl_bytes),
322 format_duration(dl_duration)
323 ));
324 }
325 if ul_bytes > 0 {
326 lines.push(format!(
327 " {:>14}: {} in {}",
328 "Upload",
329 common::format_data_size(ul_bytes),
330 format_duration(ul_duration)
331 ));
332 }
333 let total = dl_bytes + ul_bytes;
334 let total_dur = dl_duration + ul_duration;
335 if total > 0 {
336 lines.push(format!(
337 " {:>14}: {} in {}",
338 "Total",
339 common::format_data_size(total),
340 format_duration(total_dur)
341 ));
342 }
343
344 lines.join("\n")
345}
346
347pub fn format_test_summary(
348 dl_bytes: u64,
349 ul_bytes: u64,
350 dl_duration: f64,
351 ul_duration: f64,
352 nc: bool,
353) {
354 eprintln!(
355 "{}",
356 build_test_summary(dl_bytes, ul_bytes, dl_duration, ul_duration, nc)
357 );
358}
359
360pub fn build_footer(timestamp: &str, nc: bool) -> String {
361 if nc {
362 format!("\n Completed at: {timestamp}")
363 } else {
364 format!(
365 "\n {} {}",
366 "Completed at:".dimmed(),
367 timestamp.bright_black()
368 )
369 }
370}
371
372pub fn format_footer(timestamp: &str, nc: bool) {
373 eprintln!("{}", build_footer(timestamp, nc));
374}
375
376pub fn build_list(servers: &[Server]) -> String {
378 let nc = no_color();
379
380 let (max_id_len, max_sponsor_len, max_name_len) =
381 servers
382 .iter()
383 .fold((3, 7, 24), |(max_id, max_sponsor, max_name), s| {
384 let name_len = s.name.len() + s.country.len() + 3;
385 (
386 max_id.max(s.id.len()),
387 max_sponsor.max(s.sponsor.len()),
388 max_name.max(name_len),
389 )
390 });
391
392 let idw = max_id_len.max(3);
393 let sw = max_sponsor_len.max(7);
394 let nw = max_name_len.max(24);
395
396 let mut lines = Vec::new();
397
398 if nc {
399 lines.push(String::from("\n AVAILABLE SERVERS"));
400 } else {
401 lines.push(format!("\n {}", "AVAILABLE SERVERS".bold().underline()));
402 }
403
404 if nc {
405 lines.push(format!(
406 " {:<idw$} {:<sw$} {:<nw$} {:>10}",
407 "ID", "Sponsor", "Name (Country)", "Distance"
408 ));
409 } else {
410 lines.push(format!(
411 " {:<idw$} {:<sw$} {:<nw$} {:>10}",
412 "ID".dimmed(),
413 "Sponsor".dimmed(),
414 "Name (Country)".dimmed(),
415 "Distance".dimmed()
416 ));
417 }
418
419 if nc {
420 lines.push(format!(
421 " {:->idw$} {:->sw$} {:->nw$} {:->10}",
422 "", "", "", ""
423 ));
424 } else {
425 lines.push(format!(
426 " {:->idw$} {:->sw$} {:->nw$} {:->10}",
427 "",
428 "",
429 "",
430 "".dimmed()
431 ));
432 }
433
434 for server in servers {
435 let dist = common::format_distance(server.distance);
436 if nc {
437 lines.push(format!(
438 " {:<idw$} {:<sw$} {:<24} {:>10}",
439 server.id,
440 server.sponsor,
441 format!("{} ({})", server.name, server.country),
442 dist,
443 ));
444 } else {
445 lines.push(format!(
446 " {:<idw$} {:<sw$} {:<24} {:>10}",
447 server.id,
448 server.sponsor.white().bold(),
449 format!("{} ({})", server.name, server.country),
450 dist.bright_black(),
451 ));
452 }
453 }
454
455 lines.join("\n")
456}
457
458pub fn format_list(servers: &[Server]) -> Result<(), std::io::Error> {
465 eprintln!("{}", build_list(servers));
466 Ok(())
467}