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, 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        // Pad label first, then colorize (ANSI-safe alignment)
94        let lbl = Colors::dimmed(&format!("{label:>14}"), theme);
95        // speed_tabular is pre-padded to SPEED_WIDTH; colorize without trimming
96        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/// Format a list of available servers.
544#[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
629/// Format a list of available servers.
630///
631/// # Errors
632///
633/// This function does not currently return errors, but the signature is
634/// `Result` for future extensibility.
635pub fn format_list(servers: &[Server], theme: Theme) -> Result<(), std::io::Error> {
636    eprintln!("{}", build_list(servers, theme));
637    Ok(())
638}