Skip to main content

netspeed_cli/formatter/
dashboard.rs

1//! Dashboard output format — rich boxed layout with bar charts, history sparkline, and key hints.
2//!
3//! This module provides a visually rich single-screen output suitable for
4//! terminal display. It uses only existing dependencies (`owo_colors`, `common`,
5//! `history`, `ratings`) — no new crates.
6
7use crate::common;
8use crate::formatter::ratings;
9use crate::history;
10use crate::progress::no_color;
11use crate::types::TestResult;
12use owo_colors::OwoColorize;
13
14const BOX_WIDTH: usize = 60;
15const BAR_WIDTH: usize = 28;
16
17/// Render a horizontal bar scaled to the metric's typical range.
18fn metric_bar(value: f64, max: f64, width: usize, nc: bool) -> String {
19    let bar = common::bar_chart(value, max, width);
20    if nc {
21        bar
22    } else {
23        let fill_pct = (value / max).clamp(0.0, 1.0) * 100.0;
24        if fill_pct >= 70.0 {
25            bar.green().to_string()
26        } else if fill_pct >= 40.0 {
27            bar.yellow().to_string()
28        } else {
29            bar.red().to_string()
30        }
31    }
32}
33
34/// Summary data extracted from test runs for dashboard display.
35pub struct DashboardSummary {
36    pub dl_mbps: f64,
37    pub dl_peak_mbps: f64,
38    pub dl_bytes: u64,
39    pub dl_duration: f64,
40    pub ul_mbps: f64,
41    pub ul_peak_mbps: f64,
42    pub ul_bytes: u64,
43    pub ul_duration: f64,
44}
45
46/// Build a section separator line.
47fn section_divider(title: &str, nc: bool) -> String {
48    let title_with_spaces = format!(" {title} ");
49    let dash_count = BOX_WIDTH.saturating_sub(title_with_spaces.len() + 4);
50    let dashes = "─".repeat(dash_count);
51    if nc {
52        format!("  {title_with_spaces}{dashes}")
53    } else {
54        format!("  {}", title_with_spaces.dimmed()) + &dashes.dimmed().to_string()
55    }
56}
57
58/// Build the boxed header with version, server, and client IP.
59fn build_header(result: &TestResult, nc: bool) -> String {
60    let version = env!("CARGO_PKG_VERSION");
61    let title = format!(" netspeed-cli v{version} ");
62    let half_pad = (BOX_WIDTH.saturating_sub(title.len())) / 2;
63    let left_pad = "═".repeat(half_pad);
64    let right_pad = "═".repeat(BOX_WIDTH.saturating_sub(half_pad + title.len()));
65    let title_line = format!("{left_pad}{title}{right_pad}");
66
67    let server_line = format!(
68        "  Server: {} ({}) · {} · {}",
69        result.server.sponsor,
70        result.server.name,
71        result.server.country,
72        common::format_distance(result.server.distance)
73    );
74
75    let ip_line = result
76        .client_ip
77        .as_ref()
78        .map(|ip| format!("  Client IP: {ip}"));
79
80    let mut lines = Vec::new();
81
82    // Top border
83    if nc {
84        lines.push(format!("╔{title_line}╗"));
85    } else {
86        lines.push(format!("╔{title_line}╗").dimmed().to_string());
87    }
88
89    // Server info
90    let padded_server = format!("{server_line:<BOX_WIDTH$}");
91    if nc {
92        lines.push(format!("║{padded_server}║"));
93    } else {
94        lines.push(format!("║{padded_server}║").dimmed().to_string());
95    }
96
97    // Client IP (if available)
98    if let Some(ip) = ip_line {
99        let padded_ip = format!("{ip:<BOX_WIDTH$}");
100        if nc {
101            lines.push(format!("║{padded_ip}║"));
102        } else {
103            lines.push(format!("║{padded_ip}║").dimmed().to_string());
104        }
105    }
106
107    // Bottom border
108    if nc {
109        lines.push(format!("╚{:═<BOX_WIDTH$}╝", ""));
110    } else {
111        lines.push(format!("╚{:═<BOX_WIDTH$}╝", "").dimmed().to_string());
112    }
113
114    lines.join("\n")
115}
116
117/// Build the overall connection rating line.
118fn build_overall_rating(result: &TestResult, nc: bool) -> String {
119    let rating = ratings::connection_rating(result);
120    if nc {
121        format!("  Overall: {rating}")
122    } else {
123        let rating_colored = ratings::colorize_rating(rating, nc);
124        format!("  {} {rating_colored}", "Overall:".dimmed())
125    }
126}
127
128/// Build metric lines with bar charts for latency, download, and upload.
129fn build_metric_bars(result: &TestResult, nc: bool) -> String {
130    let mut lines = Vec::new();
131
132    // Latency bar (0–100 ms scale, direct: shorter bar = lower ping = better)
133    if let Some(ping) = result.ping {
134        let rating = ratings::ping_rating(ping);
135        // Direct scale: 0ms = empty bar, 100ms = full bar
136        let bar = metric_bar(ping, 100.0, BAR_WIDTH, nc);
137        if nc {
138            lines.push(format!(
139                "  {:<14} {}  {:>8.1} ms  ({rating})",
140                "Latency", bar, ping
141            ));
142        } else {
143            let ping_str = format!("{ping:.1} ms");
144            let rating_str = ratings::colorize_rating(rating, nc);
145            lines.push(format!(
146                "  {:<14} {}  {}  {}",
147                "Latency".dimmed(),
148                bar,
149                ping_str.cyan().bold(),
150                rating_str,
151            ));
152        }
153    }
154
155    // Download bar (0–1000 Mbps scale)
156    if let Some(dl) = result.download {
157        let dl_mbps = dl / 1_000_000.0;
158        let rating = ratings::speed_rating_mbps(dl_mbps);
159        let bar = metric_bar(dl_mbps, 1000.0, BAR_WIDTH, nc);
160        if nc {
161            lines.push(format!(
162                "  {:<14} {}  {:>8.2} Mb/s  ({rating})",
163                "Download", bar, dl_mbps
164            ));
165        } else {
166            let speed_str = format!("{dl_mbps:.2} Mb/s");
167            let colored_speed = if dl_mbps >= 200.0 {
168                speed_str.green().bold().to_string()
169            } else if dl_mbps >= 50.0 {
170                speed_str.bright_green().to_string()
171            } else if dl_mbps >= 25.0 {
172                speed_str.yellow().to_string()
173            } else {
174                speed_str.red().to_string()
175            };
176            lines.push(format!(
177                "  {:<14} {}  {}  {}",
178                "Download".dimmed(),
179                bar,
180                colored_speed,
181                ratings::colorize_rating(rating, nc),
182            ));
183        }
184    }
185
186    // Upload bar (0–1000 Mbps scale)
187    if let Some(ul) = result.upload {
188        let ul_mbps = ul / 1_000_000.0;
189        let rating = ratings::speed_rating_mbps(ul_mbps);
190        let bar = metric_bar(ul_mbps, 1000.0, BAR_WIDTH, nc);
191        if nc {
192            lines.push(format!(
193                "  {:<14} {}  {:>8.2} Mb/s  ({rating})",
194                "Upload", bar, ul_mbps
195            ));
196        } else {
197            let speed_str = format!("{ul_mbps:.2} Mb/s");
198            let colored_speed = if ul_mbps >= 200.0 {
199                speed_str.green().bold().to_string()
200            } else if ul_mbps >= 50.0 {
201                speed_str.bright_green().to_string()
202            } else if ul_mbps >= 25.0 {
203                speed_str.yellow().to_string()
204            } else {
205                speed_str.red().to_string()
206            };
207            lines.push(format!(
208                "  {:<14} {}  {}  {}",
209                "Upload".dimmed(),
210                bar,
211                colored_speed,
212                ratings::colorize_rating(rating, nc),
213            ));
214        }
215    }
216
217    lines.join("\n")
218}
219
220/// Build the download summary section.
221fn build_download_summary(summary: &DashboardSummary, nc: bool) -> String {
222    if summary.dl_duration <= 0.0 {
223        return String::new();
224    }
225
226    let mut lines = Vec::new();
227    lines.push(section_divider("Download Summary", nc));
228
229    let bar = metric_bar(summary.dl_mbps, 1000.0, BAR_WIDTH, nc);
230    if nc {
231        lines.push(format!(
232            "  {:<14} {:>8.2} Mb/s  {bar}",
233            "Speed:", summary.dl_mbps
234        ));
235    } else {
236        lines.push(format!(
237            "  {:<14} {}  {}",
238            "Speed:".dimmed(),
239            format!("{:.2} Mb/s", summary.dl_mbps).cyan().bold(),
240            bar,
241        ));
242    }
243
244    if summary.dl_peak_mbps > 0.0 {
245        if nc {
246            lines.push(format!(
247                "  {:<14} {:.2} Mb/s",
248                "Peak:", summary.dl_peak_mbps
249            ));
250        } else {
251            lines.push(format!(
252                "  {:<14} {}",
253                "Peak:".dimmed(),
254                format!("{:.2} Mb/s", summary.dl_peak_mbps).bright_cyan(),
255            ));
256        }
257    }
258
259    if nc {
260        lines.push(format!("  {:<14} {:.1}s", "Duration:", summary.dl_duration));
261    } else {
262        lines.push(format!(
263            "  {:<14} {}",
264            "Duration:".dimmed(),
265            format!("{:.1}s", summary.dl_duration).white(),
266        ));
267    }
268
269    if nc {
270        lines.push(format!(
271            "  {:<14} {}",
272            "Transferred:",
273            common::format_data_size(summary.dl_bytes)
274        ));
275    } else {
276        lines.push(format!(
277            "  {:<14} {}",
278            "Transferred:".dimmed(),
279            common::format_data_size(summary.dl_bytes).white(),
280        ));
281    }
282
283    lines.join("\n")
284}
285
286/// Build the upload summary section.
287fn build_upload_summary(summary: &DashboardSummary, nc: bool) -> String {
288    if summary.ul_duration <= 0.0 {
289        return String::new();
290    }
291
292    let mut lines = Vec::new();
293    lines.push(section_divider("Upload Summary", nc));
294
295    let bar = metric_bar(summary.ul_mbps, 1000.0, BAR_WIDTH, nc);
296    if nc {
297        lines.push(format!(
298            "  {:<14} {:>8.2} Mb/s  {bar}",
299            "Speed:", summary.ul_mbps
300        ));
301    } else {
302        lines.push(format!(
303            "  {:<14} {}  {}",
304            "Speed:".dimmed(),
305            format!("{:.2} Mb/s", summary.ul_mbps).yellow().bold(),
306            bar,
307        ));
308    }
309
310    if summary.ul_peak_mbps > 0.0 {
311        if nc {
312            lines.push(format!(
313                "  {:<14} {:.2} Mb/s",
314                "Peak:", summary.ul_peak_mbps
315            ));
316        } else {
317            lines.push(format!(
318                "  {:<14} {}",
319                "Peak:".dimmed(),
320                format!("{:.2} Mb/s", summary.ul_peak_mbps).bright_yellow(),
321            ));
322        }
323    }
324
325    if nc {
326        lines.push(format!("  {:<14} {:.1}s", "Duration:", summary.ul_duration));
327    } else {
328        lines.push(format!(
329            "  {:<14} {}",
330            "Duration:".dimmed(),
331            format!("{:.1}s", summary.ul_duration).white(),
332        ));
333    }
334
335    if nc {
336        lines.push(format!(
337            "  {:<14} {}",
338            "Transferred:",
339            common::format_data_size(summary.ul_bytes)
340        ));
341    } else {
342        lines.push(format!(
343            "  {:<14} {}",
344            "Transferred:".dimmed(),
345            common::format_data_size(summary.ul_bytes).white(),
346        ));
347    }
348
349    lines.join("\n")
350}
351
352/// Build history section with sparkline.
353fn build_history(nc: bool) -> String {
354    let recent = history::get_recent_sparkline();
355    if recent.is_empty() {
356        if nc {
357            return String::from("  History:  No history available");
358        }
359        return format!(
360            "  {} {}",
361            "History:".dimmed(),
362            "No history available".bright_black()
363        );
364    }
365
366    let mut lines = Vec::new();
367    lines.push(section_divider("History", nc));
368
369    // Build sparkline from download and upload speeds
370    let dl_values: Vec<f64> = recent.iter().map(|(_, dl, _)| *dl).collect();
371    let ul_values: Vec<f64> = recent.iter().map(|(_, _, ul)| *ul).collect();
372
373    let dl_spark = history::sparkline(&dl_values);
374    let ul_spark = history::sparkline(&ul_values);
375
376    if nc {
377        lines.push(format!("  DL sparkline:  {dl_spark}"));
378        lines.push(format!("  UL sparkline:  {ul_spark}"));
379    } else {
380        lines.push(format!("  {} {}", "DL:".dimmed(), dl_spark.green()));
381        lines.push(format!("  {} {}", "UL:".dimmed(), ul_spark.yellow()));
382    }
383
384    // Last 3 entries as text
385    for (date, dl, ul) in recent.iter().rev().take(3) {
386        let indicator = if *dl >= 200.0 {
387            "⚡"
388        } else if *dl >= 50.0 {
389            "●"
390        } else if *dl >= 25.0 {
391            "◐"
392        } else {
393            "○"
394        };
395        if nc {
396            lines.push(format!("  {date}  {dl:>7.1}↓ / {ul:>6.1}↑ Mb/s"));
397        } else {
398            let indicator_colored = if *dl >= 200.0 {
399                indicator.green().to_string()
400            } else if *dl >= 50.0 {
401                indicator.bright_green().to_string()
402            } else if *dl >= 25.0 {
403                indicator.yellow().to_string()
404            } else {
405                indicator.red().to_string()
406            };
407            lines.push(format!(
408                "  {date}  {indicator_colored} {}↓ / {}↑ Mb/s",
409                format!("{dl:.1}").green(),
410                format!("{ul:.1}").yellow(),
411            ));
412        }
413    }
414
415    lines.join("\n")
416}
417
418/// Build footer with keyboard hints (informational — dashboard is static output).
419fn build_footer() -> String {
420    format!(
421        "  {}",
422        "Tip: Use --list to see servers, --history for full history".bright_black()
423    )
424}
425
426/// Format the full dashboard output.
427///
428/// # Errors
429///
430/// This function does not currently return errors, but the signature is
431/// `Result` for future extensibility.
432pub fn format_dashboard(
433    result: &TestResult,
434    summary: &DashboardSummary,
435) -> Result<(), crate::error::SpeedtestError> {
436    let nc = no_color();
437
438    eprintln!();
439    eprintln!("{}", build_header(result, nc));
440    eprintln!();
441    eprintln!("{}", build_overall_rating(result, nc));
442    eprintln!();
443    eprintln!("{}", build_metric_bars(result, nc));
444    eprintln!();
445    let dl_summary = build_download_summary(summary, nc);
446    if !dl_summary.is_empty() {
447        eprintln!("{dl_summary}");
448        eprintln!();
449    }
450    let ul_summary = build_upload_summary(summary, nc);
451    if !ul_summary.is_empty() {
452        eprintln!("{ul_summary}");
453        eprintln!();
454    }
455    eprintln!("{}", build_history(nc));
456    eprintln!();
457    eprintln!("{}", build_footer());
458    eprintln!();
459
460    Ok(())
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use crate::types::ServerInfo;
467
468    fn make_result() -> TestResult {
469        TestResult {
470            server: ServerInfo {
471                id: "1".to_string(),
472                name: "TestServer".to_string(),
473                sponsor: "TestISP".to_string(),
474                country: "US".to_string(),
475                distance: 15.0,
476            },
477            ping: Some(12.0),
478            jitter: Some(1.5),
479            packet_loss: Some(0.0),
480            download: Some(150_000_000.0),
481            download_peak: Some(180_000_000.0),
482            upload: Some(50_000_000.0),
483            upload_peak: Some(60_000_000.0),
484            latency_download: Some(18.0),
485            latency_upload: Some(15.0),
486            download_samples: Some(vec![140_000_000.0, 150_000_000.0, 160_000_000.0]),
487            upload_samples: Some(vec![48_000_000.0, 50_000_000.0, 52_000_000.0]),
488            ping_samples: Some(vec![11.0, 12.0, 13.0]),
489            timestamp: "2026-04-06T12:00:00Z".to_string(),
490            client_ip: Some("192.168.1.100".to_string()),
491        }
492    }
493
494    #[test]
495    fn test_metric_bar_half() {
496        let bar = metric_bar(500.0, 1000.0, 20, true);
497        assert_eq!(bar.chars().count(), 20);
498        // Half filled: 10█ + 10░
499        assert_eq!(bar, "██████████░░░░░░░░░░");
500    }
501
502    #[test]
503    fn test_metric_bar_full() {
504        let bar = metric_bar(1000.0, 1000.0, 10, true);
505        assert_eq!(bar, "██████████");
506    }
507
508    #[test]
509    fn test_build_header() {
510        let result = make_result();
511        let header = build_header(&result, true);
512        assert!(header.contains("netspeed-cli"));
513        assert!(header.contains("TestISP"));
514        assert!(header.contains("192.168.1.100"));
515        // Verify box structure
516        assert!(header.starts_with("╔"));
517        assert!(header.contains("╚"));
518    }
519
520    #[test]
521    fn test_build_metric_bars() {
522        let result = make_result();
523        let bars = build_metric_bars(&result, true);
524        assert!(bars.contains("Latency"));
525        assert!(bars.contains("Download"));
526        assert!(bars.contains("Upload"));
527        assert!(bars.contains("█"));
528    }
529
530    #[test]
531    fn test_build_overall_rating() {
532        let result = make_result();
533        let rating = build_overall_rating(&result, true);
534        assert!(rating.contains("Overall"));
535    }
536
537    #[test]
538    fn test_build_download_summary() {
539        let summary = DashboardSummary {
540            dl_mbps: 150.0,
541            dl_peak_mbps: 180.0,
542            dl_bytes: 15_000_000,
543            dl_duration: 3.2,
544            ul_mbps: 50.0,
545            ul_peak_mbps: 60.0,
546            ul_bytes: 5_000_000,
547            ul_duration: 2.1,
548        };
549        let result = build_download_summary(&summary, true);
550        assert!(result.contains("Download Summary"));
551        assert!(result.contains("Speed"));
552        assert!(result.contains("Peak"));
553        assert!(result.contains("150.00"));
554    }
555
556    #[test]
557    fn test_build_upload_summary() {
558        let summary = DashboardSummary {
559            dl_mbps: 150.0,
560            dl_peak_mbps: 180.0,
561            dl_bytes: 15_000_000,
562            dl_duration: 3.2,
563            ul_mbps: 50.0,
564            ul_peak_mbps: 60.0,
565            ul_bytes: 5_000_000,
566            ul_duration: 2.1,
567        };
568        let result = build_upload_summary(&summary, true);
569        assert!(result.contains("Upload Summary"));
570        assert!(result.contains("Speed"));
571        assert!(result.contains("50.00"));
572    }
573
574    #[test]
575    fn test_build_history_no_data() {
576        // History section renders regardless of actual data
577        let section = build_history(true);
578        // Should always contain the sparkline header
579        assert!(section.contains("History"));
580    }
581
582    #[test]
583    fn test_build_footer() {
584        let footer = build_footer();
585        assert!(footer.contains("--list"));
586        assert!(footer.contains("--history"));
587    }
588
589    #[test]
590    fn test_format_dashboard_integration() {
591        let result = make_result();
592        let summary = DashboardSummary {
593            dl_mbps: 150.0,
594            dl_peak_mbps: 180.0,
595            dl_bytes: 15_000_000,
596            dl_duration: 3.2,
597            ul_mbps: 50.0,
598            ul_peak_mbps: 60.0,
599            ul_bytes: 5_000_000,
600            ul_duration: 2.1,
601        };
602        // Should not panic
603        format_dashboard(&result, &summary).unwrap();
604    }
605
606    #[test]
607    fn test_format_dashboard_no_color() {
608        let result = make_result();
609        let summary = DashboardSummary {
610            dl_mbps: 150.0,
611            dl_peak_mbps: 180.0,
612            dl_bytes: 15_000_000,
613            dl_duration: 3.2,
614            ul_mbps: 50.0,
615            ul_peak_mbps: 60.0,
616            ul_bytes: 5_000_000,
617            ul_duration: 2.1,
618        };
619        // SAFETY: test context, no concurrent env access
620        #[allow(unsafe_code)]
621        unsafe {
622            std::env::set_var("NO_COLOR", "1");
623        }
624        format_dashboard(&result, &summary).unwrap();
625        // SAFETY: test context, no concurrent env access
626        #[allow(unsafe_code)]
627        unsafe {
628            std::env::remove_var("NO_COLOR");
629        }
630    }
631
632    #[test]
633    fn test_section_divider() {
634        let div = section_divider("Speed", true);
635        assert!(div.contains("Speed"));
636        assert!(div.contains("─"));
637    }
638}