use owo_colors::OwoColorize;
use crate::verdict::{PingStats, SpeedStats, Status, Verdict, VerdictLabel};
fn fmt_ms(opt: Option<f64>) -> String {
match opt {
Some(v) => format!("{:.1}", v),
None => "-".to_string(),
}
}
fn fmt_mbps(opt: Option<f64>) -> String {
match opt {
Some(v) => format!("{:.1}", v),
None => "-".to_string(),
}
}
fn render_table(headers: &[&str], rows: &[Vec<String>]) -> String {
let cols = headers.len();
let widths: Vec<usize> = (0..cols)
.map(|c| {
let header_w = headers[c].chars().count();
let row_w = rows.iter().map(|r| r[c].chars().count()).max().unwrap_or(0);
header_w.max(row_w)
})
.collect();
let pad = |c: &str, w: usize| -> String {
let len = c.chars().count();
let mut s = String::with_capacity(w + 2);
s.push(' ');
s.push_str(c);
s.push_str(&" ".repeat(w.saturating_sub(len)));
s.push(' ');
s
};
let mut out = String::new();
out.push('╭');
out.push_str(
&widths
.iter()
.map(|w| "─".repeat(w + 2))
.collect::<Vec<_>>()
.join("┬"),
);
out.push('╮');
out.push('\n');
out.push('│');
out.push_str(
&headers
.iter()
.zip(widths.iter())
.map(|(h, w)| pad(h, *w))
.collect::<Vec<_>>()
.join("│"),
);
out.push('│');
out.push('\n');
out.push('├');
out.push_str(
&widths
.iter()
.map(|w| "─".repeat(w + 2))
.collect::<Vec<_>>()
.join("┼"),
);
out.push('┤');
out.push('\n');
for row in rows {
out.push('│');
out.push_str(
&row.iter()
.zip(widths.iter())
.map(|(cell, w)| pad(cell, *w))
.collect::<Vec<_>>()
.join("│"),
);
out.push('│');
out.push('\n');
}
out.push('╰');
out.push_str(
&widths
.iter()
.map(|w| "─".repeat(w + 2))
.collect::<Vec<_>>()
.join("┴"),
);
out.push('╯');
out
}
pub fn diagnostic_table(pings: &[PingStats], speed: Option<&SpeedStats>) -> String {
let headers = ["target", "latency", "jitter", "loss", "speed"];
let mut rows: Vec<Vec<String>> = pings
.iter()
.map(|p| {
vec![
crate::targets::label_for(&p.target).to_string(),
fmt_ms(p.avg_ms()),
fmt_ms(p.jitter_ms()),
format!("{:.0}%", p.loss_pct()),
"-".to_string(),
]
})
.collect();
if let Some(s) = speed {
rows.push(vec![
"Internet (CF)".to_string(),
"-".to_string(),
"-".to_string(),
"-".to_string(),
format!(
"{} ↓ / {} ↑",
fmt_mbps(s.download_mbps),
fmt_mbps(s.upload_mbps),
),
]);
}
render_table(&headers, &rows)
}
pub fn verdict_banner(verdict: &Verdict, worst: Status) -> String {
use VerdictLabel::*;
let (art, color): (&[&str], fn(&str) -> String) = match verdict.label {
You => (ART_IT_IS_YOU, |s: &str| s.red().bold().to_string()),
Vendor => (ART_IT_IS_THEM, |s: &str| s.yellow().bold().to_string()),
Clear => match worst {
Status::Ok => (ART_IT_IS_NOT_YOU, |s: &str| s.green().bold().to_string()),
_ => (ART_IT_IS_NOT_YOU, |s: &str| s.yellow().bold().to_string()),
},
};
let colored: String = art
.iter()
.map(|line| format!("{}\n", color(line)))
.collect();
format!("{}\n {}\n", colored, verdict.reason)
}
const ART_IT_IS_YOU: &[&str] = &[
"██╗ ██████╗ ██╗ ██╗",
"██║██╔═══██╗██║ ██║",
"██║██║ ██║███████║",
"██║██║ ██║╚██╔══██║",
"╚═╝╚██████╔╝ ╚═╝ ╚═╝",
" ╚═════╝ ",
];
const ART_IT_IS_NOT_YOU: &[&str] = &[
"██╗ ██████╗ ██╗ ██╗ ███╗ ██╗ ██████╗ ██╗ ██╗",
"██║██╔═══██╗██║ ██║ ████╗ ██║██╔═══██╗██║ ██║",
"██║██║ ██║███████║ ██╔██╗ ██║██║ ██║███████║",
"██║██║ ██║╚██╔══██║ ██║╚██╗██║██║ ██║╚██╔══██║",
"╚═╝╚██████╔╝ ╚═╝ ╚═╝ ██║ ╚████║╚██████╔╝ ╚═╝ ╚═╝",
" ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝ ",
];
const ART_IT_IS_THEM: &[&str] = &[
"████████╗██╗ ██╗███████╗███╗ ███╗",
"╚══██╔══╝██║ ██║██╔════╝████╗ ████║",
" ██║ ███████║█████╗ ██╔████╔██║",
" ██║ ██╔══██║██╔══╝ ██║╚██╔╝██║",
" ╚═╝ ╚═╝ ╚═╝███████╗╚═╝ ╚═╝╚═╝",
" ╚══════╝ ",
];
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use crate::verdict::PingStats;
fn stats(target: &str, samples: &[f64], sent: u32, recv: u32) -> PingStats {
PingStats {
target: target.to_string(),
samples_ms: samples.to_vec(),
packets_sent: sent,
packets_received: recv,
}
}
#[test]
fn fmt_ms_handles_some_and_none() {
assert_eq!(fmt_ms(Some(12.34)), "12.3");
assert_eq!(fmt_ms(None), "-");
}
#[test]
fn fmt_mbps_handles_some_and_none() {
assert_eq!(fmt_mbps(Some(50.67)), "50.7");
assert_eq!(fmt_mbps(None), "-");
}
#[test]
fn render_table_empty_rows_has_header_only() {
let t = render_table(&["a", "b"], &[]);
assert!(t.contains('╭'));
assert!(t.contains('╯'));
assert!(t.contains("│ a │ b │"));
}
#[test]
fn render_table_aligns_to_widest_cell() {
let t = render_table(&["h", "name"], &[vec!["x".to_string(), "longvalue".to_string()]]);
assert!(t.contains("│ longvalue │"));
assert!(t.contains("│ name │"));
}
#[test]
fn render_table_handles_unicode_width() {
let t = render_table(&["h"], &[vec!["é".to_string()]]);
assert!(t.contains("│ é │"));
}
#[test]
fn diagnostic_table_shows_label_and_loss() {
let pings = vec![stats("8.8.8.8", &[10.0, 20.0], 2, 2)];
let t = diagnostic_table(&pings, None);
assert!(t.contains("Google DNS (baseline)"));
assert!(t.contains("0%"));
}
#[test]
fn diagnostic_table_has_five_headers() {
let pings = vec![stats("8.8.8.8", &[10.0, 20.0], 2, 2)];
let t = diagnostic_table(&pings, None);
for h in ["target", "latency", "jitter", "loss", "speed"] {
assert!(t.contains(h), "missing header {}", h);
}
}
#[test]
fn diagnostic_table_appends_speed_row_when_given() {
let pings = vec![stats("zoom.us", &[10.0, 20.0], 2, 2)];
let s = SpeedStats {
download_mbps: Some(50.0),
upload_mbps: Some(10.0),
};
let t = diagnostic_table(&pings, Some(&s));
assert!(t.contains("Internet (CF)"));
assert!(t.contains("50.0"));
assert!(t.contains("10.0"));
assert!(t.contains("↓"));
assert!(t.contains("↑"));
}
#[test]
fn diagnostic_table_speed_dash_when_no_speed() {
let pings = vec![stats("8.8.8.8", &[10.0, 20.0], 2, 2)];
let t = diagnostic_table(&pings, None);
assert!(!t.contains("Internet (CF)"));
}
#[test]
fn verdict_banner_shows_headline_words_and_reason() {
let v = Verdict {
label: VerdictLabel::Clear,
reason: "test reason here".to_string(),
};
let banner = verdict_banner(&v, Status::Ok);
let stripped = strip_ansi(&banner);
assert!(stripped.contains("█"));
assert!(stripped.contains("test reason here"));
}
#[test]
fn verdict_banner_you_when_label_you() {
let v = Verdict {
label: VerdictLabel::You,
reason: "your network".to_string(),
};
let banner = verdict_banner(&v, Status::Bad);
let stripped = strip_ansi(&banner);
assert!(stripped.contains("█"));
assert!(stripped.contains("your network"));
}
#[test]
fn verdict_banner_vendor_when_label_vendor() {
let v = Verdict {
label: VerdictLabel::Vendor,
reason: "zoom down".to_string(),
};
let banner = verdict_banner(&v, Status::Bad);
let stripped = strip_ansi(&banner);
assert!(stripped.contains("█"));
assert!(stripped.contains("zoom down"));
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut in_esc = false;
for c in s.chars() {
if c == '\x1b' {
in_esc = true;
} else if in_esc && c == 'm' {
in_esc = false;
} else if !in_esc {
out.push(c);
}
}
out
}
}