Skip to main content

netspeed_cli/formatter/
sections.rs

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