Skip to main content

netspeed_cli/formatter/
sections.rs

1//! Output section formatters for detailed test results.
2
3use 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
14// ── Tabular Column Widths ────────────────────────────────────────────────────
15const LATENCY_WIDTH: usize = 10; // "    12.1 ms"
16const JITTER_WIDTH: usize = 10; // "     1.5 ms"
17const LOSS_WIDTH: usize = 8; // "     0.0%"
18const SPEED_WIDTH: usize = 14; // "    150.00 Mb/s"
19const DATA_SIZE_WIDTH: usize = 10; // "    15.0 MB"
20const DURATION_WIDTH: usize = 8; // "    30.5s"
21
22// ── Layout Mode ──────────────────────────────────────────────────────────────
23
24#[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
45/// Build a section header with consistent formatting.
46fn 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/// Format a list of available servers.
527#[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
609/// Format a list of available servers.
610///
611/// # Errors
612///
613/// This function does not currently return errors, but the signature is
614/// `Result` for future extensibility.
615pub fn format_list(servers: &[Server]) -> Result<(), std::io::Error> {
616    eprintln!("{}", build_list(servers));
617    Ok(())
618}