use crate::common;
use crate::formatter::ratings;
use crate::history;
use crate::progress::no_color;
use crate::types::TestResult;
use owo_colors::OwoColorize;
const BOX_WIDTH: usize = 60;
const BAR_WIDTH: usize = 28;
fn metric_bar(value: f64, max: f64, width: usize, nc: bool) -> String {
let bar = common::bar_chart(value, max, width);
if nc {
bar
} else {
let fill_pct = (value / max).clamp(0.0, 1.0) * 100.0;
if fill_pct >= 70.0 {
bar.green().to_string()
} else if fill_pct >= 40.0 {
bar.yellow().to_string()
} else {
bar.red().to_string()
}
}
}
pub struct DashboardSummary {
pub dl_mbps: f64,
pub dl_peak_mbps: f64,
pub dl_bytes: u64,
pub dl_duration: f64,
pub ul_mbps: f64,
pub ul_peak_mbps: f64,
pub ul_bytes: u64,
pub ul_duration: f64,
}
fn section_divider(title: &str, nc: bool) -> String {
let title_with_spaces = format!(" {title} ");
let dash_count = BOX_WIDTH.saturating_sub(title_with_spaces.len() + 4);
let dashes = "─".repeat(dash_count);
if nc {
format!(" {title_with_spaces}{dashes}")
} else {
format!(" {}", title_with_spaces.dimmed()) + &dashes.dimmed().to_string()
}
}
fn build_header(result: &TestResult, nc: bool) -> String {
let version = env!("CARGO_PKG_VERSION");
let title = format!(" netspeed-cli v{version} ");
let half_pad = (BOX_WIDTH.saturating_sub(title.len())) / 2;
let left_pad = "═".repeat(half_pad);
let right_pad = "═".repeat(BOX_WIDTH.saturating_sub(half_pad + title.len()));
let title_line = format!("{left_pad}{title}{right_pad}");
let server_line = format!(
" Server: {} ({}) · {} · {}",
result.server.sponsor,
result.server.name,
result.server.country,
common::format_distance(result.server.distance)
);
let ip_line = result
.client_ip
.as_ref()
.map(|ip| format!(" Client IP: {ip}"));
let mut lines = Vec::new();
if nc {
lines.push(format!("╔{title_line}╗"));
} else {
lines.push(format!("╔{title_line}╗").dimmed().to_string());
}
let padded_server = format!("{server_line:<BOX_WIDTH$}");
if nc {
lines.push(format!("║{padded_server}║"));
} else {
lines.push(format!("║{padded_server}║").dimmed().to_string());
}
if let Some(ip) = ip_line {
let padded_ip = format!("{ip:<BOX_WIDTH$}");
if nc {
lines.push(format!("║{padded_ip}║"));
} else {
lines.push(format!("║{padded_ip}║").dimmed().to_string());
}
}
if nc {
lines.push(format!("╚{:═<BOX_WIDTH$}╝", ""));
} else {
lines.push(format!("╚{:═<BOX_WIDTH$}╝", "").dimmed().to_string());
}
lines.join("\n")
}
fn build_overall_rating(result: &TestResult, nc: bool) -> String {
let rating = ratings::connection_rating(result);
if nc {
format!(" Overall: {rating}")
} else {
let rating_colored = ratings::colorize_rating(rating, nc);
format!(" {} {rating_colored}", "Overall:".dimmed())
}
}
fn build_metric_bars(result: &TestResult, nc: bool) -> String {
let mut lines = Vec::new();
if let Some(ping) = result.ping {
let rating = ratings::ping_rating(ping);
let bar = metric_bar(ping, 100.0, BAR_WIDTH, nc);
if nc {
lines.push(format!(
" {:<14} {} {:>8.1} ms ({rating})",
"Latency", bar, ping
));
} else {
let ping_str = format!("{ping:.1} ms");
let rating_str = ratings::colorize_rating(rating, nc);
lines.push(format!(
" {:<14} {} {} {}",
"Latency".dimmed(),
bar,
ping_str.cyan().bold(),
rating_str,
));
}
}
if let Some(dl) = result.download {
let dl_mbps = dl / 1_000_000.0;
let rating = ratings::speed_rating_mbps(dl_mbps);
let bar = metric_bar(dl_mbps, 1000.0, BAR_WIDTH, nc);
if nc {
lines.push(format!(
" {:<14} {} {:>8.2} Mb/s ({rating})",
"Download", bar, dl_mbps
));
} else {
let speed_str = format!("{dl_mbps:.2} Mb/s");
let colored_speed = if dl_mbps >= 200.0 {
speed_str.green().bold().to_string()
} else if dl_mbps >= 50.0 {
speed_str.bright_green().to_string()
} else if dl_mbps >= 25.0 {
speed_str.yellow().to_string()
} else {
speed_str.red().to_string()
};
lines.push(format!(
" {:<14} {} {} {}",
"Download".dimmed(),
bar,
colored_speed,
ratings::colorize_rating(rating, nc),
));
}
}
if let Some(ul) = result.upload {
let ul_mbps = ul / 1_000_000.0;
let rating = ratings::speed_rating_mbps(ul_mbps);
let bar = metric_bar(ul_mbps, 1000.0, BAR_WIDTH, nc);
if nc {
lines.push(format!(
" {:<14} {} {:>8.2} Mb/s ({rating})",
"Upload", bar, ul_mbps
));
} else {
let speed_str = format!("{ul_mbps:.2} Mb/s");
let colored_speed = if ul_mbps >= 200.0 {
speed_str.green().bold().to_string()
} else if ul_mbps >= 50.0 {
speed_str.bright_green().to_string()
} else if ul_mbps >= 25.0 {
speed_str.yellow().to_string()
} else {
speed_str.red().to_string()
};
lines.push(format!(
" {:<14} {} {} {}",
"Upload".dimmed(),
bar,
colored_speed,
ratings::colorize_rating(rating, nc),
));
}
}
lines.join("\n")
}
fn build_download_summary(summary: &DashboardSummary, nc: bool) -> String {
if summary.dl_duration <= 0.0 {
return String::new();
}
let mut lines = Vec::new();
lines.push(section_divider("Download Summary", nc));
let bar = metric_bar(summary.dl_mbps, 1000.0, BAR_WIDTH, nc);
if nc {
lines.push(format!(
" {:<14} {:>8.2} Mb/s {bar}",
"Speed:", summary.dl_mbps
));
} else {
lines.push(format!(
" {:<14} {} {}",
"Speed:".dimmed(),
format!("{:.2} Mb/s", summary.dl_mbps).cyan().bold(),
bar,
));
}
if summary.dl_peak_mbps > 0.0 {
if nc {
lines.push(format!(
" {:<14} {:.2} Mb/s",
"Peak:", summary.dl_peak_mbps
));
} else {
lines.push(format!(
" {:<14} {}",
"Peak:".dimmed(),
format!("{:.2} Mb/s", summary.dl_peak_mbps).bright_cyan(),
));
}
}
if nc {
lines.push(format!(" {:<14} {:.1}s", "Duration:", summary.dl_duration));
} else {
lines.push(format!(
" {:<14} {}",
"Duration:".dimmed(),
format!("{:.1}s", summary.dl_duration).white(),
));
}
if nc {
lines.push(format!(
" {:<14} {}",
"Transferred:",
common::format_data_size(summary.dl_bytes)
));
} else {
lines.push(format!(
" {:<14} {}",
"Transferred:".dimmed(),
common::format_data_size(summary.dl_bytes).white(),
));
}
lines.join("\n")
}
fn build_upload_summary(summary: &DashboardSummary, nc: bool) -> String {
if summary.ul_duration <= 0.0 {
return String::new();
}
let mut lines = Vec::new();
lines.push(section_divider("Upload Summary", nc));
let bar = metric_bar(summary.ul_mbps, 1000.0, BAR_WIDTH, nc);
if nc {
lines.push(format!(
" {:<14} {:>8.2} Mb/s {bar}",
"Speed:", summary.ul_mbps
));
} else {
lines.push(format!(
" {:<14} {} {}",
"Speed:".dimmed(),
format!("{:.2} Mb/s", summary.ul_mbps).yellow().bold(),
bar,
));
}
if summary.ul_peak_mbps > 0.0 {
if nc {
lines.push(format!(
" {:<14} {:.2} Mb/s",
"Peak:", summary.ul_peak_mbps
));
} else {
lines.push(format!(
" {:<14} {}",
"Peak:".dimmed(),
format!("{:.2} Mb/s", summary.ul_peak_mbps).bright_yellow(),
));
}
}
if nc {
lines.push(format!(" {:<14} {:.1}s", "Duration:", summary.ul_duration));
} else {
lines.push(format!(
" {:<14} {}",
"Duration:".dimmed(),
format!("{:.1}s", summary.ul_duration).white(),
));
}
if nc {
lines.push(format!(
" {:<14} {}",
"Transferred:",
common::format_data_size(summary.ul_bytes)
));
} else {
lines.push(format!(
" {:<14} {}",
"Transferred:".dimmed(),
common::format_data_size(summary.ul_bytes).white(),
));
}
lines.join("\n")
}
fn build_history(nc: bool) -> String {
let recent = history::get_recent_sparkline();
if recent.is_empty() {
if nc {
return String::from(" History: No history available");
}
return format!(
" {} {}",
"History:".dimmed(),
"No history available".bright_black()
);
}
let mut lines = Vec::new();
lines.push(section_divider("History", nc));
let dl_values: Vec<f64> = recent.iter().map(|(_, dl, _)| *dl).collect();
let ul_values: Vec<f64> = recent.iter().map(|(_, _, ul)| *ul).collect();
let dl_spark = history::sparkline(&dl_values);
let ul_spark = history::sparkline(&ul_values);
if nc {
lines.push(format!(" DL sparkline: {dl_spark}"));
lines.push(format!(" UL sparkline: {ul_spark}"));
} else {
lines.push(format!(" {} {}", "DL:".dimmed(), dl_spark.green()));
lines.push(format!(" {} {}", "UL:".dimmed(), ul_spark.yellow()));
}
for (date, dl, ul) in recent.iter().rev().take(3) {
let indicator = if *dl >= 200.0 {
"⚡"
} else if *dl >= 50.0 {
"●"
} else if *dl >= 25.0 {
"◐"
} else {
"○"
};
if nc {
lines.push(format!(" {date} {dl:>7.1}↓ / {ul:>6.1}↑ Mb/s"));
} else {
let indicator_colored = if *dl >= 200.0 {
indicator.green().to_string()
} else if *dl >= 50.0 {
indicator.bright_green().to_string()
} else if *dl >= 25.0 {
indicator.yellow().to_string()
} else {
indicator.red().to_string()
};
lines.push(format!(
" {date} {indicator_colored} {}↓ / {}↑ Mb/s",
format!("{dl:.1}").green(),
format!("{ul:.1}").yellow(),
));
}
}
lines.join("\n")
}
fn build_footer() -> String {
format!(
" {}",
"Tip: Use --list to see servers, --history for full history".bright_black()
)
}
pub fn format_dashboard(
result: &TestResult,
summary: &DashboardSummary,
) -> Result<(), crate::error::SpeedtestError> {
let nc = no_color();
eprintln!();
eprintln!("{}", build_header(result, nc));
eprintln!();
eprintln!("{}", build_overall_rating(result, nc));
eprintln!();
eprintln!("{}", build_metric_bars(result, nc));
eprintln!();
let dl_summary = build_download_summary(summary, nc);
if !dl_summary.is_empty() {
eprintln!("{dl_summary}");
eprintln!();
}
let ul_summary = build_upload_summary(summary, nc);
if !ul_summary.is_empty() {
eprintln!("{ul_summary}");
eprintln!();
}
eprintln!("{}", build_history(nc));
eprintln!();
eprintln!("{}", build_footer());
eprintln!();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ServerInfo;
fn make_result() -> TestResult {
TestResult {
server: ServerInfo {
id: "1".to_string(),
name: "TestServer".to_string(),
sponsor: "TestISP".to_string(),
country: "US".to_string(),
distance: 15.0,
},
ping: Some(12.0),
jitter: Some(1.5),
packet_loss: Some(0.0),
download: Some(150_000_000.0),
download_peak: Some(180_000_000.0),
upload: Some(50_000_000.0),
upload_peak: Some(60_000_000.0),
latency_download: Some(18.0),
latency_upload: Some(15.0),
download_samples: Some(vec![140_000_000.0, 150_000_000.0, 160_000_000.0]),
upload_samples: Some(vec![48_000_000.0, 50_000_000.0, 52_000_000.0]),
ping_samples: Some(vec![11.0, 12.0, 13.0]),
timestamp: "2026-04-06T12:00:00Z".to_string(),
client_ip: Some("192.168.1.100".to_string()),
}
}
#[test]
fn test_metric_bar_half() {
let bar = metric_bar(500.0, 1000.0, 20, true);
assert_eq!(bar.chars().count(), 20);
assert_eq!(bar, "██████████░░░░░░░░░░");
}
#[test]
fn test_metric_bar_full() {
let bar = metric_bar(1000.0, 1000.0, 10, true);
assert_eq!(bar, "██████████");
}
#[test]
fn test_build_header() {
let result = make_result();
let header = build_header(&result, true);
assert!(header.contains("netspeed-cli"));
assert!(header.contains("TestISP"));
assert!(header.contains("192.168.1.100"));
assert!(header.starts_with("╔"));
assert!(header.contains("╚"));
}
#[test]
fn test_build_metric_bars() {
let result = make_result();
let bars = build_metric_bars(&result, true);
assert!(bars.contains("Latency"));
assert!(bars.contains("Download"));
assert!(bars.contains("Upload"));
assert!(bars.contains("█"));
}
#[test]
fn test_build_overall_rating() {
let result = make_result();
let rating = build_overall_rating(&result, true);
assert!(rating.contains("Overall"));
}
#[test]
fn test_build_download_summary() {
let summary = DashboardSummary {
dl_mbps: 150.0,
dl_peak_mbps: 180.0,
dl_bytes: 15_000_000,
dl_duration: 3.2,
ul_mbps: 50.0,
ul_peak_mbps: 60.0,
ul_bytes: 5_000_000,
ul_duration: 2.1,
};
let result = build_download_summary(&summary, true);
assert!(result.contains("Download Summary"));
assert!(result.contains("Speed"));
assert!(result.contains("Peak"));
assert!(result.contains("150.00"));
}
#[test]
fn test_build_upload_summary() {
let summary = DashboardSummary {
dl_mbps: 150.0,
dl_peak_mbps: 180.0,
dl_bytes: 15_000_000,
dl_duration: 3.2,
ul_mbps: 50.0,
ul_peak_mbps: 60.0,
ul_bytes: 5_000_000,
ul_duration: 2.1,
};
let result = build_upload_summary(&summary, true);
assert!(result.contains("Upload Summary"));
assert!(result.contains("Speed"));
assert!(result.contains("50.00"));
}
#[test]
fn test_build_history_no_data() {
let section = build_history(true);
assert!(section.contains("History"));
}
#[test]
fn test_build_footer() {
let footer = build_footer();
assert!(footer.contains("--list"));
assert!(footer.contains("--history"));
}
#[test]
fn test_format_dashboard_integration() {
let result = make_result();
let summary = DashboardSummary {
dl_mbps: 150.0,
dl_peak_mbps: 180.0,
dl_bytes: 15_000_000,
dl_duration: 3.2,
ul_mbps: 50.0,
ul_peak_mbps: 60.0,
ul_bytes: 5_000_000,
ul_duration: 2.1,
};
format_dashboard(&result, &summary).unwrap();
}
#[test]
fn test_format_dashboard_no_color() {
let result = make_result();
let summary = DashboardSummary {
dl_mbps: 150.0,
dl_peak_mbps: 180.0,
dl_bytes: 15_000_000,
dl_duration: 3.2,
ul_mbps: 50.0,
ul_peak_mbps: 60.0,
ul_bytes: 5_000_000,
ul_duration: 2.1,
};
#[allow(unsafe_code)]
unsafe {
std::env::set_var("NO_COLOR", "1");
}
format_dashboard(&result, &summary).unwrap();
#[allow(unsafe_code)]
unsafe {
std::env::remove_var("NO_COLOR");
}
}
#[test]
fn test_section_divider() {
let div = section_divider("Speed", true);
assert!(div.contains("Speed"));
assert!(div.contains("─"));
}
}