use crate::model::{Phase, RunResult, TestEvent};
use std::path::Path;
pub fn format_saved_line(path: &Path) -> String {
format!("Saved: {}", path.display())
}
pub fn format_event_lines(ev: &TestEvent) -> Vec<String> {
match ev {
TestEvent::PhaseStarted { phase } => vec![format!("== {phase:?} ==")],
TestEvent::ThroughputTick {
phase, bps_instant, ..
} => {
if matches!(phase, Phase::Download | Phase::Upload) {
let mbps = (bps_instant * 8.0) / 1_000_000.0;
vec![format!("{phase:?}: {:.2} Mbps", mbps)]
} else {
Vec::new()
}
}
TestEvent::LatencySample {
phase,
ok,
rtt_ms,
during,
} => {
if !ok {
return Vec::new();
}
let Some(ms) = rtt_ms else { return Vec::new() };
match (phase, during) {
(Phase::IdleLatency, None) => vec![format!("Idle latency: {:.1} ms", ms)],
_ => Vec::new(),
}
}
TestEvent::Info { message } => vec![message.clone()],
TestEvent::UdpLossProgress {
sent,
received,
total,
rtt_ms,
} => {
let loss_pct = if *sent == 0 {
0.0
} else {
((sent.saturating_sub(*received)) as f64) * 100.0 / *sent as f64
};
let rtt_display = rtt_ms
.map(|v| format!("{:.1}ms", v))
.unwrap_or_else(|| "timeout".to_string());
vec![format!(
"Packet loss probe: {}/{} recv {} loss {:.1}% ({})",
sent, total, received, loss_pct, rtt_display
)]
}
TestEvent::MetaInfo { .. } => Vec::new(),
TestEvent::DiagnosticDns { summary } => {
vec![format!("DNS: {:.2}ms", summary.resolution_time_ms)]
}
TestEvent::DiagnosticTls { summary } => vec![format!(
"TLS: handshake {:.2}ms, {} {}",
summary.handshake_time_ms,
summary.protocol_version.as_deref().unwrap_or("-"),
summary.cipher_suite.as_deref().unwrap_or("-")
)],
TestEvent::DiagnosticIpComparison { comparison } => {
let mut lines = Vec::new();
if let Some(ref v4) = comparison.ipv4_result {
if v4.available {
lines.push(format!(
"IPv4: {} - DL {:.2} Mbps, UL {:.2} Mbps, latency {:.1}ms",
v4.ip_address, v4.download_mbps, v4.upload_mbps, v4.latency_ms
));
} else {
lines.push(format!("IPv4: unavailable - {:?}", v4.error));
}
}
if let Some(ref v6) = comparison.ipv6_result {
if v6.available {
lines.push(format!(
"IPv6: {} - DL {:.2} Mbps, UL {:.2} Mbps, latency {:.1}ms",
v6.ip_address, v6.download_mbps, v6.upload_mbps, v6.latency_ms
));
} else {
lines.push(format!("IPv6: unavailable - {:?}", v6.error));
}
}
lines
}
TestEvent::TracerouteHop { hop_number, hop } => {
let addr = hop.ip_address.as_deref().unwrap_or("*");
let rtts: Vec<String> = hop.rtt_ms.iter().map(|r| format!("{:.1}ms", r)).collect();
let rtt_str = if rtts.is_empty() {
"*".to_string()
} else {
rtts.join(" ")
};
vec![format!("{:>2} {} {}", hop_number, addr, rtt_str)]
}
TestEvent::TracerouteComplete { summary } => vec![format!(
"Traceroute to {} {} ({} hops)",
summary.destination,
if summary.completed {
"completed"
} else {
"incomplete"
},
summary.hops.len()
)],
TestEvent::ExternalIps { ipv4, ipv6 } => {
let v4 = ipv4.as_deref().unwrap_or("-");
let v6 = ipv6.as_deref().unwrap_or("-");
vec![format!("External IPs: v4={} v6={}", v4, v6)]
}
}
}
const THROUGHPUT_LABEL_WIDTH: usize = 10;
fn fmt_throughput(label: &str, values: &[f64]) -> Option<String> {
let (mean, median, p25, p75) = crate::metrics::compute_metrics(values)?;
Some(format!(
"{:<width$}avg {:.2} med {:.2} p25 {:.2} p75 {:.2}",
format!("{}:", label),
mean,
median,
p25,
p75,
width = THROUGHPUT_LABEL_WIDTH,
))
}
fn fmt_latency(
label: &str,
samples: &[f64],
summary: &crate::model::LatencySummary,
) -> Option<String> {
let (mean, median, p25, p75) = crate::metrics::compute_metrics(samples)?;
Some(format!(
"{}: avg {:.1} med {:.1} p25 {:.1} p75 {:.1} ms (loss {:.1}%, jitter {:.1} ms)",
label,
mean,
median,
p25,
p75,
summary.loss * 100.0,
summary.jitter_ms.unwrap_or(f64::NAN)
))
}
fn y_values(points: &[(f64, f64)]) -> Vec<f64> {
points.iter().map(|(_, y)| *y).collect()
}
pub fn format_result_summary(
result: &RunResult,
dl_points: &[(f64, f64)],
ul_points: &[(f64, f64)],
idle_lat_samples: &[f64],
dl_lat_samples: &[f64],
ul_lat_samples: &[f64],
) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if let Some(meta) = result.meta.as_ref() {
let extracted = crate::network::extract_metadata(meta);
let ip = extracted.ip.as_deref().unwrap_or("-");
let colo = extracted.colo.as_deref().unwrap_or("-");
let asn = extracted.asn.as_deref().unwrap_or("-");
let org = extracted.as_org.as_deref().unwrap_or("-");
out.push(format!("IP/Colo/ASN: {ip} / {colo} / {asn} ({org})"));
}
if let Some(server) = result.server.as_deref() {
out.push(format!("Server: {server}"));
}
if let Some(comments) = result.comments.as_deref() {
if !comments.trim().is_empty() {
out.push(format!("Comments: {}", comments));
}
}
out.extend(fmt_throughput("Download", &y_values(dl_points)));
out.extend(fmt_throughput("Upload", &y_values(ul_points)));
out.extend(fmt_latency(
"Idle latency",
idle_lat_samples,
&result.idle_latency,
));
out.extend(fmt_latency(
"Loaded latency (download)",
dl_lat_samples,
&result.loaded_latency_download,
));
out.extend(fmt_latency(
"Loaded latency (upload)",
ul_lat_samples,
&result.loaded_latency_upload,
));
if let Some(ref exp) = result.experimental_udp {
let mos_str = exp
.mos
.map(|m| format!("MOS {:.1}", m))
.unwrap_or_else(|| "N/A".to_string());
let jitter_str = exp
.latency
.jitter_ms
.map(|j| format!("{:.1}ms", j))
.unwrap_or_else(|| "-".to_string());
out.push(format!(
"UDP quality: {} ({}) | loss {:.1}% jitter {} reorder {:.1}% rtt {}ms",
exp.quality_label,
mos_str,
exp.latency.loss * 100.0,
jitter_str,
exp.out_of_order_pct,
exp.latency.median_ms.unwrap_or(f64::NAN)
));
}
if let Some(ref cq) = result.connection_quality {
if let Some(ms) = cq.bufferbloat_ms {
out.push(format!("Bufferbloat: {} ({:.0}ms)", cq.bufferbloat_grade, ms));
}
if let Some(cv) = cq.stability_cv_pct {
let mut detail = format!("CV {:.1}%", cv);
if let Some(dl) = cq.stability_cv_download_pct {
detail.push_str(&format!(", DL {:.1}%", dl));
}
if let Some(ul) = cq.stability_cv_upload_pct {
detail.push_str(&format!(", UL {:.1}%", ul));
}
out.push(format!("Stability: {} ({})", cq.stability_grade, detail));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{empty_run_result, ConnectionQuality};
#[test]
fn summary_omits_connection_quality_when_absent() {
let r = empty_run_result();
let out = format_result_summary(&r, &[], &[], &[], &[], &[]);
assert!(!out.iter().any(|l| l.starts_with("Bufferbloat:")));
assert!(!out.iter().any(|l| l.starts_with("Stability:")));
}
#[test]
fn summary_includes_connection_quality_when_present() {
let mut r = empty_run_result();
r.connection_quality = Some(ConnectionQuality {
bufferbloat_grade: "B".into(),
bufferbloat_ms: Some(47.3),
stability_grade: "A".into(),
stability_cv_pct: Some(3.8),
stability_cv_download_pct: Some(3.1),
stability_cv_upload_pct: Some(3.8),
});
let out = format_result_summary(&r, &[], &[], &[], &[], &[]);
assert!(out.iter().any(|l| l == "Bufferbloat: B (47ms)"));
assert!(out.iter().any(|l| l == "Stability: A (CV 3.8%, DL 3.1%, UL 3.8%)"));
}
#[test]
fn summary_skips_per_direction_when_unavailable() {
let mut r = empty_run_result();
r.connection_quality = Some(ConnectionQuality {
bufferbloat_grade: "-".into(),
bufferbloat_ms: None,
stability_grade: "A".into(),
stability_cv_pct: Some(5.0),
stability_cv_download_pct: None,
stability_cv_upload_pct: Some(5.0),
});
let out = format_result_summary(&r, &[], &[], &[], &[], &[]);
assert!(!out.iter().any(|l| l.starts_with("Bufferbloat:")));
assert!(out.iter().any(|l| l == "Stability: A (CV 5.0%, UL 5.0%)"));
}
}