use std::fmt::Write as _;
use serde::Serialize;
use crate::stats::Endpoint;
const RULE: usize = 80;
const DOMAIN_COL: usize = 34;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub struct Totals {
pub ingress_bytes: u64,
pub egress_bytes: u64,
pub connections: u64,
}
impl Totals {
pub fn of<'a>(endpoints: impl IntoIterator<Item = &'a Endpoint>) -> Self {
endpoints
.into_iter()
.fold(Self::default(), |mut acc, endpoint| {
acc.ingress_bytes += endpoint.ingress_bytes;
acc.egress_bytes += endpoint.egress_bytes;
acc.connections += endpoint.connections;
acc
})
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub struct TotalsBreakdown {
pub matching_filter: Totals,
pub all_destinations: Totals,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Report {
pub command: String,
pub domain_prefix_filter: Option<String>,
pub execution_time_ms: u128,
pub exit_code: i32,
pub totals: TotalsBreakdown,
pub endpoints: Vec<Endpoint>,
}
impl Report {
pub fn new(
command: String,
domain_prefix_filter: Option<String>,
execution_time_ms: u128,
exit_code: i32,
endpoints: Vec<Endpoint>,
) -> Self {
let totals = TotalsBreakdown {
matching_filter: Totals::of(endpoints.iter().filter(|e| e.matches_filter)),
all_destinations: Totals::of(endpoints.iter()),
};
Self {
command,
domain_prefix_filter,
execution_time_ms,
exit_code,
totals,
endpoints,
}
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
pub fn to_text(&self) -> String {
let rule = "=".repeat(RULE);
let thin = "-".repeat(RULE);
let mut out = String::with_capacity(1024);
let _ = writeln!(out, "{rule}");
let _ = writeln!(out, "{:^width$}", "WIRETALLY TRAFFIC SUMMARY", width = RULE);
let _ = writeln!(out, "{rule}");
let _ = writeln!(out, "Command: {}", self.command);
let filter = match &self.domain_prefix_filter {
Some(prefix) => format!("*.{prefix} (Prefix Match)"),
None => "none (all destinations)".to_owned(),
};
let _ = writeln!(out, "Target Filter: {filter}");
let _ = writeln!(
out,
"Execution Time: {:.2}s",
self.execution_time_ms as f64 / 1000.0
);
let _ = writeln!(out, "Exit Code: {}", self.exit_code);
let _ = writeln!(out);
let _ = writeln!(out, "DOMAIN / ENDPOINT SUMMARY:");
let _ = writeln!(out, "{thin}");
let _ = writeln!(
out,
"{:<DOMAIN_COL$}{:>14}{:>17}{:>8}",
"ENDPOINT / DOMAIN", "INGRESS (Rx)", "EGRESS (Tx)", "CONNS"
);
let _ = writeln!(out, "{thin}");
let shown: Vec<&Endpoint> = self
.endpoints
.iter()
.filter(|endpoint| endpoint.matches_filter)
.collect();
if shown.is_empty() {
let _ = writeln!(out, "(no matching traffic observed)");
}
for endpoint in shown {
let _ = writeln!(
out,
"{:<DOMAIN_COL$}{:>14}{:>17}{:>8}",
elide(&endpoint.domain, DOMAIN_COL - 1),
format_bytes(endpoint.ingress_bytes),
format_bytes(endpoint.egress_bytes),
endpoint.connections
);
}
let _ = writeln!(out, "{thin}");
for (label, totals) in [
("TOTAL (Matching Filter):", self.totals.matching_filter),
("TOTAL (All Destinations):", self.totals.all_destinations),
] {
let _ = writeln!(
out,
"{:<DOMAIN_COL$}{:>14}{:>17}{:>8}",
label,
format_bytes(totals.ingress_bytes),
format_bytes(totals.egress_bytes),
totals.connections
);
}
let _ = writeln!(out, "{rule}");
out
}
}
pub fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["KB", "MB", "GB", "TB", "PB"];
if bytes < 1024 {
return format!("{bytes} B");
}
let mut value = bytes as f64 / 1024.0;
let mut unit = UNITS[0];
for next in &UNITS[1..] {
if value < 1024.0 {
break;
}
value /= 1024.0;
unit = next;
}
format!("{value:.2} {unit}")
}
fn elide(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_owned();
}
let kept: String = text.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}
#[cfg(test)]
mod tests {
use super::*;
fn endpoint(domain: &str, rx: u64, tx: u64, conns: u64, matches: bool) -> Endpoint {
Endpoint {
domain: domain.to_owned(),
ip_address: Some("10.0.0.1".parse().unwrap()),
ingress_bytes: rx,
egress_bytes: tx,
connections: conns,
matches_filter: matches,
}
}
#[test]
fn byte_formatting_matches_spec_examples() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(1023), "1023 B");
assert_eq!(format_bytes(1024), "1.00 KB");
assert_eq!(format_bytes(4311), "4.21 KB");
assert_eq!(format_bytes(149_760_000), "142.82 MB");
assert_eq!(format_bytes(2 * 1024 * 1024 * 1024), "2.00 GB");
}
#[test]
fn totals_split_filtered_from_all() {
let report = Report::new(
"curl x".to_owned(),
Some("example.com".to_owned()),
1840,
0,
vec![
endpoint("a.example.com", 100, 10, 2, true),
endpoint("other.dev", 5, 1, 1, false),
],
);
assert_eq!(report.totals.matching_filter.ingress_bytes, 100);
assert_eq!(report.totals.all_destinations.ingress_bytes, 105);
assert_eq!(report.totals.all_destinations.connections, 3);
}
#[test]
fn text_report_hides_nonmatching_rows_but_keeps_them_in_totals() {
let report = Report::new(
"curl x".to_owned(),
Some("example.com".to_owned()),
1000,
0,
vec![
endpoint("a.example.com", 100, 10, 1, true),
endpoint("other.dev", 5, 1, 1, false),
],
);
let text = report.to_text();
assert!(text.contains("a.example.com"));
assert!(!text.contains("other.dev"));
assert!(text.contains("TOTAL (All Destinations):"));
assert!(text.lines().all(|line| line.chars().count() <= RULE + 4));
}
#[test]
fn json_uses_the_specified_field_names() {
let report = Report::new(
"mcap info".to_owned(),
Some("amazonaws.com".to_owned()),
1840,
0,
vec![endpoint(
"s3.amazonaws.com",
149_760_000,
1_174_405,
8,
true,
)],
);
let json: serde_json::Value = serde_json::from_str(&report.to_json().unwrap()).unwrap();
assert_eq!(json["domain_prefix_filter"], "amazonaws.com");
assert_eq!(json["execution_time_ms"], 1840);
assert_eq!(
json["totals"]["matching_filter"]["ingress_bytes"],
149_760_000
);
assert_eq!(json["endpoints"][0]["ip_address"], "10.0.0.1");
assert_eq!(json["endpoints"][0]["matches_filter"], true);
}
#[test]
fn elide_keeps_column_width() {
let long = "a".repeat(60);
assert_eq!(elide(&long, 10).chars().count(), 10);
assert_eq!(elide("short", 10), "short");
}
}