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