Skip to main content

netspeed_cli/formatter/
sections.rs

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