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 latency_table(pings: &[PingStats]) -> String {
let headers = ["target", "latency", "jitter", "loss"];
let 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()),
]
})
.collect();
render_table(&headers, &rows)
}
pub fn speed_table(speed: &SpeedStats) -> String {
let headers = ["target", "speed"];
let rows = vec![vec![
"Internet (CF)".to_string(),
format!(
"{} ↓ / {} ↑",
fmt_mbps(speed.download_mbps),
fmt_mbps(speed.upload_mbps),
),
]];
render_table(&headers, &rows)
}
pub fn verdict_banner(verdict: &Verdict, worst: Status) -> String {
use VerdictLabel::*;
let (text, color): (&str, fn(&str) -> String) = match verdict.label {
You => ("IT IS YOU", |s: &str| s.red().bold().to_string()),
Vendor => ("IT IS THEM", |s: &str| s.yellow().bold().to_string()),
Clear => match worst {
Status::Ok => ("IT IS NOT YOU", |s: &str| s.green().bold().to_string()),
_ => ("IT IS NOT YOU", |s: &str| s.yellow().bold().to_string()),
},
};
let art = figlet_banner(text).unwrap_or_else(|| text.to_string());
let colored: String = art
.lines()
.map(|line| format!("{}\n", color(line)))
.collect();
format!("{}\n {}\n", colored, verdict.reason)
}
fn figlet_banner(text: &str) -> Option<String> {
let font = figlet_rs::FIGlet::big().ok()?;
let figure = font.convert(text)?;
Some(figure.as_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 latency_table_shows_label_and_loss() {
let pings = vec![stats("8.8.8.8", &[10.0, 20.0], 2, 2)];
let t = latency_table(&pings);
assert!(t.contains("Google DNS (baseline)"));
assert!(t.contains("0%"));
}
#[test]
fn latency_table_has_four_headers() {
let pings = vec![stats("8.8.8.8", &[10.0, 20.0], 2, 2)];
let t = latency_table(&pings);
for h in ["target", "latency", "jitter", "loss"] {
assert!(t.contains(h), "missing header {}", h);
}
}
#[test]
fn speed_table_shows_down_and_up() {
let s = SpeedStats {
download_mbps: Some(50.0),
upload_mbps: Some(10.0),
};
let t = speed_table(&s);
assert!(t.contains("Internet (CF)"));
assert!(t.contains("50.0"));
assert!(t.contains("10.0"));
assert!(t.contains("↓"));
assert!(t.contains("↑"));
}
#[test]
fn latency_table_has_no_speed_column() {
let pings = vec![stats("8.8.8.8", &[10.0, 20.0], 2, 2)];
let t = latency_table(&pings);
assert!(!t.contains("speed"));
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('_') || 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('_') || 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('_') || 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
}
}