1use crate::common;
4use crate::terminal;
5use crate::theme::{Colors, Theme};
6use crate::types::{Server, TestResult};
7use owo_colors::OwoColorize;
8
9use super::ratings::{
10 bufferbloat_colorized, bufferbloat_grade, colorize_rating, degradation_str, ping_rating,
11 speed_rating_mbps,
12};
13
14const LATENCY_WIDTH: usize = 10; const JITTER_WIDTH: usize = 10; const LOSS_WIDTH: usize = 8; const SPEED_WIDTH: usize = 14; const DATA_SIZE_WIDTH: usize = 10; const DURATION_WIDTH: usize = 8; #[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum LayoutMode {
26 Compact,
27 Standard,
28 Expanded,
29}
30
31impl LayoutMode {
32 #[must_use]
33 pub fn detect() -> Self {
34 let width = crate::common::get_terminal_width().unwrap_or(100);
35 if width < 80 {
36 Self::Compact
37 } else if width < 100 {
38 Self::Standard
39 } else {
40 Self::Expanded
41 }
42 }
43}
44
45fn section_header(title: &str, nc: bool) -> String {
47 if nc {
48 format!("\n {title}")
49 } else {
50 format!("\n {}", title.bold().underline())
51 }
52}
53
54fn build_skipped_line(label: &str, nc: bool) -> String {
55 if nc {
56 format!(" {label:>14}: — (skipped)")
57 } else {
58 format!(
59 " {:>14}: {}",
60 label.dimmed(),
61 "— (skipped)".bright_black()
62 )
63 }
64}
65
66fn build_speed_section(
67 label: &str,
68 speed_bps: f64,
69 _bytes: bool,
70 nc: bool,
71 theme: Theme,
72) -> String {
73 let speed_tabular = common::format_speed_tabular(speed_bps, SPEED_WIDTH);
74 let rating = colorize_rating(speed_rating_mbps(speed_bps / 1_000_000.0), nc, theme);
75 let bar = crate::common::bar_chart(speed_bps / 1_000_000.0, 1000.0, 28);
76 let bar_display = if nc {
77 bar
78 } else {
79 let fill_pct = (speed_bps / 1_000_000.0 / 1000.0).clamp(0.0, 1.0) * 100.0;
80 if fill_pct >= 70.0 {
81 Colors::good(&bar, theme)
82 } else if fill_pct >= 40.0 {
83 Colors::warn(&bar, theme)
84 } else {
85 Colors::bad(&bar, theme)
86 }
87 };
88 if nc {
89 format!(" {label:>14}: {speed_tabular} {bar_display}")
90 } else {
91 let speed_colored = {
92 let fill_pct = (speed_bps / 1_000_000.0 / 1000.0).clamp(0.0, 1.0) * 100.0;
93 if fill_pct >= 70.0 {
94 Colors::good(speed_tabular.trim(), theme)
95 } else if fill_pct >= 40.0 {
96 Colors::warn(speed_tabular.trim(), theme)
97 } else {
98 Colors::bad(speed_tabular.trim(), theme)
99 }
100 };
101 format!(
102 " {:>14}: {:>SPEED_WIDTH$} {bar_display} {rating}",
103 Colors::dimmed(label, theme),
104 speed_colored,
105 )
106 }
107}
108
109fn build_peak_line(peak_bps: f64, _bytes: bool, nc: bool, theme: Theme) -> String {
110 let peak_tabular = common::format_speed_tabular(peak_bps, SPEED_WIDTH);
111 let peak = if nc {
112 peak_tabular
113 } else {
114 Colors::dimmed(peak_tabular.trim(), theme)
115 };
116 if nc {
117 format!(" {:>14}: {peak}", "Peak (1s avg)")
118 } else {
119 format!(" {:>14}: {peak}", "Peak (1s avg)".dimmed())
120 }
121}
122
123fn build_latency_load_line(
124 lat_load: f64,
125 idle_ping: Option<f64>,
126 nc: bool,
127 theme: Theme,
128) -> String {
129 let degradation = degradation_str(lat_load, idle_ping, nc, theme);
130 let lat_val = common::format_latency_tabular(lat_load, LATENCY_WIDTH);
131 if nc {
132 format!(" {:>14}: {lat_val}{degradation}", "Latency (load)")
133 } else {
134 format!(
135 " {:>14}: {}{degradation}",
136 "Latency (load)".dimmed(),
137 Colors::warn(lat_val.trim(), theme),
138 )
139 }
140}
141
142#[must_use]
143pub fn build_latency_section(result: &TestResult, nc: bool, theme: Theme) -> String {
144 let Some(ping) = result.ping else {
145 return String::new();
146 };
147
148 let mut lines = Vec::new();
149
150 let rating_str = colorize_rating(ping_rating(ping), nc, theme);
151 let latency_val = common::format_latency_tabular(ping, LATENCY_WIDTH);
152 if nc {
153 lines.push(format!(
154 " {:>14}: {latency_val} ({rating_str})",
155 "Latency"
156 ));
157 } else {
158 lines.push(format!(
159 " {:>14}: {} {rating_str}",
160 "Latency".dimmed(),
161 Colors::info(latency_val.trim(), theme),
162 ));
163 }
164
165 if let Some(jitter) = result.jitter {
166 let jitter_val = common::format_jitter_tabular(jitter, JITTER_WIDTH);
167 lines.push(format!(" {:>14}: {jitter_val}", "Jitter".dimmed()));
168 }
169
170 if let Some(loss) = result.packet_loss {
171 let loss_str = if nc || terminal::no_emoji() {
172 common::format_loss_tabular(loss, LOSS_WIDTH)
173 } else {
174 let loss_val = common::format_loss_tabular(loss, LOSS_WIDTH);
175 if loss == 0.0 {
176 Colors::good(loss_val.trim(), theme)
177 } else if loss < 1.0 {
178 Colors::warn(loss_val.trim(), theme)
179 } else {
180 Colors::bad(loss_val.trim(), theme)
181 }
182 };
183 lines.push(format!(" {:>14}: {loss_str}", "Packet Loss".dimmed()));
184 }
185
186 if let (Some(lat_dl), Some(lat_ul)) = (result.latency_download, result.latency_upload) {
187 let max_load = lat_dl.max(lat_ul);
188 let (grade, added) = bufferbloat_grade(max_load, result.ping.unwrap_or(0.0));
189 let display = bufferbloat_colorized(grade, added, nc, theme);
190 lines.push(format!(" {:>14}: {display}", "Bufferbloat".dimmed()));
191 }
192
193 lines.join("\n")
194}
195
196pub fn format_latency_section(result: &TestResult, nc: bool, theme: Theme) {
197 let output = build_latency_section(result, nc, theme);
198 if !output.is_empty() {
199 eprintln!("{output}");
200 }
201}
202
203#[must_use]
204pub fn build_download_section(
205 result: &TestResult,
206 bytes: bool,
207 nc: bool,
208 skipped: bool,
209 theme: Theme,
210) -> String {
211 let Some(dl) = result.download else {
212 if skipped {
213 return build_skipped_line("Download", nc);
214 }
215 return String::new();
216 };
217
218 let mut lines = Vec::new();
219 lines.push(build_speed_section("Download", dl, bytes, nc, theme));
220
221 if let Some(peak) = result.download_peak {
222 lines.push(build_peak_line(peak, bytes, nc, theme));
223 }
224
225 if let Some(lat_dl) = result.latency_download {
226 lines.push(build_latency_load_line(lat_dl, result.ping, nc, theme));
227 }
228
229 if let Some(cv) = result.download_cv {
230 let cv_pct = cv * 100.0;
231 let stability = if cv_pct < 5.0 {
232 "stable"
233 } else if cv_pct < 15.0 {
234 "variable"
235 } else {
236 "unstable"
237 };
238 if nc {
239 lines.push(format!(
240 " {:>14}: ±{cv_pct:.1}% ({stability})",
241 "Variance"
242 ));
243 } else {
244 let cv_display = format!("{cv_pct:.1}");
245 let cv_color = if cv_pct < 5.0 {
246 Colors::good(&cv_display, theme)
247 } else if cv_pct < 15.0 {
248 Colors::warn(&cv_display, theme)
249 } else {
250 Colors::bad(&cv_display, theme)
251 };
252 lines.push(format!(
253 " {:>14}: ±{}% ({stability})",
254 "Variance".dimmed(),
255 cv_color
256 ));
257 }
258 }
259
260 lines.join("\n")
261}
262
263pub fn format_download_section(
264 result: &TestResult,
265 bytes: bool,
266 nc: bool,
267 skipped: bool,
268 theme: Theme,
269) {
270 let output = build_download_section(result, bytes, nc, skipped, theme);
271 if !output.is_empty() {
272 eprintln!("{output}");
273 }
274}
275
276#[must_use]
277pub fn build_upload_section(
278 result: &TestResult,
279 bytes: bool,
280 nc: bool,
281 skipped: bool,
282 theme: Theme,
283) -> String {
284 let Some(ul) = result.upload else {
285 if skipped {
286 return build_skipped_line("Upload", nc);
287 }
288 return String::new();
289 };
290
291 let mut lines = Vec::new();
292 lines.push(build_speed_section("Upload", ul, bytes, nc, theme));
293
294 if let Some(peak) = result.upload_peak {
295 lines.push(build_peak_line(peak, bytes, nc, theme));
296 }
297
298 if let Some(lat_ul) = result.latency_upload {
299 lines.push(build_latency_load_line(lat_ul, result.ping, nc, theme));
300 }
301
302 if let Some(cv) = result.upload_cv {
303 let cv_pct = cv * 100.0;
304 let stability = if cv_pct < 5.0 {
305 "stable"
306 } else if cv_pct < 15.0 {
307 "variable"
308 } else {
309 "unstable"
310 };
311 if nc {
312 lines.push(format!(
313 " {:>14}: ±{cv_pct:.1}% ({stability})",
314 "Variance"
315 ));
316 } else {
317 let cv_display = format!("{cv_pct:.1}");
318 let cv_color = if cv_pct < 5.0 {
319 Colors::good(&cv_display, theme)
320 } else if cv_pct < 15.0 {
321 Colors::warn(&cv_display, theme)
322 } else {
323 Colors::bad(&cv_display, theme)
324 };
325 lines.push(format!(
326 " {:>14}: ±{}% ({stability})",
327 "Variance".dimmed(),
328 cv_color
329 ));
330 }
331 }
332
333 if let (Some(dl), Some(ul)) = (result.download, result.upload) {
334 let ratio = if ul > 0.0 { dl / ul } else { f64::INFINITY };
335 let ratio_str = if nc {
336 format!("{ratio:.2}x")
337 } else {
338 let label = if ratio > 1.5 {
339 "download-heavy"
340 } else if ratio < 0.67 {
341 "upload-favored"
342 } else {
343 "balanced"
344 };
345 let text = format!("{ratio:.2}x {label}");
346 if ratio > 1.5 {
347 Colors::warn(&text, theme)
348 } else if ratio < 0.67 {
349 Colors::info(&text, theme)
350 } else {
351 Colors::good(&text, theme)
352 }
353 };
354 lines.push(format!(" {:>14}: {ratio_str}", "UL/DL Ratio".dimmed()));
355 }
356
357 lines.join("\n")
358}
359
360pub fn format_upload_section(
361 result: &TestResult,
362 bytes: bool,
363 nc: bool,
364 skipped: bool,
365 theme: Theme,
366) {
367 let output = build_upload_section(result, bytes, nc, skipped, theme);
368 if !output.is_empty() {
369 eprintln!("{output}");
370 }
371}
372
373#[must_use]
374pub fn build_connection_info(result: &TestResult, nc: bool, theme: Theme) -> String {
375 let dist = common::format_distance(result.server.distance);
376 let mut lines = Vec::new();
377
378 lines.push(section_header("CONNECTION INFO", nc));
379
380 if nc {
381 lines.push(format!(
382 " {:>16}: {} ({})",
383 "Server", result.server.sponsor, result.server.name
384 ));
385 } else {
386 lines.push(format!(
387 " {:>16}: {} ({})",
388 "Server".dimmed(),
389 Colors::bold(&result.server.sponsor, theme),
390 result.server.name
391 ));
392 }
393
394 if nc {
395 lines.push(format!(
396 " {:>16}: {} ({dist})",
397 "Location", result.server.country
398 ));
399 } else {
400 lines.push(format!(
401 " {:>16}: {} ({dist})",
402 "Location".dimmed(),
403 result.server.country,
404 ));
405 }
406
407 if let Some(ip) = &result.client_ip {
408 lines.push(format!(" {:>16}: {ip}", "Client IP".dimmed()));
409 }
410
411 lines.join("\n")
412}
413
414pub fn format_connection_info(result: &TestResult, nc: bool, theme: Theme) {
415 eprintln!("{}", build_connection_info(result, nc, theme));
416}
417
418#[must_use]
419pub fn build_test_summary(
420 dl_bytes: u64,
421 ul_bytes: u64,
422 dl_duration: f64,
423 ul_duration: f64,
424 nc: bool,
425) -> String {
426 let mut lines = Vec::new();
427
428 lines.push(section_header("TEST SUMMARY", nc));
429
430 if dl_bytes > 0 {
431 let size_val = common::format_data_size_tabular(dl_bytes, DATA_SIZE_WIDTH);
432 let dur_val = common::format_duration_tabular(dl_duration, DURATION_WIDTH);
433 let dur_display = if nc {
434 dur_val
435 } else {
436 dur_val.dimmed().to_string()
437 };
438 lines.push(format!(
439 " {:>14}: {size_val} in {dur_display}",
440 "Download"
441 ));
442 }
443 if ul_bytes > 0 {
444 let size_val = common::format_data_size_tabular(ul_bytes, DATA_SIZE_WIDTH);
445 let dur_val = common::format_duration_tabular(ul_duration, DURATION_WIDTH);
446 let dur_display = if nc {
447 dur_val
448 } else {
449 dur_val.dimmed().to_string()
450 };
451 lines.push(format!(" {:>14}: {size_val} in {dur_display}", "Upload"));
452 }
453 let total = dl_bytes + ul_bytes;
454 let total_dur = dl_duration + ul_duration;
455 if total > 0 {
456 let size_val = common::format_data_size_tabular(total, DATA_SIZE_WIDTH);
457 let dur_val = common::format_duration_tabular(total_dur, DURATION_WIDTH);
458 let size_display = if nc {
459 size_val
460 } else {
461 size_val.bold().to_string()
462 };
463 let dur_display = if nc {
464 dur_val
465 } else {
466 dur_val.dimmed().to_string()
467 };
468 lines.push(format!(
469 " {:>14}: {size_display} in {dur_display}",
470 "Total"
471 ));
472 }
473
474 lines.join("\n")
475}
476
477pub fn format_test_summary(
478 dl_bytes: u64,
479 ul_bytes: u64,
480 dl_duration: f64,
481 ul_duration: f64,
482 nc: bool,
483) {
484 eprintln!(
485 "{}",
486 build_test_summary(dl_bytes, ul_bytes, dl_duration, ul_duration, nc)
487 );
488}
489
490#[must_use]
491pub fn build_footer(timestamp: &str, nc: bool, theme: Theme) -> String {
492 if nc {
493 format!("\n Completed at: {timestamp}")
494 } else {
495 format!(
496 "\n {} {}",
497 "Completed at:".dimmed(),
498 Colors::muted(timestamp, theme),
499 )
500 }
501}
502
503pub fn format_footer(timestamp: &str, nc: bool, theme: Theme) {
504 eprintln!("{}", build_footer(timestamp, nc, theme));
505}
506
507#[must_use]
508pub fn build_elapsed_time(elapsed: std::time::Duration, nc: bool, theme: Theme) -> String {
509 let secs = elapsed.as_secs_f64();
510 let time_val = common::format_duration_tabular(secs, DURATION_WIDTH);
511 if nc {
512 format!("\n Total time: {time_val}")
513 } else {
514 format!(
515 "\n {} {}",
516 "Total time:".dimmed(),
517 Colors::info(time_val.trim(), theme)
518 )
519 }
520}
521
522pub fn format_elapsed_time(elapsed: std::time::Duration, nc: bool, theme: Theme) {
523 eprintln!("{}", build_elapsed_time(elapsed, nc, theme));
524}
525
526#[must_use]
528pub fn build_list(servers: &[Server]) -> String {
529 let nc = terminal::no_color();
530
531 let (max_id_len, max_sponsor_len, max_name_len) =
532 servers
533 .iter()
534 .fold((3, 7, 24), |(max_id, max_sponsor, max_name), s| {
535 let name_len = s.name.len() + s.country.len() + 3;
536 (
537 max_id.max(s.id.len()),
538 max_sponsor.max(s.sponsor.len()),
539 max_name.max(name_len),
540 )
541 });
542
543 let idw = max_id_len.max(3);
544 let sw = max_sponsor_len.max(7);
545 let nw = max_name_len.max(24);
546
547 let mut lines = Vec::new();
548
549 if nc {
550 lines.push(String::from("\n AVAILABLE SERVERS"));
551 } else {
552 lines.push(format!("\n {}", "AVAILABLE SERVERS".bold().underline()));
553 }
554
555 if nc {
556 lines.push(format!(
557 " {:<idw$} {:<sw$} {:<nw$} {:>10}",
558 "ID", "Sponsor", "Name (Country)", "Distance"
559 ));
560 } else {
561 lines.push(format!(
562 " {:<idw$} {:<sw$} {:<nw$} {:>10}",
563 "ID".dimmed(),
564 "Sponsor".dimmed(),
565 "Name (Country)".dimmed(),
566 "Distance".dimmed()
567 ));
568 }
569
570 if nc {
571 lines.push(format!(
572 " {:->idw$} {:->sw$} {:->nw$} {:->10}",
573 "", "", "", ""
574 ));
575 } else {
576 lines.push(format!(
577 " {:->idw$} {:->sw$} {:->nw$} {:->10}",
578 "",
579 "",
580 "",
581 "".dimmed()
582 ));
583 }
584
585 for server in servers {
586 let dist = common::format_distance(server.distance);
587 if nc {
588 lines.push(format!(
589 " {:<idw$} {:<sw$} {:<24} {:>10}",
590 server.id,
591 server.sponsor,
592 format!("{} ({})", server.name, server.country),
593 dist,
594 ));
595 } else {
596 lines.push(format!(
597 " {:<idw$} {:<sw$} {:<24} {:>10}",
598 server.id,
599 server.sponsor.white().bold(),
600 format!("{} ({})", server.name, server.country),
601 dist.bright_black(),
602 ));
603 }
604 }
605
606 lines.join("\n")
607}
608
609pub fn format_list(servers: &[Server]) -> Result<(), std::io::Error> {
616 eprintln!("{}", build_list(servers));
617 Ok(())
618}